From 515bf2d6bb6934ddb057762b55495d1c908014b9 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:00:25 -0700 Subject: [PATCH 001/351] Fix restored terminals rendering blank on mobile (#8768) Co-authored-by: Orca --- src/main/daemon/daemon-pty-adapter.test.ts | 6 + src/main/daemon/daemon-pty-adapter.ts | 22 +- src/main/ipc/pty.test.ts | 76 +++- src/main/ipc/pty.ts | 34 +- src/main/providers/types.ts | 6 + src/main/runtime/orca-runtime.test.ts | 395 ++++++++++++++++++ src/main/runtime/orca-runtime.ts | 222 +++++++++- ...erminal-provider-snapshot-sequence.test.ts | 187 +++++++++ ...rminal-kitty-keyboard-mode-tracker.test.ts | 3 + .../terminal-kitty-keyboard-mode-tracker.ts | 12 + 10 files changed, 954 insertions(+), 9 deletions(-) create mode 100644 src/main/runtime/rpc/terminal-provider-snapshot-sequence.test.ts diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index 37896ab83363..1b3b5fb0b7ee 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -132,6 +132,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { const result = await adapter.spawn({ cols: 80, rows: 24 }) expect(result.id).toBeDefined() expect(typeof result.id).toBe('string') + expect(result.providerSequence).toEqual({ value: 0, generation: 'reset' }) }) it('uses worktreeId as session prefix when provided', async () => { @@ -506,6 +507,10 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { expect(second.launchAgent).toBe('droid') expect(second.snapshot).toBeDefined() expect(second.snapshot).toContain('hello from shell') + expect(second.providerSequence).toEqual({ + value: 'hello from shell\r\n'.length, + generation: 'continued' + }) }) it('includes rehydrateSequences in snapshot when terminal modes are active', async () => { @@ -528,6 +533,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { expect(result.id).toBe('brand-new') expect(result.isReattach).toBeUndefined() expect(result.snapshot).toBeUndefined() + expect(result.providerSequence).toEqual({ value: 0, generation: 'reset' }) }) }) diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index 0fc077293839..151969928be2 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -47,6 +47,17 @@ function getRecoveredHistorySeed(restoreInfo: ColdRestoreInfo): string | null { : restoreInfo.rehydrateSequences + restoreInfo.snapshotAnsi } +function providerSequenceForSpawn( + result: CreateOrAttachResult +): PtySpawnResult['providerSequence'] { + if (result.isNew) { + return { value: 0, generation: 'reset' } + } + return typeof result.snapshot?.outputSequence === 'number' + ? { value: result.snapshot.outputSequence, generation: 'continued' } + : undefined +} + export type DaemonPtyAdapterOptions = { socketPath: string tokenPath: string @@ -322,6 +333,7 @@ export class DaemonPtyAdapter implements IPtyProvider { const wasAlreadyManaged = this.activeSessionIds.has(sessionId) this.activeSessionIds.add(sessionId) + const providerSequence = providerSequenceForSpawn(result) // Cold restore: daemon created a new session but disk history shows // an unclean shutdown → return saved scrollback so the renderer can @@ -353,10 +365,16 @@ export class DaemonPtyAdapter implements IPtyProvider { pid, ...launchIdentity(), coldRestore, + ...(providerSequence ? { providerSequence } : {}), ...(!result.isNew ? { isReattach: true } : {}) } } - return { id: sessionId, pid, ...launchIdentity() } + return { + id: sessionId, + pid, + ...launchIdentity(), + ...(providerSequence ? { providerSequence } : {}) + } } if (this.historyManager && result.isNew) { @@ -394,6 +412,7 @@ export class DaemonPtyAdapter implements IPtyProvider { id: sessionId, pid, ...launchIdentity(), + ...(providerSequence ? { providerSequence } : {}), ...(isReattach ? { isReattach: true } : {}) } } @@ -415,6 +434,7 @@ export class DaemonPtyAdapter implements IPtyProvider { snapshot: snapshotPayload, snapshotCols: result.snapshot.cols, snapshotRows: result.snapshot.rows, + ...(providerSequence ? { providerSequence } : {}), ...(typeof kittyKeyboardFlags === 'number' && kittyKeyboardFlags > 0 ? { snapshotKittyKeyboardFlags: kittyKeyboardFlags } : {}), diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 40efbe699083..6adb7e5cf691 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -10506,6 +10506,44 @@ describe('registerPtyHandlers', () => { expect(mockProc.proc.write).not.toHaveBeenCalled() }) + it('synchronizes runtime output sequencing from a provider reattach snapshot', async () => { + setLocalPtyProvider({ + spawn: vi.fn(async () => ({ + id: 'pty-restored', + isReattach: true, + providerSequence: { value: 900, generation: 'continued' as const } + })), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + shutdown: vi.fn(), + onData: vi.fn(() => vi.fn()), + onExit: vi.fn(() => vi.fn()), + listProcesses: vi.fn(async () => []), + getForegroundProcess: vi.fn(async () => null) + } as never) + const runtime = { + setPtyController: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + getPtyOutputSequence: vi.fn().mockReturnValue(7), + synchronizePtyOutputSequenceFromProvider: vi.fn(), + onPtySpawned: vi.fn(), + onPtyData: vi.fn(), + onPtyExit: vi.fn(), + createPreAllocatedTerminalHandle: vi.fn(() => null), + preAllocateHandleForPty: vi.fn() + } + registerPtyHandlers(mainWindow as never, runtime as never) + + await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 }) + + expect(runtime.synchronizePtyOutputSequenceFromProvider).toHaveBeenCalledWith( + 'pty-restored', + { value: 900, generation: 'continued' }, + 7 + ) + }) + it('seeds headless terminal state with cold-restore cwd metadata', async () => { const oscLinks = [{ row: 0, startCol: 0, endCol: 8, uri: 'https://example.com/restored' }] const coldRestore = { @@ -10543,7 +10581,7 @@ describe('registerPtyHandlers', () => { 'pty-cold-restore', 'restored history\r\n', undefined, - { cwd: '/projects/restored', oscLinks } + { cwd: '/projects/restored', oscLinks, preferProviderIfExisting: true } ) }) @@ -11360,6 +11398,42 @@ describe('registerPtyHandlers', () => { }) }) + describe('provider buffer snapshot dispatch', () => { + it('exposes daemon history when no renderer pane is mounted', async () => { + const provider = installObservableDaemonTestProvider() + provider.getBufferSnapshot.mockResolvedValue({ + data: 'restored screen\r\n', + scrollbackAnsi: 'restored history\r\n', + cols: 120, + rows: 40, + cwd: '/projects/restored', + seq: 900, + source: 'headless' + }) + const runtime = { setPtyController: vi.fn() } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as { + serializeProviderBuffer(ptyId: string, opts?: { scrollbackRows?: number }): Promise + } + + await expect( + controller.serializeProviderBuffer('daemon-restored', { scrollbackRows: 5000 }) + ).resolves.toEqual({ + data: 'restored screen\r\n', + scrollbackAnsi: 'restored history\r\n', + cols: 120, + rows: 40, + cwd: '/projects/restored', + seq: 900, + source: 'headless' + }) + expect(provider.getBufferSnapshot).toHaveBeenCalledWith('daemon-restored', { + scrollbackRows: 5000 + }) + }) + }) + describe('main buffer snapshot dispatch', () => { it('returns a hidden-output recovery snapshot with clamped scrollback', async () => { const runtime = { diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 67db1b690cdd..ffaa3a3990d4 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -3146,7 +3146,18 @@ export function registerPtyHandlers( if (args.preAllocatedHandle) { trustedTerminalHandleEnv.add(args.preAllocatedHandle) } + const expectedPtyId = effectiveSessionAppId ?? sessionId + const sequenceBeforeProviderSpawn = expectedPtyId + ? (runtime?.getPtyOutputSequence?.(expectedPtyId) ?? 0) + : 0 result = await provider.spawn(spawnOptions) + if (result.providerSequence) { + runtime?.synchronizePtyOutputSequenceFromProvider?.( + result.id, + result.providerSequence, + sequenceBeforeProviderSpawn + ) + } } catch (err) { const rawMessage = err instanceof Error ? err.message : String(err) const spawnError = normalizeNodePtySpawnError(err) @@ -3487,6 +3498,15 @@ export function registerPtyHandlers( // state and dimensions before live TUI chunks can render correctly. return requestSerializedBuffer(ptyId, opts) }, + serializeProviderBuffer: async (ptyId, opts) => { + try { + // Why: restored daemon PTYs can be live while their desktop pane stays + // unmounted; query the provider model so phone-local navigation works. + return (await getProviderForPty(ptyId).getBufferSnapshot?.(ptyId, opts)) ?? null + } catch { + return null + } + }, hasRendererSerializer: (ptyId) => { // Why: the runtime needs a synchronous probe so it can decide whether to // skip the daemon-snapshot seed (the renderer will hydrate it) or run the @@ -4058,7 +4078,18 @@ export function registerPtyHandlers( ) } spawnTiming.mark('options') + const expectedPtyId = effectiveSessionAppId ?? effectiveSessionId + const sequenceBeforeProviderSpawn = expectedPtyId + ? (runtime?.getPtyOutputSequence?.(expectedPtyId) ?? 0) + : 0 result = await provider.spawn(spawnOptions) + if (result.providerSequence) { + runtime?.synchronizePtyOutputSequenceFromProvider?.( + result.id, + result.providerSequence, + sequenceBeforeProviderSpawn + ) + } spawnTiming.mark('provider_spawn') } catch (err) { // Why: a failed spawn must not leave a stale hidden mark on a session @@ -4293,7 +4324,8 @@ export function registerPtyHandlers( ) { runtime.seedHeadlessTerminal(result.id, result.coldRestore.scrollback, seedSize, { cwd: result.coldRestore.cwd, - oscLinks: result.coldRestore.oscLinks + oscLinks: result.coldRestore.oscLinks, + preferProviderIfExisting: true }) } } diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index f6e4042f482a..ab6012cb7b92 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -117,6 +117,12 @@ export type PtySpawnResult = { * writing the snapshot so ANSI cursor positions land correctly. */ snapshotCols?: number snapshotRows?: number + /** Provider sequence at the attach boundary. `reset` starts a new provider + * generation; `continued` resumes the existing absolute domain. */ + providerSequence?: { + value: number + generation: 'continued' | 'reset' + } /** Kitty keyboard flags persisted in the daemon snapshot, threaded so the * re-seeded runtime emulator answers hidden `CSI ? u` with the real flags * (terminal-query-authority.md §kitty). Never replayed into a renderer diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 2122e17d9a23..994328da8756 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -7117,6 +7117,153 @@ describe('OrcaRuntimeService', () => { expect(runtime.getPtyOutputSequence('pty-1')).toBe(0) }) + it('aligns a restored session and pre-response bytes to the provider sequence', async () => { + const { runtime } = createSideEffectRuntime() + + // Simulate a stream-socket byte winning the race with the spawn response. + runtime.onPtyData('pty-restored', 'queued', 100) + expect( + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-restored', + { value: 900, generation: 'continued' }, + 0 + ) + ).toBe(906) + runtime.onPtyData('pty-restored', 'fresh', 101) + + expect(runtime.getPtyOutputSequence('pty-restored')).toBe(911) + await expect(runtime.serializeMainTerminalBuffer('pty-restored')).resolves.toMatchObject({ + data: expect.stringContaining('queuedfresh'), + seq: 911, + source: 'headless' + }) + }) + + it('does not double a fresh daemon sequence on same-main reattach', () => { + const { runtime } = createSideEffectRuntime() + + expect( + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-fresh', + { value: 0, generation: 'reset' }, + 0 + ) + ).toBe(0) + runtime.onPtyData('pty-fresh', 'fresh output', 100) + const sequenceBeforeReattach = runtime.getPtyOutputSequence('pty-fresh') + + expect( + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-fresh', + { value: sequenceBeforeReattach, generation: 'continued' }, + sequenceBeforeReattach + ) + ).toBe(sequenceBeforeReattach) + }) + + it('does not jump ahead of delayed bytes covered by a reattach snapshot', () => { + const { runtime } = createSideEffectRuntime() + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-delayed', + { value: 0, generation: 'reset' }, + 0 + ) + runtime.onPtyData('pty-delayed', 'before', 100) + const sequenceBeforeReattach = runtime.getPtyOutputSequence('pty-delayed') + const delayedCoveredBytes = 'queued' + + expect( + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-delayed', + { + value: sequenceBeforeReattach + delayedCoveredBytes.length, + generation: 'continued' + }, + sequenceBeforeReattach + ) + ).toBe(sequenceBeforeReattach) + runtime.onPtyData('pty-delayed', delayedCoveredBytes, 101) + + expect(runtime.getPtyOutputSequence('pty-delayed')).toBe( + sequenceBeforeReattach + delayedCoveredBytes.length + ) + }) + + it('retains bytes emitted before a fresh daemon spawn resolves', async () => { + const { runtime } = createSideEffectRuntime() + const earlyOutput = '\x1b]0;Fresh shell\x07early prompt' + runtime.onPtyData('pty-fresh', earlyOutput, 100) + + expect( + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-fresh', + { value: 0, generation: 'reset' }, + 0 + ) + ).toBe(earlyOutput.length) + + await expect(runtime.serializeMainTerminalBuffer('pty-fresh')).resolves.toMatchObject({ + data: expect.stringContaining('early prompt'), + lastTitle: 'Fresh shell', + seq: earlyOutput.length + }) + }) + + it('drops stale headless state without rewinding the runtime sequence', async () => { + const { runtime } = createSideEffectRuntime() + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-restarted', + { value: 0, generation: 'reset' }, + 0 + ) + runtime.onPtyData('pty-restarted', 'old generation', 100) + const sequenceBeforeRespawn = runtime.getPtyOutputSequence('pty-restarted') + + expect( + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-restarted', + { value: 0, generation: 'reset' }, + sequenceBeforeRespawn + ) + ).toBe(sequenceBeforeRespawn) + runtime.onPtyData('pty-restarted', 'new generation', 101) + + await expect(runtime.serializeMainTerminalBuffer('pty-restarted')).resolves.toMatchObject({ + data: expect.not.stringContaining('old generation'), + seq: sequenceBeforeRespawn + 'new generation'.length + }) + }) + + it('keeps active listener sequences monotonic across a daemon reset', () => { + const { runtime } = createSideEffectRuntime() + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-reset', + { value: 0, generation: 'reset' }, + 0 + ) + runtime.onPtyData('pty-reset', 'old', 100) + const sequenceBeforeRespawn = runtime.getPtyOutputSequence('pty-reset') + const observedSequences: number[] = [] + runtime.subscribeToTerminalData('pty-reset', (_data, meta) => { + if (typeof meta?.seq === 'number') { + observedSequences.push(meta.seq) + } + }) + + runtime.onPtyData('pty-reset', 'early', 101) + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-reset', + { value: 0, generation: 'reset' }, + sequenceBeforeRespawn + ) + runtime.onPtyData('pty-reset', 'later', 102) + + expect(observedSequences).toEqual([ + sequenceBeforeRespawn + 'early'.length, + sequenceBeforeRespawn + 'early'.length + 'later'.length + ]) + }) + it('carries the synthetic permission BEL as a bell fact', () => { const { runtime, batches } = createSideEffectRuntime() syncSinglePty(runtime) @@ -7217,6 +7364,254 @@ describe('OrcaRuntimeService', () => { expect(snapshot?.lastTitle).toBe('⠋ Cursor Agent') }) + it('falls back to the provider snapshot for a restored PTY with no mounted renderer', async () => { + const { runtime } = createSideEffectRuntime() + const serializeBuffer = vi.fn() + const serializeProviderBuffer = vi.fn().mockResolvedValue({ + data: 'restored screen\r\n', + scrollbackAnsi: 'restored history\r\n', + cols: 120, + rows: 40, + cwd: '/projects/restored', + seq: 900, + source: 'headless' + }) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + serializeBuffer, + serializeProviderBuffer, + hasRendererSerializer: () => false + }) + + const snapshot = await runtime.serializeTerminalBuffer('pty-restored', { + scrollbackRows: 5000 + }) + + expect(serializeBuffer).not.toHaveBeenCalled() + expect(serializeProviderBuffer).toHaveBeenCalledWith('pty-restored', { + scrollbackRows: 5000 + }) + expect(snapshot).toEqual({ + data: 'restored screen\r\n', + scrollbackAnsi: 'restored history\r\n', + cols: 120, + rows: 40, + cwd: '/projects/restored', + seq: 900, + source: 'headless' + }) + }) + + it('does not let pre-response bytes hide restored provider history', async () => { + const { runtime } = createSideEffectRuntime() + const serializeProviderBuffer = vi.fn().mockResolvedValue({ + data: 'restored history\r\nqueued', + cols: 80, + rows: 24, + seq: 906, + source: 'headless' + }) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + serializeProviderBuffer, + hasRendererSerializer: () => false + }) + runtime.onPtyData('pty-restored', 'queued', 100) + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-restored', + { value: 900, generation: 'continued' }, + 0 + ) + + const snapshot = await runtime.serializeTerminalBuffer('pty-restored', { + scrollbackRows: 5000 + }) + + expect(snapshot?.data).toContain('restored history') + expect(serializeProviderBuffer).toHaveBeenCalledOnce() + }) + + it('keeps restored provider history authoritative after later live output', async () => { + const { runtime } = createSideEffectRuntime() + const serializeProviderBuffer = vi.fn().mockResolvedValue({ + data: 'restored history\r\nlater output', + cols: 80, + rows: 24, + seq: 912, + source: 'headless' + }) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + serializeProviderBuffer, + hasRendererSerializer: () => false + }) + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-restored', + { value: 900, generation: 'continued' }, + 0 + ) + runtime.onPtyData('pty-restored', 'later output', 100) + + const snapshot = await runtime.serializeTerminalBuffer('pty-restored') + + expect(snapshot?.data).toContain('restored history') + expect(serializeProviderBuffer).toHaveBeenCalledOnce() + }) + + it('uses provider alternate-screen state while a partial model is unsafe', async () => { + const { runtime } = createSideEffectRuntime() + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + serializeProviderBuffer: vi.fn().mockResolvedValue({ + data: 'restored tui', + cols: 80, + rows: 24, + seq: 903, + source: 'headless', + alternateScreen: true + }), + hasRendererSerializer: () => false + }) + runtime.onPtyData('pty-tui', 'tui', 100) + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-tui', + { value: 900, generation: 'continued' }, + 0 + ) + + await runtime.serializeTerminalBuffer('pty-tui') + + expect(runtime.isTerminalAlternateScreen('pty-tui')).toBe(true) + }) + + it('tracks live alternate-screen transitions after a provider snapshot', async () => { + const { runtime } = createSideEffectRuntime() + const serializeProviderBuffer = vi.fn().mockResolvedValue({ + data: 'restored tui', + cols: 80, + rows: 24, + seq: 900, + source: 'headless', + alternateScreen: true + }) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + serializeProviderBuffer, + hasRendererSerializer: () => false + }) + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-tui', + { value: 900, generation: 'continued' }, + 0 + ) + await runtime.serializeTerminalBuffer('pty-tui') + expect(runtime.isTerminalAlternateScreen('pty-tui')).toBe(true) + + runtime.onPtyData('pty-tui', '\x1b[?1049l', 100) + expect(runtime.isTerminalAlternateScreen('pty-tui')).toBe(false) + runtime.onPtyData('pty-tui', '\x1b[?1049h', 101) + expect(runtime.isTerminalAlternateScreen('pty-tui')).toBe(true) + }) + + it('keeps mode transitions that race a provider snapshot response', async () => { + const { runtime } = createSideEffectRuntime() + let resolveProviderSnapshot: + | ((snapshot: { + data: string + cols: number + rows: number + seq: number + source: 'headless' + alternateScreen: boolean + }) => void) + | undefined + const serializeProviderBuffer = vi.fn( + () => + new Promise<{ + data: string + cols: number + rows: number + seq: number + source: 'headless' + alternateScreen: boolean + }>((resolve) => { + resolveProviderSnapshot = resolve + }) + ) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + serializeProviderBuffer, + hasRendererSerializer: () => false + }) + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-tui-race', + { value: 900, generation: 'continued' }, + 0 + ) + + const snapshotPromise = runtime.serializeTerminalBuffer('pty-tui-race') + await vi.waitFor(() => expect(resolveProviderSnapshot).toBeDefined()) + runtime.onPtyData('pty-tui-race', '\x1b[?1049l', 100) + resolveProviderSnapshot?.({ + data: 'captured alt screen', + cols: 80, + rows: 24, + seq: 900, + source: 'headless', + alternateScreen: true + }) + await snapshotPromise + + expect(runtime.isTerminalAlternateScreen('pty-tui-race')).toBe(false) + }) + + it('translates reset provider snapshots without retaining the old title', async () => { + const { runtime } = createSideEffectRuntime() + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-replaced', + { value: 0, generation: 'reset' }, + 0 + ) + runtime.onPtyData('pty-replaced', '\x1b]0;Old process\x07old', 100) + const sequenceBeforeRespawn = runtime.getPtyOutputSequence('pty-replaced') + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + serializeProviderBuffer: vi.fn().mockResolvedValue({ + data: 'new process', + cols: 80, + rows: 24, + seq: 'new process'.length, + source: 'headless', + lastTitle: 'New process' + }), + hasRendererSerializer: () => false + }) + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-replaced', + { value: 0, generation: 'reset' }, + sequenceBeforeRespawn + ) + + await expect(runtime.serializeTerminalBuffer('pty-replaced')).resolves.toMatchObject({ + lastTitle: 'New process', + seq: sequenceBeforeRespawn + 'new process'.length + }) + }) + it('prefers the tracked title over the headless emulator lastTitle', async () => { const { runtime } = createSideEffectRuntime() syncSinglePty(runtime) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 6a193a404e59..6d8c041e0192 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -25,6 +25,7 @@ import type { TerminalSideEffectFact } from '../../shared/terminal-side-effect-facts' import type { TerminalGitHubPRLink } from '../../shared/terminal-github-pr-link-detector' +import { TerminalKittyKeyboardModeTracker } from '../../shared/terminal-kitty-keyboard-mode-tracker' import { AGENT_STATUS_STALE_AFTER_MS, isFreshNonDoneAgentStatus, @@ -351,6 +352,7 @@ import { serveSimStateWatcher } from '../emulator/serve-sim-state-watcher' import type { EmulatorBridge } from '../emulator/emulator-bridge' import { RuntimeFileCommands } from './orca-runtime-files' import { RuntimeGitCommands } from './orca-runtime-git' +import type { PtyProviderBufferSnapshot } from '../providers/types' import { ClaudeAgentTeamsService } from './claude-agent-teams-service' import type { AgentTeamsTmuxCompatRequest, @@ -1179,6 +1181,8 @@ type RuntimeHeadlessTerminal = { type HeadlessSeedMetadata = { cwd?: string | null oscLinks?: TerminalOscLinkRange[] + /** Cold restore history must outrank a model that only saw new-generation bytes. */ + preferProviderIfExisting?: boolean /** Persisted kitty flags from the daemon snapshot, re-applied to the fresh * emulator so hidden `CSI ? u` answers the real flags instead of ?0u * (terminal-query-authority.md §kitty). */ @@ -1219,6 +1223,11 @@ type RuntimePtyController = { ptyId: string, opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } ): Promise<{ data: string; cols: number; rows: number; lastTitle?: string } | null> + /** Authoritative provider-owned snapshot for restored PTYs with no mounted renderer. */ + serializeProviderBuffer?( + ptyId: string, + opts?: { scrollbackRows?: number } + ): Promise // Why: synchronous probe used by maybeHydrateHeadlessFromRenderer to skip // hydration when no renderer is authoritative for this PTY. See // docs/mobile-prefer-renderer-scrollback.md. @@ -2244,6 +2253,14 @@ export class OrcaRuntimeService { private titleObservationSequence = 0 private headlessTerminals = new Map() private ptyOutputSequenceById = new Map() + private providerSequenceInitializedPtys = new Set() + private providerSequenceOffsetByPtyId = new Map() + private providerSnapshotPreferredPtys = new Set() + private providerModeTrackersByPtyId = new Map() + private providerModeSnapshotScansByPtyId = new Map< + string, + Set + >() private recentPtyPathCandidatesById = new Map() // Why: OSC 9999 status can span PTY chunks. Keeping parser state in the // runtime lets hidden/model-owned terminals observe agent state without a @@ -5680,6 +5697,10 @@ export class OrcaRuntimeService { onPtyData(ptyId: string, data: string, at: number, sequenceChars = data.length): number { const outputSequence = (this.ptyOutputSequenceById.get(ptyId) ?? 0) + sequenceChars this.ptyOutputSequenceById.set(ptyId, outputSequence) + this.providerModeTrackersByPtyId.get(ptyId)?.scan(data) + for (const tracker of this.providerModeSnapshotScansByPtyId.get(ptyId) ?? []) { + tracker.scan(data) + } const osc7Metadata = this.recordOsc7MetadataForPty(ptyId, data) const cwd = osc7Metadata.cwd const cwdChanged = osc7Metadata.cwdChanged @@ -6404,6 +6425,29 @@ export class OrcaRuntimeService { this.ptyTitleTrackersByPtyId.delete(ptyId) } + private resetTrackedTerminalStateForProviderGeneration(ptyId: string): void { + // Why: a replacement daemon session can reuse the PTY id, but title/parser + // state from the prior process must not bleed into its snapshots or chunks. + this.disposePtyTitleTracker(ptyId) + this.oscTitleScanTailByPtyId.delete(ptyId) + this.osc7ScanTailByPtyId.delete(ptyId) + this.agentStatusOscProcessorsByPtyId.delete(ptyId) + const pty = this.ptysById.get(ptyId) + if (pty) { + pty.lastOscTitle = null + pty.lastOscTitleAt = null + pty.lastAgentStatus = null + pty.managementTitle = null + pty.managementTitleAt = null + } + for (const leaf of this.getLeavesForPty(ptyId)) { + leaf.lastOscTitle = null + leaf.lastOscTitleAt = null + leaf.lastAgentStatus = null + } + this.clearAgentRowSnapshotsForPty(ptyId) + } + private setTerminalSideEffectConsumerAvailable(available: boolean): void { const nextAvailable = available && this.onTerminalSideEffects !== null if (nextAvailable === this.terminalSideEffectConsumerAvailable) { @@ -6594,6 +6638,80 @@ export class OrcaRuntimeService { return this.ptyOutputSequenceById.get(ptyId) ?? 0 } + synchronizePtyOutputSequenceFromProvider( + ptyId: string, + providerSequence: { value: number; generation: 'continued' | 'reset' }, + runtimeSequenceAtSpawnStart = 0 + ): number { + if ( + !Number.isFinite(providerSequence.value) || + providerSequence.value < 0 || + !Number.isFinite(runtimeSequenceAtSpawnStart) || + runtimeSequenceAtSpawnStart < 0 + ) { + return this.getPtyOutputSequence(ptyId) + } + const baseline = Math.floor(providerSequence.value) + const currentSequence = this.getPtyOutputSequence(ptyId) + const sequenceAtSpawnStart = Math.min(currentSequence, Math.floor(runtimeSequenceAtSpawnStart)) + const postSpawnSequence = currentSequence - sequenceAtSpawnStart + const wasInitialized = this.providerSequenceInitializedPtys.has(ptyId) + const replacesExistingRuntimeGeneration = wasInitialized || sequenceAtSpawnStart > 0 + const providerOffset = + providerSequence.generation === 'reset' + ? sequenceAtSpawnStart + : (this.providerSequenceOffsetByPtyId.get(ptyId) ?? 0) + const providerBaseline = providerOffset + baseline + + if (providerSequence.generation === 'reset') { + // Why: daemon respawn/cold restore starts a new absolute domain. Old + // emulator state cannot remain authoritative over the replacement. + if (replacesExistingRuntimeGeneration) { + this.disposeHeadlessTerminal(ptyId) + } + this.providerModeTrackersByPtyId.delete(ptyId) + if (replacesExistingRuntimeGeneration && postSpawnSequence === 0) { + this.resetTrackedTerminalStateForProviderGeneration(ptyId) + } + } + + const synchronizedSequence = + providerSequence.generation === 'reset' + ? currentSequence + : wasInitialized + ? currentSequence + : providerBaseline + postSpawnSequence + this.ptyOutputSequenceById.set(ptyId, synchronizedSequence) + this.providerSequenceInitializedPtys.add(ptyId) + this.providerSequenceOffsetByPtyId.set(ptyId, providerOffset) + + const snapshotMayCoverMissingState = + (providerSequence.generation === 'continued' && !wasInitialized) || + (postSpawnSequence > 0 && + providerSequence.generation === 'reset' && + replacesExistingRuntimeGeneration) || + (providerSequence.generation === 'continued' && + wasInitialized && + providerBaseline > currentSequence) + if (snapshotMayCoverMissingState) { + // Why: bytes can cross the control/stream sockets around attach. Until a + // full renderer/provider snapshot is available, a partial model is unsafe. + this.providerSnapshotPreferredPtys.add(ptyId) + } else if (providerSequence.generation === 'reset') { + this.providerSnapshotPreferredPtys.delete(ptyId) + } + + const headless = this.headlessTerminals.get(ptyId) + if (headless && !wasInitialized && providerSequence.generation === 'continued') { + // Why: daemon bytes can reach main just before spawn resolves. Queue the + // baseline behind those writes so their emulator sequence is rebased too. + headless.writeChain = headless.writeChain.then(() => { + headless.outputSequence = synchronizedSequence + }) + } + return synchronizedSequence + } + subscribeToTerminalData( ptyId: string, listener: (data: string, meta?: { seq?: number; rawLength?: number; cwd?: string }) => void @@ -6766,7 +6884,8 @@ export class OrcaRuntimeService { // Why: hidden-output recovery is initiated by the desktop renderer. If the // runtime has not built headless state yet, the mounted xterm is still the // best available state and avoids a false "snapshot unavailable" result. - return this.serializeRendererTerminalBuffer(ptyId, opts) + const rendererSnapshot = await this.serializeRendererTerminalBuffer(ptyId, opts) + return rendererSnapshot ?? this.serializeProviderTerminalBuffer(ptyId, opts) } async clearTerminalBuffer(handle: string): Promise<{ handle: string; cleared: boolean }> { @@ -6791,10 +6910,17 @@ export class OrcaRuntimeService { // scrollback to mobile so it rewraps at the new cols, but alternate-screen // TUIs (vim, Claude Code) own their repaint and have no scrollback — for // those the mobile client just resizes xterm geometry and consumes the - // TUI's own redraw, so the resize re-stream must be skipped. Returns false - // when there is no headless emulator (resize falls back to geometry-only). + // TUI's own redraw, so the resize re-stream must be skipped. Provider state + // covers restored PTYs whose main-side emulator is only a partial suffix. isTerminalAlternateScreen(ptyId: string): boolean { - return this.headlessTerminals.get(ptyId)?.emulator.isAlternateScreen ?? false + if (this.providerSnapshotPreferredPtys.has(ptyId)) { + return this.providerModeTrackersByPtyId.get(ptyId)?.isAlternateScreen ?? false + } + return ( + this.headlessTerminals.get(ptyId)?.emulator.isAlternateScreen ?? + this.providerModeTrackersByPtyId.get(ptyId)?.isAlternateScreen ?? + false + ) } // Why: daemon-backed PTYs that the runtime adopted after an Orca relaunch @@ -6818,10 +6944,14 @@ export class OrcaRuntimeService { if (existing) { // Why: emulator already has live data — re-seeding would duplicate // every byte. The seed is only valid when the emulator is fresh. + if (metadata.preferProviderIfExisting) { + this.providerSnapshotPreferredPtys.add(ptyId) + } return } const dims = size ?? this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 } const state = this.createPtyHeadlessTerminalState(ptyId, dims) + state.outputSequence = this.getPtyOutputSequence(ptyId) this.headlessTerminals.set(ptyId, state) this.recordOsc7MetadataForPty(ptyId, data) this.recordRecentPtyOutputForPathProvenance(ptyId, data) @@ -6843,6 +6973,7 @@ export class OrcaRuntimeService { if (metadata.oscLinks !== undefined) { state.emulator.setRestoredOscLinks(metadata.oscLinks) } + this.providerSnapshotPreferredPtys.delete(ptyId) }) .catch(() => { // Seeding is best-effort; live data will continue to populate the @@ -6860,7 +6991,8 @@ export class OrcaRuntimeService { if (this.headlessHydrationState.has(ptyId)) { return } - if (this.headlessTerminals.has(ptyId)) { + const providerSnapshotPreferred = this.providerSnapshotPreferredPtys.has(ptyId) + if (this.headlessTerminals.has(ptyId) && !providerSnapshotPreferred) { // Daemon-snapshot seed already populated the emulator — skip hydration. this.headlessHydrationState.set(ptyId, 'done') return @@ -6875,11 +7007,18 @@ export class OrcaRuntimeService { return } + if (providerSnapshotPreferred) { + // Why: a stream byte can create a partial model before restored history + // arrives. A mounted renderer snapshot can safely replace that model. + this.disposeHeadlessTerminal(ptyId) + } + this.headlessHydrationState.set(ptyId, 'pending') const dims = this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 } // Why: hydration writes below never set forwardQueryReplies (main-side // replay guard) — renderer-buffer snapshots can embed stale queries. const state = this.createPtyHeadlessTerminalState(ptyId, dims) + state.outputSequence = this.getPtyOutputSequence(ptyId) this.headlessTerminals.set(ptyId, state) // Why: append the seed work to writeChain so live writes queued by @@ -6918,6 +7057,7 @@ export class OrcaRuntimeService { state.emulator.setLastTitle(seedTitle) this.applySeededAgentStatus(ptyId, seedTitle) } + this.providerSnapshotPreferredPtys.delete(ptyId) } catch { // Hydration is best-effort. Live writes continue via the same // writeChain that this catch-arm leaves intact. @@ -7110,12 +7250,26 @@ export class OrcaRuntimeService { alternateScreen?: boolean pendingEscapeTailAnsi?: string } | null> { + if (this.providerSnapshotPreferredPtys.has(ptyId)) { + // Why: pre-attach stream bytes only form a suffix of restored state. A + // sequenced provider snapshot safely reconciles live bytes; renderer is + // the fallback when an older provider cannot expose that boundary. + const providerSnapshot = await this.serializeProviderTerminalBuffer(ptyId, opts) + if (providerSnapshot) { + return providerSnapshot + } + const rendererSnapshot = await this.serializeRendererTerminalBuffer(ptyId, opts) + if (rendererSnapshot) { + return rendererSnapshot + } + } const headlessSnapshot = await this.serializeHeadlessTerminalBuffer(ptyId, opts) if (headlessSnapshot) { return headlessSnapshot } - return this.serializeRendererTerminalBuffer(ptyId, opts) + const rendererSnapshot = await this.serializeRendererTerminalBuffer(ptyId, opts) + return rendererSnapshot ?? this.serializeProviderTerminalBuffer(ptyId, opts) } private async serializeRendererTerminalBuffer( @@ -7130,6 +7284,9 @@ export class OrcaRuntimeService { source?: 'renderer' oscLinks?: TerminalOscLinkRange[] } | null> { + if (this.ptyController?.hasRendererSerializer?.(ptyId) === false) { + return null + } let rendererSnapshot: { data: string cols: number @@ -7160,6 +7317,49 @@ export class OrcaRuntimeService { : null } + private async serializeProviderTerminalBuffer( + ptyId: string, + opts: { scrollbackRows?: number } = {} + ): Promise { + const liveModeTracker = new TerminalKittyKeyboardModeTracker() + let liveModeTrackers = this.providerModeSnapshotScansByPtyId.get(ptyId) + if (!liveModeTrackers) { + liveModeTrackers = new Set() + this.providerModeSnapshotScansByPtyId.set(ptyId, liveModeTrackers) + } + liveModeTrackers.add(liveModeTracker) + try { + // Why: daemon PTYs survive an app relaunch before any renderer mounts. + // Mobile still needs their retained history without navigating desktop. + const snapshot = await this.ptyController?.serializeProviderBuffer?.(ptyId, opts) + if (typeof snapshot?.alternateScreen === 'boolean') { + const modeTracker = new TerminalKittyKeyboardModeTracker() + if (snapshot.alternateScreen) { + modeTracker.scan('\x1b[?1049h') + } + if (liveModeTracker.hasObservedAlternateScreenSwitch) { + modeTracker.scan(liveModeTracker.isAlternateScreen ? '\x1b[?1049h' : '\x1b[?1049l') + } + this.providerModeTrackersByPtyId.set(ptyId, modeTracker) + } + if (!snapshot) { + return null + } + const providerOffset = this.providerSequenceOffsetByPtyId.get(ptyId) ?? 0 + return this.preferTrackedLastTitle(ptyId, { + ...snapshot, + seq: providerOffset + snapshot.seq + }) + } catch { + return null + } finally { + liveModeTrackers.delete(liveModeTracker) + if (liveModeTrackers.size === 0) { + this.providerModeSnapshotScansByPtyId.delete(ptyId) + } + } + } + private async withVisibleSnapshotFallback( ptyId: string, read: RuntimeTerminalRead, @@ -8245,6 +8445,11 @@ export class OrcaRuntimeService { this.clearWaitBlockedCheckState(ptyId) this.recentPtyPathCandidatesById.delete(ptyId) this.ptyOutputSequenceById.delete(ptyId) + this.providerSequenceInitializedPtys.delete(ptyId) + this.providerSequenceOffsetByPtyId.delete(ptyId) + this.providerSnapshotPreferredPtys.delete(ptyId) + this.providerModeTrackersByPtyId.delete(ptyId) + this.providerModeSnapshotScansByPtyId.delete(ptyId) this.agentStatusOscProcessorsByPtyId.delete(ptyId) this.terminalSpawnCommandsByPtyId.delete(ptyId) this.disposePtyTitleTracker(ptyId) @@ -20469,6 +20674,11 @@ export class OrcaRuntimeService { this.clearWaitBlockedCheckState(ptyId) this.recentPtyPathCandidatesById.delete(ptyId) this.ptyOutputSequenceById.delete(ptyId) + this.providerSequenceInitializedPtys.delete(ptyId) + this.providerSequenceOffsetByPtyId.delete(ptyId) + this.providerSnapshotPreferredPtys.delete(ptyId) + this.providerModeTrackersByPtyId.delete(ptyId) + this.providerModeSnapshotScansByPtyId.delete(ptyId) this.agentStatusOscProcessorsByPtyId.delete(ptyId) this.terminalSpawnCommandsByPtyId.delete(ptyId) this.disposePtyTitleTracker(ptyId) diff --git a/src/main/runtime/rpc/terminal-provider-snapshot-sequence.test.ts b/src/main/runtime/rpc/terminal-provider-snapshot-sequence.test.ts new file mode 100644 index 000000000000..5d04ef243892 --- /dev/null +++ b/src/main/runtime/rpc/terminal-provider-snapshot-sequence.test.ts @@ -0,0 +1,187 @@ +import { expect, it, vi } from 'vitest' +import { RpcDispatcher } from './dispatcher' +import type { RpcRequest } from './core' +import { TERMINAL_METHODS } from './methods/terminal' +import type { OrcaRuntimeService } from '../orca-runtime' +import type { RuntimeTerminalWait } from '../../../shared/runtime-types' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + decodeTerminalStreamText +} from '../../../shared/terminal-stream-protocol' + +const request: RpcRequest = { + id: 'req-provider-sequence', + authToken: 'tok', + method: 'terminal.subscribe', + params: { + terminal: 'terminal-restored', + client: { id: 'phone-1', type: 'mobile' }, + capabilities: { terminalBinaryStream: 1 } + } +} + +it('replays post-capture output in the provider snapshot sequence domain', async () => { + const binaryFrames: Uint8Array[] = [] + const cleanups = new Map void>() + let dataListener: + | ((data: string, meta?: { seq?: number; rawLength?: number }) => void) + | undefined + let resolveSnapshot: + | ((snapshot: { data: string; cols: number; rows: number; seq: number }) => void) + | undefined + const runtime = { + getRuntimeId: () => 'test-runtime', + registerRemoteTerminalViewSubscriber: () => () => {}, + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-restored' }), + handleMobileSubscribe: vi.fn().mockResolvedValue(true), + handleMobileUnsubscribe: vi.fn(), + subscribeToTerminalData: vi.fn((_ptyId, listener) => { + dataListener = listener + return vi.fn() + }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn( + () => + new Promise<{ data: string; cols: number; rows: number; seq: number }>((resolve) => { + resolveSnapshot = resolve + }) + ), + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + isTerminalAlternateScreen: vi.fn().mockReturnValue(false), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + cleanups.get(id)?.() + cleanups.delete(id) + }), + waitForTerminal: vi.fn(() => new Promise(() => {})) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming(request, vi.fn(), { + connectionId: 'conn-restored', + sendBinary: (bytes) => { + binaryFrames.push(bytes) + } + }) + + await vi.waitFor(() => expect(resolveSnapshot).toBeDefined()) + resolveSnapshot?.({ data: 'restored snapshot', cols: 80, rows: 24, seq: 900 }) + // Why: provider attach anchoring makes this first new chunk continue at + // 905 instead of restarting at 5, so snapshot reconciliation keeps it. + dataListener?.('fresh', { seq: 905, rawLength: 5 }) + + await vi.waitFor(() => + expect( + binaryFrames.some((bytes) => { + const frame = decodeTerminalStreamFrame(bytes) + return ( + frame?.opcode === TerminalStreamOpcode.Output && + decodeTerminalStreamText(frame.payload) === 'fresh' + ) + }) + ).toBe(true) + ) + + runtime.cleanupSubscription('terminal-restored:phone-1') + await dispatchPromise +}) + +it('keeps provider-backed alternate-screen resizes geometry-only', async () => { + const binaryFrames: Uint8Array[] = [] + const cleanups = new Map void>() + let resizeListener: + | ((event: { + cols: number + rows: number + displayMode: string + reason: string + seq: number + }) => void) + | undefined + const serializeTerminalBuffer = vi + .fn() + .mockResolvedValue({ data: 'restored tui', cols: 80, rows: 24 }) + const runtime = { + getRuntimeId: () => 'test-runtime', + registerRemoteTerminalViewSubscriber: () => () => {}, + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-tui' }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer, + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + isTerminalAlternateScreen: vi.fn().mockReturnValue(true), + handleMobileSubscribe: vi.fn().mockResolvedValue(undefined), + handleMobileUnsubscribe: vi.fn(), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + subscribeToTerminalResize: vi.fn((_ptyId, listener) => { + resizeListener = listener + return vi.fn() + }), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + const cleanup = cleanups.get(id) + cleanups.delete(id) + cleanup?.() + }), + waitForTerminal: vi.fn(() => new Promise(() => {})), + sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), + updateMobileViewport: vi.fn().mockResolvedValue({ updated: true, applied: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatchPromise = dispatcher.dispatchStreaming( + { + ...request, + id: 'req-provider-alt-screen', + params: { + terminal: 'terminal-tui', + client: { id: 'phone-1', type: 'mobile' }, + capabilities: { terminalBinaryStream: 1 } + } + }, + vi.fn(), + { + connectionId: 'conn-tui-resize', + sendBinary: (bytes) => { + binaryFrames.push(bytes) + } + } + ) + + await vi.waitFor(() => expect(resizeListener).toBeDefined()) + binaryFrames.splice(0) + resizeListener?.({ + cols: 100, + rows: 30, + displayMode: 'auto', + reason: 'apply-layout', + seq: 2 + }) + await vi.waitFor(() => + expect( + binaryFrames.some( + (frame) => decodeTerminalStreamFrame(frame)?.opcode === TerminalStreamOpcode.Resized + ) + ).toBe(true) + ) + + expect(serializeTerminalBuffer).toHaveBeenCalledOnce() + expect( + binaryFrames.some( + (frame) => decodeTerminalStreamFrame(frame)?.opcode === TerminalStreamOpcode.SnapshotStart + ) + ).toBe(false) + + runtime.cleanupSubscription('terminal-tui:phone-1') + await dispatchPromise +}) diff --git a/src/shared/terminal-kitty-keyboard-mode-tracker.test.ts b/src/shared/terminal-kitty-keyboard-mode-tracker.test.ts index 6d88f56a070c..9ad2cb4d77a5 100644 --- a/src/shared/terminal-kitty-keyboard-mode-tracker.test.ts +++ b/src/shared/terminal-kitty-keyboard-mode-tracker.test.ts @@ -60,10 +60,13 @@ describe('TerminalKittyKeyboardModeTracker', () => { tracker.scan('\x1b[>1u') expect(tracker.flags).toBe(1) tracker.scan('\x1b[?1049h') + expect(tracker.hasObservedAlternateScreenSwitch).toBe(true) + expect(tracker.isAlternateScreen).toBe(true) expect(tracker.flags).toBe(0) tracker.scan('\x1b[>2u') expect(tracker.flags).toBe(2) tracker.scan('\x1b[?1049l') + expect(tracker.isAlternateScreen).toBe(false) expect(tracker.flags).toBe(1) }) diff --git a/src/shared/terminal-kitty-keyboard-mode-tracker.ts b/src/shared/terminal-kitty-keyboard-mode-tracker.ts index a0b7b0d1f2b4..2b501a2c9ffd 100644 --- a/src/shared/terminal-kitty-keyboard-mode-tracker.ts +++ b/src/shared/terminal-kitty-keyboard-mode-tracker.ts @@ -30,12 +30,21 @@ export class TerminalKittyKeyboardModeTracker { private mainStack: number[] = [] private altStack: number[] = [] private alternateScreenActive = false + private alternateScreenSwitchObserved = false /** Current effective kitty keyboard flags (0 = protocol inactive). */ get flags(): number { return this.currentFlags } + get isAlternateScreen(): boolean { + return this.alternateScreenActive + } + + get hasObservedAlternateScreenSwitch(): boolean { + return this.alternateScreenSwitchObserved + } + reset(): void { this.scanTail = '' this.currentFlags = 0 @@ -44,6 +53,7 @@ export class TerminalKittyKeyboardModeTracker { this.mainStack = [] this.altStack = [] this.alternateScreenActive = false + this.alternateScreenSwitchObserved = false } scan(data: string): void { @@ -77,6 +87,7 @@ export class TerminalKittyKeyboardModeTracker { const tail = this.scanTail this.reset() this.scanTail = tail + this.alternateScreenSwitchObserved = true continue } if (match[0].endsWith('!p')) { @@ -108,6 +119,7 @@ export class TerminalKittyKeyboardModeTracker { if (param !== 47 && param !== 1047 && param !== 1049) { continue } + this.alternateScreenSwitchObserved = true // Why: xterm swaps the current flags with the inactive screen's slot on // every 47/1047/1049 transition, without an already-active guard — // mirror it exactly so this state matches what the renderer encodes. From 0a0b400ada741982eeb574820fa8d8b3a3b7b1a2 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:16:30 -0700 Subject: [PATCH 002/351] Prepare mobile 0.0.30 (#8779) Co-authored-by: Orca --- mobile/app.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/app.json b/mobile/app.json index e2d6a248f858..cb740d1f545b 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Orca", "slug": "orca-mobile", - "version": "0.0.27", + "version": "0.0.30", "orientation": "default", "icon": "./assets/icon.png", "userInterfaceStyle": "automatic", From dfabd85513e42ff8f337a6d2651827ae192a5485 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:35:31 -0700 Subject: [PATCH 003/351] Stop usage chips flashing and back off retries for failing providers (#8772) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Stop usage chips flashing and back off retries for failing providers Two behaviors made the status bar unusable when any provider was persistently failing (bad auth, unsupported plan): 1. Every refetch repainted all providers as 'fetching', so a settled error chip flashed to a loading "…" chip and back on every cycle. withFetchingStatus now keeps settled states (ok/error/unavailable) visible until the new result lands; only providers with no settled state (first load, explicit account-switch clear) show loading. 2. Error providers on the fast activation-retry lane (claude/codex/ grok) were retried every 30s on any focus/show/restore event — forever. Repeated hits drove Claude's tight-budget usage endpoint into 429s, flipping the chip between 'Limited' and its actual error. Retries now back off exponentially per consecutive applied failure (30s, 60s, 120s, … capped at the 15-minute poll cadence) and reset on success or account/target switch. * Count full fetches as failure-lane retries and keep Grok pane refresh feedback - Stamp failing providers' activation-retry clocks when a stale-driven full fetch runs, so the individual failure lane does not fire a redundant retry right after the full fetch already retried them. - GrokUsagePane: manual refresh spinner/disable is now renderer-local, since settled snapshots no longer repaint as 'fetching' during refetches. - Strengthen the backoff-reset test so it distinguishes a reset streak from a stale retry timestamp (CodeRabbit), and add a regression test for the full-fetch retry stamping. --- src/main/rate-limits/service.test.ts | 180 +++++++++++++++++- src/main/rate-limits/service.ts | 89 ++++++++- .../src/components/stats/GrokUsagePane.tsx | 17 +- 3 files changed, 282 insertions(+), 4 deletions(-) diff --git a/src/main/rate-limits/service.test.ts b/src/main/rate-limits/service.test.ts index 261a94f36bef..9bd66cb806a1 100644 --- a/src/main/rate-limits/service.test.ts +++ b/src/main/rate-limits/service.test.ts @@ -120,6 +120,19 @@ function unavailableProvider( } } +// Why: beforeEach caches snapshot objects whose updatedAt is pinned at suite +// start, so after 5 fake minutes every healthy provider looks stale and every +// activation degrades to a full fetch. Backoff tests that reason about the +// individual retry lane need healthy providers minted fresh at fetch time. +function mockFreshBackgroundProviderFetches(): void { + vi.mocked(fetchCodexRateLimits).mockImplementation(async () => okProvider('codex', 24)) + vi.mocked(fetchGeminiRateLimits).mockImplementation(async () => okProvider('gemini', 0)) + vi.mocked(fetchOpenCodeGoRateLimits).mockImplementation(async () => okProvider('opencode-go', 0)) + vi.mocked(fetchKimiRateLimits).mockImplementation(async () => okProvider('kimi', 0)) + vi.mocked(fetchMiniMaxRateLimits).mockImplementation(async () => okProvider('minimax', 0)) + vi.mocked(fetchGrokRateLimits).mockImplementation(async () => unavailableProvider('grok')) +} + function serviceInternals(service: RateLimitService): { fetchAll: () => Promise } { return service as unknown as { fetchAll: () => Promise } } @@ -393,7 +406,7 @@ describe('RateLimitService', () => { } }) - it('throttles repeated active-window retries while Claude is still failing', async () => { + it('backs off repeated active-window retries while Claude is still failing', async () => { vi.useFakeTimers() try { vi.mocked(fetchClaudeRateLimits).mockResolvedValue(errorProvider('claude', 'still failing')) @@ -407,6 +420,7 @@ describe('RateLimitService', () => { await vi.advanceTimersByTimeAsync(1000) expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) + // First activation recovers immediately (retry timestamps start at 0). window.emit('focus') await vi.advanceTimersByTimeAsync(0) expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(2) @@ -415,10 +429,174 @@ describe('RateLimitService', () => { await vi.advanceTimersByTimeAsync(0) expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(2) + // Two consecutive failures: the retry window doubled to 60s, so an + // activation at +30s must not hammer the endpoint again. + await vi.advanceTimersByTimeAsync(30 * 1000) + window.emit('restore') + await vi.advanceTimersByTimeAsync(0) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(30 * 1000) + window.emit('focus') + await vi.advanceTimersByTimeAsync(0) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(3) + + // Three consecutive failures: 120s window. + await vi.advanceTimersByTimeAsync(60 * 1000) + window.emit('show') + await vi.advanceTimersByTimeAsync(0) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(3) + + await vi.advanceTimersByTimeAsync(60 * 1000) window.emit('restore') await vi.advanceTimersByTimeAsync(0) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(4) + + service.stop() + } finally { + vi.useRealTimers() + } + }) + + it('resets the retry backoff once Claude recovers', async () => { + vi.useFakeTimers() + try { + vi.mocked(fetchClaudeRateLimits) + .mockResolvedValueOnce(errorProvider('claude', 'still failing')) + .mockResolvedValueOnce(errorProvider('claude', 'still failing')) + .mockResolvedValueOnce(okProvider('claude', 12)) + .mockResolvedValue(errorProvider('claude', 'failing again')) + mockFreshBackgroundProviderFetches() + + const service = new RateLimitService() + const window = new FakeRateLimitWindow() + service.attach(asRateLimitWindow(window)) + service.start({ fetchImmediately: false }) + + await vi.advanceTimersByTimeAsync(1000) + window.emit('focus') + await vi.advanceTimersByTimeAsync(0) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(2) + + // Recovery fetch succeeds and must clear the failure streak. + await vi.advanceTimersByTimeAsync(60 * 1000) + window.emit('focus') + await vi.advanceTimersByTimeAsync(0) expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(3) + expect(service.getState().claude?.status).toBe('ok') + + // The stale-ok snapshot forces a full refresh, which fails again. + await vi.advanceTimersByTimeAsync(5 * 60 * 1000) + window.emit('focus') + await vi.advanceTimersByTimeAsync(0) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(4) + expect(service.getState().claude?.status).toBe('error') + + // Consume the stale active-retry timestamp so the next windows measure + // the post-recovery streak rather than time elapsed before recovery. + window.emit('focus') + await vi.advanceTimersByTimeAsync(0) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(5) + + // Post-recovery streak is 2 (not the pre-recovery 4): the window must be + // 60s, so +30s stays throttled and +60s retries. + await vi.advanceTimersByTimeAsync(30 * 1000) + window.emit('focus') + await vi.advanceTimersByTimeAsync(0) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(5) + + await vi.advanceTimersByTimeAsync(30 * 1000) + window.emit('focus') + await vi.advanceTimersByTimeAsync(0) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(6) + + service.stop() + } finally { + vi.useRealTimers() + } + }) + + it('counts a stale-driven full fetch as the failing provider retry attempt', async () => { + vi.useFakeTimers() + try { + vi.mocked(fetchClaudeRateLimits).mockImplementation(async () => + errorProvider('claude', 'still failing') + ) + mockFreshBackgroundProviderFetches() + + const service = new RateLimitService() + const window = new FakeRateLimitWindow() + service.attach(asRateLimitWindow(window)) + service.start({ fetchImmediately: false }) + + await vi.advanceTimersByTimeAsync(1000) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) + + window.emit('focus') + await vi.advanceTimersByTimeAsync(0) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(2) + + // Healthy providers go stale after 5 minutes, so this activation runs a + // full fetch that also retries failing Claude (streak now 3 → 120s). + await vi.advanceTimersByTimeAsync(5 * 60 * 1000) + window.emit('focus') + await vi.advanceTimersByTimeAsync(0) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(3) + + // Why: the full fetch was itself a retry. An activation moments later + // must not fire the individual failure lane ahead of the backoff window. + window.emit('focus') + await vi.advanceTimersByTimeAsync(0) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(3) + + // Once the 120s window elapses, the individual retry lane fires again. + await vi.advanceTimersByTimeAsync(120 * 1000) + window.emit('focus') + await vi.advanceTimersByTimeAsync(0) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(4) + + service.stop() + } finally { + vi.useRealTimers() + } + }) + + it('keeps a settled error chip settled during background refetches instead of flashing fetching', async () => { + vi.useFakeTimers() + try { + const secondClaude = deferred() + vi.mocked(fetchClaudeRateLimits) + .mockResolvedValueOnce(errorProvider('claude', 'still failing')) + .mockImplementationOnce(() => secondClaude.promise) + vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 24)) + + const service = new RateLimitService() + const window = new FakeRateLimitWindow() + const claudeStatuses: string[] = [] + service.onStateChange((state) => { + if (state.claude) { + claudeStatuses.push(state.claude.status) + } + }) + service.attach(asRateLimitWindow(window)) + service.start({ fetchImmediately: false }) + + await vi.advanceTimersByTimeAsync(1000) + expect(service.getState().claude?.status).toBe('error') + + // Why: the retry must not repaint the settled error chip as a loading + // "…" chip while the refetch is in flight — that is the flash users see + // every cycle when a provider is stuck failing. + window.emit('focus') + await vi.advanceTimersByTimeAsync(0) + expect(service.getState().claude?.status).toBe('error') + + secondClaude.resolve(okProvider('claude', 12)) + await vi.advanceTimersByTimeAsync(0) + expect(service.getState().claude?.status).toBe('ok') + + const statusesAfterFirstSettle = claudeStatuses.slice(claudeStatuses.indexOf('error')) + expect(statusesAfterFirstSettle).not.toContain('fetching') service.stop() } finally { diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index cd301cfe516d..84fb016fec8d 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -77,6 +77,12 @@ const MIN_POLL_MS = 30 * 1000 // 30 seconds — renderer input should never crea const MAX_POLL_MS = 2_147_483_647 // Max safe setInterval delay before Node clamps back to 1ms. const MIN_REFETCH_MS = 5 * 60 * 1000 // 5 minutes — debounce resume/manual refresh bursts const ACTIVE_FAILURE_REFETCH_MS = MIN_POLL_MS +// Why: a persistent failure (bad auth, unsupported plan) retried at the 30s +// floor hammers provider endpoints — Claude's tight-budget usage endpoint +// starts returning 429s — without ever recovering. Back off per consecutive +// failure, capped at the background poll cadence. +const MAX_ACTIVE_FAILURE_REFETCH_MS = DEFAULT_POLL_MS +const MAX_ACTIVE_FAILURE_STREAK = 8 // Why: these providers have a dedicated fetch cycle, so an activation retry can // refresh just the failing one. Providers without one force a full fetchAll, so // their error retries stay on the 5-minute cadence to protect Claude's budget. @@ -152,6 +158,18 @@ export class RateLimitService { grok: 0, antigravity: 0 } + // Why: consecutive applied failures per provider drive exponential backoff of + // the fast activation-retry lane; reset on any successful/unavailable result. + private activeFailureStreakByProvider: Record = { + claude: 0, + codex: 0, + gemini: 0, + 'opencode-go': 0, + kimi: 0, + minimax: 0, + grok: 0, + antigravity: 0 + } private mainWindow: BrowserWindow | null = null private detachWindowListeners: (() => void) | null = null private isFetching = false @@ -358,6 +376,8 @@ export class RateLimitService { } this.codexFetchTarget = nextTarget this.codexFetchGeneration += 1 + // Why: a new account/target starts with a clean retry schedule. + this.activeFailureStreakByProvider.codex = 0 this.inactiveCodexAccountsGeneration += 1 this.pruneInactiveCodexState() this.lastInactiveCodexFetchAt = 0 @@ -377,6 +397,7 @@ export class RateLimitService { const targetChanged = !this.isSameCodexTarget(this.codexFetchTarget, nextTarget) this.codexFetchTarget = nextTarget this.codexFetchGeneration += 1 + this.activeFailureStreakByProvider.codex = 0 this.updateState({ ...this.state, codex: this.withFetchingStatus(targetChanged ? null : this.state.codex, 'codex') @@ -426,6 +447,8 @@ export class RateLimitService { this.inactiveClaudeAccountsGeneration += 1 this.pruneInactiveClaudeState() this.claudeFetchGeneration += 1 + // Why: a new account/target starts with a clean retry schedule. + this.activeFailureStreakByProvider.claude = 0 this.lastInactiveClaudeFetchAt = 0 this.updateState({ ...this.state, @@ -440,6 +463,7 @@ export class RateLimitService { const targetChanged = !this.isSameClaudeTarget(this.claudeFetchTarget, nextTarget) this.claudeFetchTarget = nextTarget this.claudeFetchGeneration += 1 + this.activeFailureStreakByProvider.claude = 0 this.updateState({ ...this.state, claude: this.withFetchingStatus(targetChanged ? null : this.state.claude, 'claude') @@ -764,7 +788,11 @@ export class RateLimitService { if (limits.status === 'error') { const lastRetryAt = this.lastActiveFailureRetryAtByProvider[provider] const throttleMs = INDIVIDUALLY_REFRESHABLE_PROVIDERS.has(provider) - ? ACTIVE_FAILURE_REFETCH_MS + ? Math.min( + ACTIVE_FAILURE_REFETCH_MS * + 2 ** Math.max(0, this.activeFailureStreakByProvider[provider] - 1), + MAX_ACTIVE_FAILURE_REFETCH_MS + ) : MIN_REFETCH_MS if (now - lastRetryAt >= throttleMs) { retryableFailures.push(provider) @@ -783,6 +811,18 @@ export class RateLimitService { return } if (plan.kind === 'full') { + // Why: a full fetch retries failing providers too. Restart their retry + // clocks so the individual failure lane doesn't fire again right after, + // ahead of its backoff window. Skip when a fetch is already in flight — + // fetchAll would no-op and the throttle must not be consumed for free. + if (!this.isFetching) { + const now = Date.now() + for (const { provider, limits } of this.getActiveProviderState()) { + if (limits?.status === 'error') { + this.lastActiveFailureRetryAtByProvider[provider] = now + } + } + } await this.fetchAll() return } @@ -1242,6 +1282,22 @@ export class RateLimitService { } } + private trackActiveFailureStreak( + provider: ActiveRateLimitProvider, + fresh: ProviderRateLimits + ): void { + if (fresh.status === 'error') { + this.activeFailureStreakByProvider[provider] = Math.min( + this.activeFailureStreakByProvider[provider] + 1, + MAX_ACTIVE_FAILURE_STREAK + ) + return + } + if (fresh.status === 'ok' || fresh.status === 'unavailable') { + this.activeFailureStreakByProvider[provider] = 0 + } + } + private withFetchingStatus( current: ProviderRateLimits | null, provider: @@ -1264,6 +1320,13 @@ export class RateLimitService { status: 'fetching' } } + // Why: repainting a settled chip as "fetching" on every background refetch + // makes the status bar flash "…" → error each retry cycle when a provider + // is persistently failing. Keep the settled state visible until the new + // result lands; only providers with no settled state show a loading chip. + if (current.status === 'ok' || current.status === 'error' || current.status === 'unavailable') { + return current + } return { ...current, status: 'fetching' } } @@ -1481,6 +1544,22 @@ export class RateLimitService { const shouldApplyOpencode = opencodeGeneration === this.opencodeFetchGeneration const shouldApplyMiniMax = miniMaxGeneration === this.minimaxFetchGeneration + if (shouldApplyClaude) { + this.trackActiveFailureStreak('claude', claude) + } + if (shouldApplyCodex) { + this.trackActiveFailureStreak('codex', codex) + } + this.trackActiveFailureStreak('gemini', gemini) + this.trackActiveFailureStreak('antigravity', antigravity) + if (shouldApplyOpencode) { + this.trackActiveFailureStreak('opencode-go', opencodeGo) + } + this.trackActiveFailureStreak('kimi', kimi) + if (shouldApplyMiniMax) { + this.trackActiveFailureStreak('minimax', miniMax) + } + // Why: account switches can race in-flight Codex fetches. Only apply a // Codex result if both the selected-account provenance and the request // generation still match, otherwise an old account could overwrite the @@ -1523,6 +1602,7 @@ export class RateLimitService { error: grokResult.reason instanceof Error ? grokResult.reason.message : 'Unknown error', status: 'error' } satisfies ProviderRateLimits) + this.trackActiveFailureStreak('grok', grok) this.updateState({ ...this.state, grok: this.applyStalePolicy(grok, previousState.grok) @@ -1575,6 +1655,9 @@ export class RateLimitService { const shouldApplyCodex = codexGeneration === this.codexFetchGeneration && codexProvenance === latestCodexProvenance + if (shouldApplyCodex) { + this.trackActiveFailureStreak('codex', codex) + } this.updateState({ ...this.state, codex: shouldApplyCodex ? this.applyStalePolicy(codex, previousState.codex) : this.state.codex @@ -1630,6 +1713,9 @@ export class RateLimitService { claudeProvenance === latestClaudeProvenance && this.isSameClaudeTarget(claudeTarget, this.claudeFetchTarget) + if (shouldApplyClaude) { + this.trackActiveFailureStreak('claude', claude) + } this.updateState({ ...this.state, claude: shouldApplyClaude @@ -1669,6 +1755,7 @@ export class RateLimitService { return } + this.trackActiveFailureStreak('grok', grok) this.updateState({ ...this.state, grok: this.applyStalePolicy(grok, previousState.grok) diff --git a/src/renderer/src/components/stats/GrokUsagePane.tsx b/src/renderer/src/components/stats/GrokUsagePane.tsx index 8e51049e3d42..08600621552f 100644 --- a/src/renderer/src/components/stats/GrokUsagePane.tsx +++ b/src/renderer/src/components/stats/GrokUsagePane.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react' import { CalendarClock, ExternalLink, RefreshCw, Sparkles } from 'lucide-react' import { translate } from '@/i18n/i18n' import { useAppStore } from '../../store' @@ -13,6 +14,18 @@ export function GrokUsagePane(): React.JSX.Element { const openSettingsPage = useAppStore((s) => s.openSettingsPage) const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) + // Why: settled snapshots keep their status during refetches (no 'fetching' + // repaint), so manual-refresh feedback must be renderer-local, matching the + // StatusBar refresh button. + const [isRefreshing, setIsRefreshing] = useState(false) + + const handleRefresh = (): void => { + if (isRefreshing) { + return + } + setIsRefreshing(true) + void refreshGrokRateLimits().finally(() => setIsRefreshing(false)) + } const openGrokAccounts = (): void => { openSettingsTarget({ pane: 'accounts', repoId: null, sectionId: 'accounts-grok' }) @@ -57,7 +70,7 @@ export function GrokUsagePane(): React.JSX.Element { grok?.weekly && typeof grok.weekly.usedPercent === 'number' ? Math.round(grok.weekly.usedPercent) : null - const isFetching = grok?.status === 'fetching' + const isFetching = isRefreshing || grok?.status === 'fetching' return (
void refreshGrokRateLimits()} + onClick={handleRefresh} disabled={isFetching} aria-label={translate( 'auto.components.stats.GrokUsagePane.i0j1k2l3m4', From a4cfc82d69a1d5123f5270cd64e2f45a80b25383 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:02:06 -0700 Subject: [PATCH 004/351] Surface Grok unified-billing monthly usage instead of a permanent warning (#8769) * Surface Grok unified-billing monthly usage instead of a permanent warning Unified-billing Grok accounts have no weekly credits: the /billing?format=credits view returns a config without creditUsagePercent, so the status bar was stuck on 'Grok billing response did not include credit usage' even though the account has a real quota. The default (format-less) /billing view reports it as an included monthly budget (monthlyLimit/used with the billing period). When the credits view has no weekly credit usage, read the default view and surface monthly usage as the provider's 30-day window (already supported by the tooltip and chip visibility for OpenCode Go). If the fallback read fails, the previous 'unavailable' presentation stands rather than escalating to an error chip. * Review fixes: chip renders monthly-only usage; fallback failures keep stale data - StatusBar ProviderSegment: monthly window is chip-visible when it is the sole window (Grok unified billing); fetching/error no-data guards and the icon-only dot now count monthly, matching tooltip.tsx. OpenCode Go chips are unchanged (monthly stays tooltip-only next to session/weekly). - grok-fetcher: monthly-fallback request failures propagate as 'error' so applyStalePolicy keeps the last good monthly snapshot; 'unavailable' is reserved for a successful response without monthly fields. * Settings: show Grok monthly usage row for unified-billing accounts Why: the Grok accounts section only rendered the weekly-credits row, so unified-billing accounts showed a signed-in state with no usage at all. * Use generated localization keys for Grok monthly copy --- src/main/rate-limits/grok-fetcher.test.ts | 86 ++++++++++- src/main/rate-limits/grok-fetcher.ts | 143 ++++++++++++++---- .../settings/GrokAccountsSection.tsx | 44 ++++-- .../src/components/status-bar/StatusBar.tsx | 18 ++- .../provider-segment-monthly-window.test.tsx | 86 +++++++++++ src/renderer/src/i18n/locales/en.json | 4 +- src/renderer/src/i18n/locales/es.json | 4 +- src/renderer/src/i18n/locales/ja.json | 4 +- src/renderer/src/i18n/locales/ko.json | 4 +- src/renderer/src/i18n/locales/zh.json | 4 +- src/shared/rate-limit-types.ts | 2 +- 11 files changed, 345 insertions(+), 54 deletions(-) create mode 100644 src/renderer/src/components/status-bar/provider-segment-monthly-window.test.tsx diff --git a/src/main/rate-limits/grok-fetcher.test.ts b/src/main/rate-limits/grok-fetcher.test.ts index 07c536f32a0e..e387a13e3a18 100644 --- a/src/main/rate-limits/grok-fetcher.test.ts +++ b/src/main/rate-limits/grok-fetcher.test.ts @@ -107,13 +107,95 @@ describe('fetchGrokRateLimits', () => { expect(netFetchMock).not.toHaveBeenCalled() }) - it('returns unavailable when billing has no credit usage', async () => { + it('returns unavailable when neither billing view has usage', async () => { authState.file = freshAuthJson() - netFetchMock.mockResolvedValueOnce(jsonResponse({ config: { subscriptionTier: 'Enterprise' } })) + netFetchMock + .mockResolvedValueOnce(jsonResponse({ config: { subscriptionTier: 'Enterprise' } })) + .mockResolvedValueOnce(jsonResponse({ config: { subscriptionTier: 'Enterprise' } })) const result = await fetchGrokRateLimits() expect(result.status).toBe('unavailable') expect(result.weekly).toBeNull() + expect(result.monthly).toBeUndefined() + }) + + it('maps monthly included usage for unified-billing accounts without weekly credits', async () => { + authState.file = freshAuthJson() + netFetchMock + .mockResolvedValueOnce( + jsonResponse({ + config: { + currentPeriod: { + type: 'USAGE_PERIOD_TYPE_WEEKLY', + start: '2026-07-10T19:38:56.948570+00:00', + end: '2026-07-17T19:38:56.948570+00:00' + }, + isUnifiedBillingUser: true, + subscriptionTier: 'SuperGrok' + } + }) + ) + .mockResolvedValueOnce( + jsonResponse({ + config: { + monthlyLimit: { val: 150000 }, + used: { val: 837 }, + billingPeriodStart: '2026-07-01T00:00:00+00:00', + billingPeriodEnd: '2026-08-01T00:00:00+00:00' + } + }) + ) + + const result = await fetchGrokRateLimits() + expect(result.status).toBe('ok') + expect(result.error).toBeNull() + expect(result.weekly).toBeNull() + expect(result.monthly?.usedPercent).toBeCloseTo((837 / 150000) * 100, 5) + expect(result.monthly?.windowMinutes).toBe(43_200) + expect(result.monthly?.resetsAt).toBe(Date.parse('2026-08-01T00:00:00+00:00')) + expect(result.usageMetadata?.authProvenance).toContain('SuperGrok') + + expect(netFetchMock).toHaveBeenNthCalledWith( + 2, + 'https://cli-chat-proxy.grok.com/v1/billing', + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer access-token' }) + }) + ) + }) + + // Why: 'unavailable' would make applyStalePolicy discard the last good + // monthly snapshot; transient fallback failures must present like transient + // credits-view failures so stale data survives. + it('surfaces an error when the monthly fallback request fails', async () => { + authState.file = freshAuthJson() + netFetchMock + .mockResolvedValueOnce(jsonResponse({ config: { isUnifiedBillingUser: true } })) + .mockResolvedValueOnce(jsonResponse({}, 500)) + + const result = await fetchGrokRateLimits() + expect(result.status).toBe('error') + expect(result.error).toBe('Grok usage request failed (HTTP 500)') + }) + + it('surfaces an error when the monthly fallback request throws', async () => { + authState.file = freshAuthJson() + netFetchMock + .mockResolvedValueOnce(jsonResponse({ config: { isUnifiedBillingUser: true } })) + .mockRejectedValueOnce(new Error('network down')) + + const result = await fetchGrokRateLimits() + expect(result.status).toBe('error') + expect(result.error).toBe('network down') + }) + + it('does not request the default billing view when weekly credits are present', async () => { + authState.file = freshAuthJson() + netFetchMock.mockResolvedValueOnce(jsonResponse(BILLING_RESPONSE)) + + const result = await fetchGrokRateLimits() + expect(result.status).toBe('ok') + expect(netFetchMock).toHaveBeenCalledTimes(1) }) it('returns unavailable when billing response has no config', async () => { diff --git a/src/main/rate-limits/grok-fetcher.ts b/src/main/rate-limits/grok-fetcher.ts index 099298a56c44..eaa140eb2be2 100644 --- a/src/main/rate-limits/grok-fetcher.ts +++ b/src/main/rate-limits/grok-fetcher.ts @@ -12,8 +12,12 @@ const GROK_CLI_PROXY_BASE = process.env.GROK_CLI_CHAT_PROXY_BASE_URL?.trim().replace(/\/$/, '') || 'https://cli-chat-proxy.grok.com/v1' const BILLING_CREDITS_URL = `${GROK_CLI_PROXY_BASE}/billing?format=credits` +// Why: unified-billing accounts have no weekly credits; their included monthly +// budget is only present in the default (format-less) billing view. +const BILLING_DEFAULT_URL = `${GROK_CLI_PROXY_BASE}/billing` const API_TIMEOUT_MS = 10_000 const WEEKLY_WINDOW_MINUTES = 10_080 +const MONTHLY_WINDOW_MINUTES = 43_200 const GROK_CLI_AUTH_HEADER = 'xai-grok-cli' @@ -31,6 +35,8 @@ type GrokBillingConfig = { billingPeriodStart?: string billingPeriodEnd?: string subscriptionTier?: string + monthlyLimit?: GrokMoneyVal + used?: GrokMoneyVal onDemandCap?: GrokMoneyVal onDemandUsed?: GrokMoneyVal prepaidBalance?: GrokMoneyVal @@ -81,6 +87,28 @@ function mapWeeklyCredits(config: GrokBillingConfig): RateLimitWindow | null { } } +function parseMoneyVal(value: GrokMoneyVal | undefined): number | null { + const raw = value?.val + const num = typeof raw === 'string' ? Number.parseFloat(raw) : raw + return typeof num === 'number' && Number.isFinite(num) ? num : null +} + +function mapMonthlyUsage(config: GrokBillingConfig): RateLimitWindow | null { + const limit = parseMoneyVal(config.monthlyLimit) + const used = parseMoneyVal(config.used) + if (limit === null || used === null || limit <= 0) { + return null + } + const periodEnd = config.currentPeriod?.end ?? config.billingPeriodEnd + const resetsAt = periodEnd ? Date.parse(periodEnd) : null + return { + usedPercent: Math.min(100, Math.max(0, (used / limit) * 100)), + windowMinutes: MONTHLY_WINDOW_MINUTES, + resetsAt: resetsAt !== null && Number.isFinite(resetsAt) ? resetsAt : null, + resetDescription: parseResetDescription(periodEnd) + } +} + function grokRequestHeaders(session: GrokAuthSession): Record { const headers: Record = { Authorization: `Bearer ${session.accessToken}`, @@ -103,28 +131,22 @@ function resolveBillingConfig(data: GrokBillingResponse): GrokBillingConfig | nu return null } -function mapBillingResponse( - data: GrokBillingResponse, +function billingUsageResult( + windows: { weekly?: RateLimitWindow | null; monthly?: RateLimitWindow | null }, + config: GrokBillingConfig, session: GrokAuthSession ): ProviderRateLimits { - const config = resolveBillingConfig(data) - // Why: a 200 without credit usage means the plan has no weekly credits — - // 'unavailable' hides the bar (like Claude on API-key billing); 'error' - // would paint a permanent alert for a signed-in account that has no quota. - if (!config) { - return result('unavailable', 'Grok billing response did not include config') - } - const weekly = mapWeeklyCredits(config) const tier = config.subscriptionTier?.trim() const authLabel = session.email?.trim() || session.userId || 'Grok account' const provenance = tier ? `${authLabel} (${tier})` : authLabel return { provider: 'grok', session: null, - weekly, + weekly: windows.weekly ?? null, + ...(windows.monthly ? { monthly: windows.monthly } : {}), updatedAt: Date.now(), - error: weekly ? null : 'Grok billing response did not include credit usage', - status: weekly ? 'ok' : 'unavailable', + error: null, + status: 'ok', usageMetadata: { source: 'oauth', authProvenance: provenance @@ -132,6 +154,61 @@ function mapBillingResponse( } } +type GrokBillingFetchOutcome = + | { kind: 'data'; data: GrokBillingResponse } + | { kind: 'result'; result: ProviderRateLimits } + +async function fetchBillingData( + url: string, + session: GrokAuthSession, + signal?: AbortSignal +): Promise { + const requestSignal = signal + ? AbortSignal.any([signal, AbortSignal.timeout(API_TIMEOUT_MS)]) + : AbortSignal.timeout(API_TIMEOUT_MS) + const res = await net.fetch(url, { + headers: grokRequestHeaders(session), + signal: requestSignal + }) + if (res.status === 401 || res.status === 403) { + return { + kind: 'result', + result: result('error', `Grok usage request unauthorized (HTTP ${res.status})`) + } + } + if (!res.ok) { + return { + kind: 'result', + result: result('error', `Grok usage request failed (HTTP ${res.status})`) + } + } + const data: unknown = await res.json() + return { + kind: 'data', + data: typeof data === 'object' && data !== null ? (data as GrokBillingResponse) : {} + } +} + +type GrokMonthlyFallbackOutcome = + | { kind: 'window'; window: RateLimitWindow | null } + | { kind: 'result'; result: ProviderRateLimits } + +// Why: request failures propagate as 'error' (thrown errors reach the caller's +// catch) so the stale policy keeps the last good monthly snapshot — the +// 'unavailable' status would discard it. Only a successful response without +// monthly fields means the account truly has no visible quota. +async function fetchMonthlyUsageFallback( + session: GrokAuthSession, + signal?: AbortSignal +): Promise { + const outcome = await fetchBillingData(BILLING_DEFAULT_URL, session, signal) + if (outcome.kind === 'result') { + return outcome + } + const config = outcome.data.config ?? outcome.data + return { kind: 'window', window: mapMonthlyUsage(config) } +} + // Why: Orca never runs grok login; it only reads the session file the CLI updates. export async function fetchGrokRateLimits( options: { signal?: AbortSignal; authReadResult?: GrokAuthReadResult } = {} @@ -152,24 +229,32 @@ export async function fetchGrokRateLimits( } try { - const signal = options.signal - ? AbortSignal.any([options.signal, AbortSignal.timeout(API_TIMEOUT_MS)]) - : AbortSignal.timeout(API_TIMEOUT_MS) - const res = await net.fetch(BILLING_CREDITS_URL, { - headers: grokRequestHeaders(session), - signal - }) - if (res.status === 401 || res.status === 403) { - return result('error', `Grok usage request unauthorized (HTTP ${res.status})`) + const outcome = await fetchBillingData(BILLING_CREDITS_URL, session, options.signal) + if (outcome.kind === 'result') { + return outcome.result + } + const config = resolveBillingConfig(outcome.data) + // Why: a 200 without credit usage means the plan has no weekly credits — + // 'unavailable' hides the bar (like Claude on API-key billing); 'error' + // would paint a permanent alert for a signed-in account that has no quota. + if (!config) { + return result('unavailable', 'Grok billing response did not include config') + } + const weekly = mapWeeklyCredits(config) + if (weekly) { + return billingUsageResult({ weekly }, config, session) + } + // Why: unified-billing accounts report a monthly included-usage budget + // instead of weekly credits; the credits view omits creditUsagePercent + // for them, so read the default billing view before giving up. + const fallback = await fetchMonthlyUsageFallback(session, options.signal) + if (fallback.kind === 'result') { + return fallback.result } - if (!res.ok) { - return result('error', `Grok usage request failed (HTTP ${res.status})`) + if (fallback.window) { + return billingUsageResult({ monthly: fallback.window }, config, session) } - const data: unknown = await res.json() - return mapBillingResponse( - typeof data === 'object' && data !== null ? (data as GrokBillingResponse) : {}, - session - ) + return result('unavailable', 'Grok billing response did not include credit usage') } catch (err) { return result('error', err instanceof Error ? err.message : 'Grok usage request failed') } diff --git a/src/renderer/src/components/settings/GrokAccountsSection.tsx b/src/renderer/src/components/settings/GrokAccountsSection.tsx index a5eb0ca8d995..474c4c1ee66a 100644 --- a/src/renderer/src/components/settings/GrokAccountsSection.tsx +++ b/src/renderer/src/components/settings/GrokAccountsSection.tsx @@ -52,6 +52,10 @@ export function GrokAccountsSection(): React.JSX.Element { const signedIn = status?.signedIn === true const tokenFresh = status?.tokenFresh === true + // Why: unified-billing accounts have no weekly credits; surface their + // monthly included usage instead of hiding the usage row entirely. + const usageIsWeekly = Boolean(grokUsage?.weekly) + const usageWindow = grokUsage?.weekly ?? grokUsage?.monthly ?? null return (
@@ -148,32 +152,46 @@ export function GrokAccountsSection(): React.JSX.Element {
- {grokUsage?.weekly ? ( + {usageWindow ? (
- {Math.round(grokUsage.weekly.usedPercent)}% + {Math.round(usageWindow.usedPercent)}% - {grokUsage.weekly.resetDescription ? ( + {usageWindow.resetDescription ? ( {translate( 'auto.components.settings.GrokAccountsSection.c6d1a8f4e2', 'Resets {{when}}', - { when: grokUsage.weekly.resetDescription } + { when: usageWindow.resetDescription } )} ) : null} - {grokUsage.usageMetadata?.authProvenance ? ( + {grokUsage?.usageMetadata?.authProvenance ? ( {grokUsage.usageMetadata.authProvenance} diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index b3cc8a922c18..2c9a2fb979c1 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -1114,7 +1114,7 @@ function WindowLabel({ // the rest (Flash Lite, experimental) are secondary and would clutter the bar. const STATUS_BAR_BUCKET_NAMES = new Set(['Flash', 'Pro', '1.5 Pro']) -function ProviderSegment({ +export function ProviderSegment({ p, compact, display @@ -1137,7 +1137,7 @@ function ProviderSegment({ } // Fetching with no prior data - if (p.status === 'fetching' && !p.session && !p.weekly && !p.fableWeekly) { + if (p.status === 'fetching' && !p.session && !p.weekly && !p.fableWeekly && !p.monthly) { return ( @@ -1156,7 +1156,7 @@ function ProviderSegment({ } // Error with no data - if (p.status === 'error' && !p.session && !p.weekly && !p.fableWeekly) { + if (p.status === 'error' && !p.session && !p.weekly && !p.fableWeekly && !p.monthly) { return ( @@ -1215,6 +1215,16 @@ function ProviderSegment({ window: p.fableWeekly, label: translate('auto.components.status.bar.StatusBar.a79c64f87e', 'Fable') } + : null, + // Why: monthly is chip-visible only when it's the sole window (Grok + // unified billing); providers with session/weekly data (OpenCode Go) + // keep monthly tooltip-only so the chip stays uncluttered. + p.monthly && !p.session && !p.weekly + ? { + key: 'monthly', + window: p.monthly, + label: formatWindowLabel(p.monthly.windowMinutes) + } : null ].filter((w): w is { key: string; window: RateLimitWindow; label: string } => w !== null) @@ -1783,7 +1793,7 @@ export function ProviderDetailsMenu({ {iconOnly ? ( {provider.provider === 'claude' diff --git a/src/renderer/src/components/status-bar/provider-segment-monthly-window.test.tsx b/src/renderer/src/components/status-bar/provider-segment-monthly-window.test.tsx new file mode 100644 index 000000000000..866282b8680c --- /dev/null +++ b/src/renderer/src/components/status-bar/provider-segment-monthly-window.test.tsx @@ -0,0 +1,86 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import type { ProviderRateLimits, RateLimitWindow } from '../../../../shared/rate-limit-types' + +vi.mock('@/i18n/i18n', () => ({ + i18n: { language: 'en' }, + translate: (_key: string, fallback: string, values?: Record) => { + let result = fallback + for (const [key, value] of Object.entries(values ?? {})) { + result = result.replace(`{{${key}}}`, value) + } + return result + } +})) + +vi.mock('@/lib/agent-catalog', () => ({ + AgentIcon: () => null +})) + +vi.mock('../../store', () => ({ + useAppStore: (selector: (state: { usagePercentageDisplay: 'used' | 'remaining' }) => unknown) => + selector({ usagePercentageDisplay: 'used' }) +})) + +function windowOf(usedPercent: number, windowMinutes: number): RateLimitWindow { + return { usedPercent, windowMinutes, resetsAt: null, resetDescription: null } +} + +// Grok unified-billing accounts surface a monthly window and nothing else. +function grokMonthlyLimits(status: ProviderRateLimits['status']): ProviderRateLimits { + return { + provider: 'grok', + session: null, + weekly: null, + monthly: windowOf(25, 43200), + updatedAt: Date.now(), + error: null, + status + } +} + +describe('ProviderSegment monthly window', () => { + it('renders a monthly-only snapshot in the chip instead of a bare icon', async () => { + const { ProviderSegment } = await import('./StatusBar') + + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('25% used 30d') + }) + + it('shows monthly data while fetching instead of the loading placeholder', async () => { + const { ProviderSegment } = await import('./StatusBar') + + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('25% used 30d') + expect(markup).not.toContain('···') + }) + + // Why: providers with session/weekly windows (OpenCode Go) keep monthly + // tooltip-only so the chip stays uncluttered. + it('keeps monthly out of the chip when session and weekly windows exist', async () => { + const { ProviderSegment } = await import('./StatusBar') + + const limits: ProviderRateLimits = { + provider: 'opencode-go', + session: windowOf(10, 300), + weekly: windowOf(20, 10080), + monthly: windowOf(30, 43200), + updatedAt: Date.now(), + error: null, + status: 'ok' + } + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('10% used 5h') + expect(markup).toContain('20% used wk') + expect(markup).not.toContain('30d') + }) +}) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index d3f221f77e88..06e0f50482cc 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -9028,7 +9028,9 @@ "3325d996cb": "Refresh usage", "a8f3e2c1b4": "Weekly credits", "b7e2d9f0a3": "Same weekly credit % as the grok /usage screen in the terminal.", - "c6d1a8f4e2": "Resets {{when}}" + "c6d1a8f4e2": "Resets {{when}}", + "e6dadc1e2b": "Monthly usage", + "75e396bf42": "Included monthly usage for Grok unified-billing accounts." }, "AppearanceWindowSidebarSection": { "usagePercentageDisplayUsed": "Used", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 903467f92276..2d093ebe8f88 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -9028,7 +9028,9 @@ "3325d996cb": "Actualizar uso", "a8f3e2c1b4": "Créditos semanales", "b7e2d9f0a3": "El mismo porcentaje de créditos semanales que la pantalla grok /usage en la terminal.", - "c6d1a8f4e2": "Se restablece {{when}}" + "c6d1a8f4e2": "Se restablece {{when}}", + "e6dadc1e2b": "Monthly usage", + "75e396bf42": "Included monthly usage for Grok unified-billing accounts." }, "AppearanceWindowSidebarSection": { "usagePercentageDisplayUsed": "Usado", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 123baa012ce9..4ad9de324bee 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -9028,7 +9028,9 @@ "3325d996cb": "使用状況を更新", "a8f3e2c1b4": "週次クレジット", "b7e2d9f0a3": "ターミナルの grok /usage 画面と同じ週次クレジット率です。", - "c6d1a8f4e2": "{{when}} にリセット" + "c6d1a8f4e2": "{{when}} にリセット", + "e6dadc1e2b": "Monthly usage", + "75e396bf42": "Included monthly usage for Grok unified-billing accounts." }, "AppearanceWindowSidebarSection": { "usagePercentageDisplayUsed": "使用済み", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index e173a6e17186..dfbefd0700b7 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -9028,7 +9028,9 @@ "3325d996cb": "사용량 새로 고침", "a8f3e2c1b4": "주간 크레딧", "b7e2d9f0a3": "터미널의 grok /usage 화면과 같은 주간 크레딧 비율입니다.", - "c6d1a8f4e2": "{{when}}에 재설정" + "c6d1a8f4e2": "{{when}}에 재설정", + "e6dadc1e2b": "Monthly usage", + "75e396bf42": "Included monthly usage for Grok unified-billing accounts." }, "AppearanceWindowSidebarSection": { "usagePercentageDisplayUsed": "사용", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index a82d1d5fc41f..eca7076c294a 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -9028,7 +9028,9 @@ "3325d996cb": "刷新使用量", "a8f3e2c1b4": "每周额度", "b7e2d9f0a3": "与终端中 grok /usage 屏幕显示的每周额度百分比相同。", - "c6d1a8f4e2": "{{when}} 重置" + "c6d1a8f4e2": "{{when}} 重置", + "e6dadc1e2b": "Monthly usage", + "75e396bf42": "Included monthly usage for Grok unified-billing accounts." }, "AppearanceWindowSidebarSection": { "usagePercentageDisplayUsed": "已用", diff --git a/src/shared/rate-limit-types.ts b/src/shared/rate-limit-types.ts index 65c09aaffaec..0e77decc76ed 100644 --- a/src/shared/rate-limit-types.ts +++ b/src/shared/rate-limit-types.ts @@ -59,7 +59,7 @@ export type ProviderRateLimits = { weekly: RateLimitWindow | null /** Claude Fable 7-day weekly window, null if not available. */ fableWeekly?: RateLimitWindow | null - /** 30-day monthly window (OpenCode Go only), null if not available. */ + /** 30-day monthly window (OpenCode Go, Grok unified billing), null if not available. */ monthly?: RateLimitWindow | null /** Named per-model buckets (Gemini only). */ buckets?: RateLimitBucket[] From 6ee2af357b82c8717ad633f1251b6b310486dd7d Mon Sep 17 00:00:00 2001 From: ishihara takashi Date: Wed, 15 Jul 2026 09:25:10 +0900 Subject: [PATCH 005/351] Add an in-place screenshot markup tool to the browser pane (#6335) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add in-place screenshot markup for the browser pane Adds a Draw button to the browser toolbar (local and remote/SSH panes) that freezes the visible viewport into a still image and overlays a drawing canvas. Users can mark it up with pen, highlighter, arrow, rectangle, ellipse, and text (color, thickness, undo/redo), then copy the composited PNG to the clipboard to paste into their agent — reusing the existing clipboard screenshot path, so it works for local and remote agents alike. The drawing layer is a renderer-side canvas, so the base image is the only environment-specific piece: local panes use webview.capturePage(), remote panes snapshot the already-displayed screencast frame. Drawing model, compositing, and shape rendering are isolated, unit-tested modules. * Fix browser markup clipboard, transparency, text input, and toggle - Copy now always produces a PNG (the clipboard handler accepts PNG only; a JPEG fallback silently produced an empty clipboard) and targets the clipboard size limit so realistic viewports stay full-resolution. - Flatten the captured base image onto opaque white so transparent page backgrounds no longer ghost the live view through the frozen backdrop. - Keep markup text-input keystrokes local (focus the field explicitly and stop propagation) so the browser pane's global handlers can't swallow them. - Make the Draw toolbar button toggle markup mode off on a second click. * Add markup text sizing, object re-editing, and text-input fixes - Add a font-size control (text tool) to the markup toolbar. - Add a Select tool to re-edit committed shapes: click to select, drag to move, change color/width/font-size of the selection, Delete to remove, and double-click text to re-edit its content. Undo/redo now snapshots the whole shape list so every edit (not just adding) is reversible. - Fix the text input: transparent ink-colored field (no dark theme box over the screenshot) and ignore Enter during IME composition so Japanese conversion no longer commits the annotation early. - Split the grown overlay logic into focused modules (editor hook, pointer + keyboard hooks, canvas renderer, document edits, hit-testing) to stay within the file-size budget. * Improve markup text commit and move toolbar to the bottom - Clicking elsewhere while a text box is open now commits the text and switches to the Select tool, instead of discarding it (the input unmounted before its blur fired) and opening a new box at the click. - Move the drawing toolbar to the bottom (stacked above the actions bar) and open the color/size popover upward. * Fix markup text re-editing alignment, doubling, and selection bounds - Hide the text shape being re-edited from the canvas so the live input isn't doubled by its committed render. - Drop the input's padding/border and use the same font as the canvas so the typing preview sits exactly where the text renders (no shift); field-sizing keeps the box hugging the text. - Estimate text width with full-width (CJK) awareness so the selection box and hit bounds track Japanese text instead of clipping it. - Move the font-size control out of the color popover into its own toolbar button showing the current size. * Unify markup text selection and edit framing Draw the selection box on the canvas even while a text shape is being re-edited (the shape stays hidden so it isn't doubled), and strip the box off the text input so it renders only the editable glyphs. The frame is now identical whether selecting or editing, and the text overlays the same position with the same font, so there's no shift between the two. * Align markup text-edit baseline with the committed render Force the text input's line-height to 1 and zero its height/padding so its top edge matches the canvas textBaseline:'top' render — the editing text no longer sits slightly lower than the committed/selected text. * Address review: markup hit-testing, clear reset, and async guards - Hit-test highlights against their rendered (4x) thickness so the select tool doesn't miss large highlight strokes. - Reset transient editor state (pending text, selection, in-progress drag) on Clear all so a pending input blur can't re-add text and no stale selection lingers over the emptied canvas. - Guard markup completion with the capture token so a stale onDeliver can't reset or error a session the user already cancelled or restarted. - Skip the grab-element keyboard shortcut while the markup overlay is open, matching the already-disabled grab toolbar buttons. - Localize the markup error messages and surface them via a toast (the controller's error was otherwise never shown). * Trim browser markup to a draw-only tool and fix two delivery bugs Reshape the screenshot-markup overlay to match its actual use — a throwaway scribble the user copies once into their agent — and drop the re-editable vector-editor machinery that made it an outlier in scope. - Remove the select/move/restyle/re-edit subsystem: hit-testing, per-shape document edits, drag preview, selection frame, double-click text re-edit, Delete-to-remove, and the CJK text-width heuristic / multi-line text plumbing that only fed selection bounds. Keep pen, highlighter, arrow, rect, ellipse, and text with color/thickness/font-size and undo/redo/clear. - Fix silent data loss on HiDPI: the compositor budgeted only bytes, so a large Retina viewport could produce a PNG under the byte limit but over the clipboard handler's pixel ceiling, which the handler drops silently while the UI still reported success. Composite now scales down to fit maxPixels too. - Fix a stranded UI after a capture failure: the controller stayed "active" with no overlay and an inert Escape. It now returns to idle (capture failure) or back to the drawing surface (compose failure) after the error toast. Net ~660 fewer lines and 3 fewer modules; delivery (freeze → composite PNG → existing clipboard-image paste) is unchanged. Unit tests updated. * Add a one-time discovery hint for the browser Draw button Highlight the new screenshot-markup Draw button the first time it's usable so users — including existing ones — notice the tool. A localStorage-gated, show-once tooltip + highlight ring, independent of the browser contextual tour (which is capped at three steps and only shows to users who haven't seen it). - New use-markup-draw-hint hook: opens once per install when the button is enabled and idle, auto-dismisses, and dismisses on click; skips cleanly when storage is unavailable. - MarkupDrawButton forces its tooltip open with the discovery copy and rings the button while the hint shows. - Localize the hint (drawHint) across en/es/ja/ko/zh; normalize the markup key order alphabetically. * Align the markup copy button label with "Copy Screenshot" The browser pane's two image-copy actions read inconsistently in English — "Copy Screenshot" (the grab flow) vs "Copy markup" (the overlay). Match the new markup button to the existing one: "Copy Markup". English only; other locales already use their own sentence-case convention consistently, so they're unchanged. * Drop the markup Draw hint auto-timeout; dismiss on act only The one-time Draw-button hint no longer auto-dismisses on a timer the user might miss. It now stays open until the user clicks Draw or the button stops being usable (grab started, markup open, or a blank tab), which also fixes a case where losing eligibility mid-hint left the forced-open tooltip stuck over a disabled button. * Optimize markup rendering and harden compose delivery Performance: - Rasterize committed shapes once into an offscreen layer; per-frame paint blits that layer and draws only the in-progress shape, so a fast pointermove stream never re-strokes the whole scene. - Encode the composite PNG off the main thread via canvas.toBlob (a large synchronous toDataURL froze the renderer), with a rAF yield so the "composing" UI paints first. Correctness / review fixes: - Don't overwrite the clipboard after cancel: re-check the capture token between the (now async) compose and onDeliver, closing the window where Escape during composing still delivered the markup. - Keep the offscreen layer and visible canvas in lockstep by threading one measured devicePixelRatio through both paints, and re-measure on window resize so a monitor move (dpr change without a CSS-box change) repaints at the new scale. Blit stretched to the canvas box so a transient mismatch never clips. - Drop the stale "or JPEG fallback" note; compose is PNG-only. * Fix test fixtures for markup pixel-budget clamping to use a truly oversi The old 3840×2160×dpr2 fixture (33.2M px) was already under the 32×1024×1024 (~33.55M) ceiling, so the "never over budget" assertions weren't exercising the clamp path. Switch to 4000×3000×dpr2 (48M px), which genuinely exceeds the ceiling. --------- Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> --- .../components/browser-pane/BrowserPane.tsx | 90 +++++- .../browser-pane/markup/MarkupDrawButton.tsx | 63 ++++ .../browser-pane/markup/MarkupOverlay.tsx | 159 ++++++++++ .../browser-pane/markup/MarkupToolbar.tsx | 293 ++++++++++++++++++ .../browser-pane/markup/markup-base-image.ts | 78 +++++ .../markup/markup-canvas-render.ts | 88 ++++++ .../markup/markup-clipboard-delivery.ts | 21 ++ .../markup/markup-drawing-model.test.ts | 166 ++++++++++ .../markup/markup-drawing-model.ts | 213 +++++++++++++ .../markup/markup-screenshot-compose.test.ts | 75 +++++ .../markup/markup-screenshot-compose.ts | 201 ++++++++++++ .../markup/markup-shape-render.test.ts | 153 +++++++++ .../markup/markup-shape-render.ts | 144 +++++++++ .../markup/use-markup-draw-hint.ts | 48 +++ .../browser-pane/markup/useMarkupEditor.ts | 179 +++++++++++ .../markup/useMarkupKeyboardShortcuts.ts | 52 ++++ .../browser-pane/markup/useMarkupMode.ts | 172 ++++++++++ .../markup/useMarkupPointerHandlers.ts | 99 ++++++ src/renderer/src/i18n/locales/en.json | 28 ++ src/renderer/src/i18n/locales/es.json | 28 ++ src/renderer/src/i18n/locales/ja.json | 28 ++ src/renderer/src/i18n/locales/ko.json | 28 ++ src/renderer/src/i18n/locales/zh.json | 28 ++ 23 files changed, 2430 insertions(+), 4 deletions(-) create mode 100644 src/renderer/src/components/browser-pane/markup/MarkupDrawButton.tsx create mode 100644 src/renderer/src/components/browser-pane/markup/MarkupOverlay.tsx create mode 100644 src/renderer/src/components/browser-pane/markup/MarkupToolbar.tsx create mode 100644 src/renderer/src/components/browser-pane/markup/markup-base-image.ts create mode 100644 src/renderer/src/components/browser-pane/markup/markup-canvas-render.ts create mode 100644 src/renderer/src/components/browser-pane/markup/markup-clipboard-delivery.ts create mode 100644 src/renderer/src/components/browser-pane/markup/markup-drawing-model.test.ts create mode 100644 src/renderer/src/components/browser-pane/markup/markup-drawing-model.ts create mode 100644 src/renderer/src/components/browser-pane/markup/markup-screenshot-compose.test.ts create mode 100644 src/renderer/src/components/browser-pane/markup/markup-screenshot-compose.ts create mode 100644 src/renderer/src/components/browser-pane/markup/markup-shape-render.test.ts create mode 100644 src/renderer/src/components/browser-pane/markup/markup-shape-render.ts create mode 100644 src/renderer/src/components/browser-pane/markup/use-markup-draw-hint.ts create mode 100644 src/renderer/src/components/browser-pane/markup/useMarkupEditor.ts create mode 100644 src/renderer/src/components/browser-pane/markup/useMarkupKeyboardShortcuts.ts create mode 100644 src/renderer/src/components/browser-pane/markup/useMarkupMode.ts create mode 100644 src/renderer/src/components/browser-pane/markup/useMarkupPointerHandlers.ts diff --git a/src/renderer/src/components/browser-pane/BrowserPane.tsx b/src/renderer/src/components/browser-pane/BrowserPane.tsx index 9781ea94961a..5a0cb9d06156 100644 --- a/src/renderer/src/components/browser-pane/BrowserPane.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPane.tsx @@ -181,6 +181,10 @@ import { shouldPollChromiumErrorPage } from './chromium-error-page-polling' import { useContextualTour } from '@/components/contextual-tours/use-contextual-tour' import { translate } from '@/i18n/i18n' import { isBrowserPagePanePaintable } from './browser-page-paintability' +import { useMarkupMode, type MarkupCaptureContext } from './markup/useMarkupMode' +import { MarkupOverlay } from './markup/MarkupOverlay' +import { MarkupDrawButton } from './markup/MarkupDrawButton' +import { deliverMarkupToClipboard } from './markup/markup-clipboard-delivery' type BrowserTabPageState = Partial< Pick< @@ -2394,6 +2398,30 @@ function RemoteBrowserPagePane({ const remoteFrameStyle = useMemo(() => getRemoteBrowserFrameStyle(frameMetadata), [frameMetadata]) + // Why: markup works on remote panes by snapshotting the already-displayed + // screencast — no in-page injection needed, so it is enabled here even + // though element-grab annotations are not. + const markup = useMarkupMode({ + getCaptureContext: useCallback((): MarkupCaptureContext | null => { + const element = imageRef.current + const container = remoteViewportRef.current + if (!element || !container) { + return null + } + const rect = container.getBoundingClientRect() + if (rect.width <= 0 || rect.height <= 0) { + return null + } + return { + source: { kind: 'image', element }, + cssWidth: rect.width, + cssHeight: rect.height, + outputScale: window.devicePixelRatio || 1 + } + }, []), + onDeliver: deliverMarkupToClipboard + }) + return (
{contextMenu @@ -2600,12 +2628,26 @@ function RemoteBrowserPagePane({ )} + (markup.isActive ? markup.cancel() : void markup.start())} + disabled={!frameUrl} + active={markup.isActive} + className="h-7 w-7" + />
+ {markup.isActive && markup.baseImage ? ( + void markup.complete(input)} + onCancel={markup.cancel} + /> + ) : null} {frameUrl ? ( (null) const [findOpen, setFindOpen] = useState(false) const grab = useGrabMode(browserTab.id) + + const markup = useMarkupMode({ + getCaptureContext: useCallback((): MarkupCaptureContext | null => { + const webview = webviewRef.current + const container = containerRef.current + if (!webview || !container) { + return null + } + const rect = container.getBoundingClientRect() + if (rect.width <= 0 || rect.height <= 0) { + return null + } + return { + source: { kind: 'webview', webview }, + cssWidth: rect.width, + cssHeight: rect.height, + outputScale: window.devicePixelRatio || 1 + } + }, []), + onDeliver: deliverMarkupToClipboard + }) const [grabIntent, setGrabIntent] = useState('copy') const grabIntentRef = useRef(grabIntent) grabIntentRef.current = grabIntent @@ -4149,14 +4212,19 @@ function BrowserPagePane({ if (isEditableKeyboardTarget(e.target)) { return } - if (keybindingMatchesAction('browser.grabElement', e, shortcutPlatform, keybindings)) { + // Why: while the markup overlay is open, don't let the grab shortcut start + // the in-guest picker behind it — matching the disabled grab toolbar buttons. + if ( + !markup.isActive && + keybindingMatchesAction('browser.grabElement', e, shortcutPlatform, keybindings) + ) { e.preventDefault() startGrabIntent('copy') } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) - }, [isActive, keybindings, startGrabIntent]) + }, [isActive, keybindings, markup.isActive, startGrabIntent]) useEffect(() => { if (!isActive) { @@ -4989,7 +5057,7 @@ function BrowserPagePane({ 'bg-foreground/80 text-background hover:bg-foreground/90' )} onClick={() => startGrabIntent('copy')} - disabled={isBlankTab} + disabled={isBlankTab || markup.isActive} aria-label={translate( 'auto.components.browser.pane.BrowserPane.fdfc7fe0ef', 'Grab page element' @@ -5026,7 +5094,7 @@ function BrowserPagePane({ 'bg-foreground/80 text-background hover:bg-foreground/90' )} onClick={() => startGrabIntent('annotate')} - disabled={isBlankTab} + disabled={isBlankTab || markup.isActive} aria-label={translate( 'auto.components.browser.pane.BrowserPane.fc9be38f6f', 'Annotate page element' @@ -5050,6 +5118,12 @@ function BrowserPagePane({ + (markup.isActive ? markup.cancel() : void markup.start())} + disabled={isBlankTab || grab.state !== 'idle'} + active={markup.isActive} + /> + + + + + {hintOpen + ? translate( + 'auto.components.browser-pane.markup.drawHint', + 'New — draw on the page, then copy it to your agent' + ) + : label} + + + ) +} diff --git a/src/renderer/src/components/browser-pane/markup/MarkupOverlay.tsx b/src/renderer/src/components/browser-pane/markup/MarkupOverlay.tsx new file mode 100644 index 000000000000..28b8c7ef1673 --- /dev/null +++ b/src/renderer/src/components/browser-pane/markup/MarkupOverlay.tsx @@ -0,0 +1,159 @@ +import React, { useCallback, useRef, useState } from 'react' +import { Check, X } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import { MarkupToolbar } from './MarkupToolbar' +import type { MarkupBaseImage } from './markup-base-image' +import type { MarkupShape } from './markup-drawing-model' +import { TEXT_FONT_FAMILY } from './markup-shape-render' +import { useMarkupEditor } from './useMarkupEditor' + +export type MarkupOverlayProps = { + baseImage: MarkupBaseImage + busy: boolean + onComplete: (input: { imageElement: HTMLImageElement; shapes: MarkupShape[] }) => void + onCancel: () => void +} + +export function MarkupOverlay({ + baseImage, + busy, + onComplete, + onCancel +}: MarkupOverlayProps): React.JSX.Element { + const baseImgRef = useRef(null) + const [baseLoaded, setBaseLoaded] = useState(false) + const editor = useMarkupEditor(busy, onCancel) + const { pendingText } = editor + + const handleDone = useCallback(() => { + const imageElement = baseImgRef.current + if (!imageElement || !baseLoaded) { + return + } + onComplete({ imageElement, shapes: editor.shapes }) + }, [baseLoaded, editor.shapes, onComplete]) + + return ( +
+ setBaseLoaded(true)} + onError={() => { + // Why: the backdrop is a self-generated data URL, so a decode failure is + // unexpected — but never trap the user with a permanently-disabled Done. + console.error('markup: base screenshot failed to load') + onCancel() + }} + className="pointer-events-none absolute inset-0 block h-full w-full select-none" + style={{ objectFit: 'fill' }} + /> + + + {pendingText ? ( + event.stopPropagation()} + onBlur={(event) => editor.commitPendingText(event.target.value)} + onKeyDown={(event) => { + // Why: keep keystrokes local — without this the browser pane's global + // key handlers can swallow typing before it reaches the input. + event.stopPropagation() + // Why: during IME composition (e.g. Japanese conversion), Enter + // confirms the candidate — it must NOT also commit the annotation. + if (event.key === 'Enter' && !event.nativeEvent.isComposing) { + event.preventDefault() + editor.commitPendingText(event.currentTarget.value) + } else if (event.key === 'Escape') { + event.preventDefault() + editor.cancelPendingText() + } + }} + // Why: the input renders ONLY the editable glyphs (no box of its own) — + // the selection frame is drawn on the canvas so it's identical whether + // selecting or editing. No padding/border + the same font as the canvas + // so the text sits exactly where it commits. field-sizing hugs the text + // so clicks outside it still reach the canvas. + className="absolute z-30 min-w-0 border-0 bg-transparent p-0 leading-none outline-none [field-sizing:content]" + style={{ + left: pendingText.x, + top: pendingText.y, + color: editor.color, + fontSize: editor.fontSize, + fontWeight: 600, + fontFamily: TEXT_FONT_FAMILY, + // Why: force line-height to 1 (the UA default ~1.2 pushes the glyph + // down) and zero the height so the text top aligns with the canvas's + // textBaseline:'top' render — otherwise editing text sits slightly low. + lineHeight: 1, + height: '1em', + boxSizing: 'content-box', + padding: 0, + textShadow: + editor.color.toLowerCase() === '#ffffff' + ? '0 0 3px rgba(0,0,0,0.7)' + : '0 0 3px rgba(255,255,255,0.9), 0 0 2px rgba(255,255,255,0.9)' + }} + /> + ) : null} + +
+
+ +
+
+ + {translate( + 'auto.components.browser-pane.markup.hint', + 'Draw on the page, then copy the markup to paste into your agent.' + )} + + + +
+
+
+ ) +} diff --git a/src/renderer/src/components/browser-pane/markup/MarkupToolbar.tsx b/src/renderer/src/components/browser-pane/markup/MarkupToolbar.tsx new file mode 100644 index 000000000000..ee616b0ce280 --- /dev/null +++ b/src/renderer/src/components/browser-pane/markup/MarkupToolbar.tsx @@ -0,0 +1,293 @@ +import React from 'react' +import { + ArrowUpRight, + Circle, + Highlighter, + Pencil, + Redo2, + Square, + Trash2, + Type, + Undo2 +} from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import { + MARKUP_COLORS, + MARKUP_FONT_SIZES, + MARKUP_WIDTHS, + type MarkupTool +} from './markup-drawing-model' + +type ToolItem = { + kind: MarkupTool + icon: React.ComponentType<{ className?: string }> + label: string +} + +function toolItems(): ToolItem[] { + return [ + { + kind: 'pen', + icon: Pencil, + label: translate('auto.components.browser-pane.markup.tool.pen', 'Pen') + }, + { + kind: 'highlight', + icon: Highlighter, + label: translate('auto.components.browser-pane.markup.tool.highlight', 'Highlighter') + }, + { + kind: 'arrow', + icon: ArrowUpRight, + label: translate('auto.components.browser-pane.markup.tool.arrow', 'Arrow') + }, + { + kind: 'rect', + icon: Square, + label: translate('auto.components.browser-pane.markup.tool.rect', 'Rectangle') + }, + { + kind: 'ellipse', + icon: Circle, + label: translate('auto.components.browser-pane.markup.tool.ellipse', 'Ellipse') + }, + { + kind: 'text', + icon: Type, + label: translate('auto.components.browser-pane.markup.tool.text', 'Text') + } + ] +} + +export type MarkupToolbarProps = { + tool: MarkupTool + onToolChange: (tool: MarkupTool) => void + color: string + onColorChange: (color: string) => void + width: number + onWidthChange: (width: number) => void + fontSize: number + onFontSizeChange: (fontSize: number) => void + canUndo: boolean + canRedo: boolean + onUndo: () => void + onRedo: () => void + onClear: () => void +} + +export const MarkupToolbar = React.memo(function MarkupToolbar({ + tool, + onToolChange, + color, + onColorChange, + width, + onWidthChange, + fontSize, + onFontSizeChange, + canUndo, + canRedo, + onUndo, + onRedo, + onClear +}: MarkupToolbarProps) { + return ( + +
+ {toolItems().map((item) => ( + onToolChange(item.kind)} + > + + + ))} + + + + + + + + + + + + {translate('auto.components.browser-pane.markup.style', 'Color and thickness')} + + + +
+ {MARKUP_COLORS.map((swatch) => ( +
+
+ {MARKUP_WIDTHS.map((option) => ( + + ))} +
+
+
+ + + + + + + + + + {translate('auto.components.browser-pane.markup.fontSize', 'Font size')} + + + +
+ {MARKUP_FONT_SIZES.map((size) => ( + + ))} +
+
+
+ + + + + + + + + + + + +
+
+ ) +}) + +function Divider(): React.JSX.Element { + return +} + +type IconButtonProps = { + label: string + active?: boolean + disabled?: boolean + onClick: () => void + children: React.ReactNode +} + +function IconButton({ + label, + active, + disabled, + onClick, + children +}: IconButtonProps): React.JSX.Element { + return ( + + + + + + {label} + + + ) +} diff --git a/src/renderer/src/components/browser-pane/markup/markup-base-image.ts b/src/renderer/src/components/browser-pane/markup/markup-base-image.ts new file mode 100644 index 000000000000..1841c6ceed8d --- /dev/null +++ b/src/renderer/src/components/browser-pane/markup/markup-base-image.ts @@ -0,0 +1,78 @@ +// Captures the frozen base screenshot the user draws on. Renderer-only and +// environment-aware: local Electron webviews expose capturePage(); remote panes +// already display the streamed frame as an we can snapshot. Keeping this +// out of the main process means the markup feature works identically for local +// and remote/SSH browser panes. + +// A frozen snapshot as a data URL plus its intrinsic pixel size. The overlay +// shows it as an backdrop and reuses that same loaded element as the +// compositor's base layer. +export type MarkupBaseImage = { + dataUrl: string + width: number + height: number +} + +export type MarkupCaptureSource = + | { kind: 'webview'; webview: Electron.WebviewTag } + | { kind: 'image'; element: HTMLImageElement } + +export async function captureMarkupBaseImage( + source: MarkupCaptureSource +): Promise { + if (source.kind === 'webview') { + return captureFromWebview(source.webview) + } + return captureFromImage(source.element) +} + +async function captureFromWebview(webview: Electron.WebviewTag): Promise { + const native = await webview.capturePage() + if (native.isEmpty()) { + throw new Error('markup: webview capturePage returned an empty image') + } + const size = native.getSize() + // Why: capturePage() can include an alpha channel where the page has no opaque + // background, which would let the live webview ghost through the frozen + // backdrop. Flatten onto white (the browser's default canvas color) so the + // base image is fully opaque. + const image = await loadImage(native.toDataURL()) + return { dataUrl: flattenToOpaquePng(image, size.width, size.height), ...size } +} + +function captureFromImage(element: HTMLImageElement): MarkupBaseImage { + const width = element.naturalWidth || element.width + const height = element.naturalHeight || element.height + // Why: drawImage() silently no-ops on an undecoded/detached source, yielding a + // blank PNG. Fail fast instead so the caller surfaces an error. + if (!element.complete || !element.isConnected || width <= 0 || height <= 0) { + throw new Error('markup: remote frame image is not ready') + } + // Why: snapshot the live frame into a detached canvas (opaque white backdrop) + // so later screencast frames can't change the base image and transparent + // regions don't ghost the live stream through. + return { dataUrl: flattenToOpaquePng(element, width, height), width, height } +} + +function flattenToOpaquePng(image: CanvasImageSource, width: number, height: number): string { + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + const ctx = canvas.getContext('2d') + if (!ctx) { + throw new Error('markup: 2d context unavailable for base image') + } + ctx.fillStyle = '#ffffff' + ctx.fillRect(0, 0, width, height) + ctx.drawImage(image, 0, 0, width, height) + return canvas.toDataURL('image/png') +} + +function loadImage(src: string): Promise { + return new Promise((resolve, reject) => { + const image = new Image() + image.onload = () => resolve(image) + image.onerror = () => reject(new Error('markup: failed to decode captured screenshot')) + image.src = src + }) +} diff --git a/src/renderer/src/components/browser-pane/markup/markup-canvas-render.ts b/src/renderer/src/components/browser-pane/markup/markup-canvas-render.ts new file mode 100644 index 000000000000..77d5d4b1255b --- /dev/null +++ b/src/renderer/src/components/browser-pane/markup/markup-canvas-render.ts @@ -0,0 +1,88 @@ +// Renders the live markup scene into the overlay canvas at device resolution. +// Committed shapes are rasterized once into an offscreen layer; the per-frame +// paint blits that layer and draws only the in-progress shape on top, so a fast +// pointermove stream never re-strokes the whole scene. Kept separate from the +// editor hook so the draw composition stays focused. +// +// Why the caller passes dpr: the committed layer and the visible canvas are sized +// in two separate paints. Reading window.devicePixelRatio independently in each +// could size them differently if the ratio changed between them (monitor move), +// misaligning the 1:1 blit. Taking one measured dpr keeps them in lockstep. + +import { clampMarkupScale } from './markup-screenshot-compose' +import { drawShapes } from './markup-shape-render' +import type { MarkupShape } from './markup-drawing-model' + +function sceneDevicePixels( + cssWidth: number, + cssHeight: number, + dpr: number +): { scale: number; width: number; height: number } { + // Why: clamp identically to the compositor so the live preview and the exported + // PNG render shapes at the same scale. + const scale = clampMarkupScale(dpr) + return { + scale, + width: Math.max(1, Math.round(cssWidth * scale)), + height: Math.max(1, Math.round(cssHeight * scale)) + } +} + +// Rasterizes the committed shapes into an offscreen layer. Called only when the +// committed shape list, the size, or the dpr changes — never on the pointermove +// path. +export function renderCommittedLayer( + layer: HTMLCanvasElement, + shapes: readonly MarkupShape[], + cssWidth: number, + cssHeight: number, + dpr: number +): void { + const { scale, width, height } = sceneDevicePixels(cssWidth, cssHeight, dpr) + if (layer.width !== width || layer.height !== height) { + layer.width = width + layer.height = height + } + const ctx = layer.getContext('2d') + if (!ctx) { + return + } + ctx.setTransform(scale, 0, 0, scale, 0, 0) + ctx.clearRect(0, 0, cssWidth, cssHeight) + drawShapes(ctx, shapes) +} + +// Blits the cached committed layer plus the in-progress shape onto the visible +// canvas. Cheap enough for every frame: one image blit + at most one shape. +export function blitMarkupScene( + canvas: HTMLCanvasElement, + committedLayer: HTMLCanvasElement, + inProgress: MarkupShape | null, + cssWidth: number, + cssHeight: number, + dpr: number +): void { + const ctx = canvas.getContext('2d') + if (!ctx) { + return + } + const { scale, width, height } = sceneDevicePixels(cssWidth, cssHeight, dpr) + if (canvas.width !== width || canvas.height !== height) { + canvas.width = width + canvas.height = height + } + // Why: blit the committed layer under the identity transform, stretched to the + // canvas's device box. It's 1:1 when the layer matches (the common case, since + // both are sized from the same dpr) and degrades gracefully — never clips or + // offsets — if a layer render briefly lags a dpr change. + ctx.setTransform(1, 0, 0, 1, 0, 0) + ctx.clearRect(0, 0, width, height) + if (committedLayer.width > 0 && committedLayer.height > 0) { + ctx.drawImage(committedLayer, 0, 0, width, height) + } + // Then switch to the dpr transform to draw the in-progress shape in CSS coords. + ctx.setTransform(scale, 0, 0, scale, 0, 0) + if (inProgress) { + drawShapes(ctx, [inProgress]) + } +} diff --git a/src/renderer/src/components/browser-pane/markup/markup-clipboard-delivery.ts b/src/renderer/src/components/browser-pane/markup/markup-clipboard-delivery.ts new file mode 100644 index 000000000000..f242bbd960b9 --- /dev/null +++ b/src/renderer/src/components/browser-pane/markup/markup-clipboard-delivery.ts @@ -0,0 +1,21 @@ +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import type { MarkupComposeResult } from './markup-screenshot-compose' + +// Delivery for v1: copy the composited markup PNG to the clipboard, mirroring the +// browser grab "copy" flow. The user pastes it into their agent terminal, where +// Orca's existing clipboard-screenshot paste writes the image to a temp file (on +// the correct host for local or remote/SSH agents) and hands the path to the TUI. +// This reuses proven, environment-agnostic machinery instead of re-plumbing a +// direct send. +export async function deliverMarkupToClipboard(result: MarkupComposeResult): Promise { + await window.api.ui.writeClipboardImage(result.dataUrl) + const isMac = navigator.userAgent.includes('Mac') + toast.success( + translate( + 'auto.components.browser-pane.markup.copiedToast', + 'Markup copied — paste it into your agent ({{value0}})', + { value0: isMac ? '⌘V' : 'Ctrl+V' } + ) + ) +} diff --git a/src/renderer/src/components/browser-pane/markup/markup-drawing-model.test.ts b/src/renderer/src/components/browser-pane/markup/markup-drawing-model.test.ts new file mode 100644 index 000000000000..7fec062ec01a --- /dev/null +++ b/src/renderer/src/components/browser-pane/markup/markup-drawing-model.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from 'vitest' +import { + arrowHeadGeometry, + canRedo, + canUndo, + clearShapes, + commitShape, + createMarkupDocument, + isEmptyDocument, + normalizeRect, + redoShape, + scaleShape, + setShapes, + undoShape, + type ArrowShape, + type MarkupShape, + type PenShape, + type TextShape +} from './markup-drawing-model' + +function pen(id: string): PenShape { + return { id, kind: 'pen', color: '#ef4444', width: 4, points: [{ x: 0, y: 0 }] } +} + +describe('markup document undo/redo', () => { + it('starts empty', () => { + const doc = createMarkupDocument() + expect(doc.shapes).toHaveLength(0) + expect(isEmptyDocument(doc)).toBe(true) + expect(canUndo(doc)).toBe(false) + expect(canRedo(doc)).toBe(false) + }) + + it('commits shapes and clears the redo stack', () => { + let doc = createMarkupDocument() + doc = commitShape(doc, pen('a')) + doc = commitShape(doc, pen('b')) + expect(doc.shapes.map((s) => s.id)).toEqual(['a', 'b']) + expect(canUndo(doc)).toBe(true) + expect(canRedo(doc)).toBe(false) + }) + + it('undoes and redoes in order', () => { + let doc = createMarkupDocument() + doc = commitShape(doc, pen('a')) + doc = commitShape(doc, pen('b')) + doc = undoShape(doc) + expect(doc.shapes.map((s) => s.id)).toEqual(['a']) + expect(canRedo(doc)).toBe(true) + doc = redoShape(doc) + expect(doc.shapes.map((s) => s.id)).toEqual(['a', 'b']) + expect(canRedo(doc)).toBe(false) + }) + + it('forks history: committing after undo discards the redo stack', () => { + let doc = createMarkupDocument() + doc = commitShape(doc, pen('a')) + doc = commitShape(doc, pen('b')) + doc = undoShape(doc) + doc = commitShape(doc, pen('c')) + expect(doc.shapes.map((s) => s.id)).toEqual(['a', 'c']) + expect(canRedo(doc)).toBe(false) + }) + + it('undo/redo on empty document are no-ops returning the same reference', () => { + const doc = createMarkupDocument() + expect(undoShape(doc)).toBe(doc) + expect(redoShape(doc)).toBe(doc) + }) + + it('clears shapes, and is a no-op (same reference) when already empty', () => { + const empty = createMarkupDocument() + expect(clearShapes(empty)).toBe(empty) + let doc = commitShape(empty, pen('a')) + doc = clearShapes(doc) + expect(doc.shapes).toHaveLength(0) + expect(canRedo(doc)).toBe(false) + }) + + it('undoes an edit (not just an add) via whole-list snapshots', () => { + let doc = commitShape(createMarkupDocument(), pen('a')) + doc = setShapes(doc, []) // delete everything + expect(doc.shapes).toHaveLength(0) + doc = undoShape(doc) + expect(doc.shapes.map((s) => s.id)).toEqual(['a']) + }) +}) + +describe('markup geometry', () => { + it('normalizes rectangles from any drag direction', () => { + expect(normalizeRect({ x: 10, y: 20 }, { x: 4, y: 50 })).toEqual({ + x: 4, + y: 20, + width: 6, + height: 30 + }) + }) + + it('scales pen points and width', () => { + const shape: PenShape = { + id: 'a', + kind: 'pen', + color: '#fff', + width: 4, + points: [ + { x: 2, y: 3 }, + { x: 4, y: 5 } + ] + } + const scaled = scaleShape(shape, 2) as PenShape + expect(scaled.width).toBe(8) + expect(scaled.points).toEqual([ + { x: 4, y: 6 }, + { x: 8, y: 10 } + ]) + }) + + it('scales rect/arrow endpoints and text position + font size', () => { + const arrow: ArrowShape = { + id: 'a', + kind: 'arrow', + color: '#fff', + width: 3, + from: { x: 1, y: 1 }, + to: { x: 3, y: 5 } + } + const scaledArrow = scaleShape(arrow, 3) as ArrowShape + expect(scaledArrow.from).toEqual({ x: 3, y: 3 }) + expect(scaledArrow.to).toEqual({ x: 9, y: 15 }) + expect(scaledArrow.width).toBe(9) + + const text: TextShape = { + id: 't', + kind: 'text', + color: '#fff', + at: { x: 10, y: 20 }, + text: 'fix', + fontSize: 18 + } + const scaledText = scaleShape(text, 2) as TextShape + expect(scaledText.at).toEqual({ x: 20, y: 40 }) + expect(scaledText.fontSize).toBe(36) + }) + + it('returns null arrowhead for a zero-length segment', () => { + expect(arrowHeadGeometry({ x: 5, y: 5 }, { x: 5, y: 5 }, 4)).toBeNull() + }) + + it('produces a symmetric arrowhead pointing at the tip', () => { + const head = arrowHeadGeometry({ x: 0, y: 0 }, { x: 10, y: 0 }, 4) + expect(head).not.toBeNull() + if (!head) { + return + } + expect(head.tip).toEqual({ x: 10, y: 0 }) + // Wings sit behind the tip (smaller x) and mirror across the horizontal axis. + expect(head.left.x).toBeLessThan(10) + expect(head.right.x).toBeLessThan(10) + expect(head.left.y).toBeCloseTo(-head.right.y, 6) + }) +}) + +// Why: exhaustiveness guard — if a new MarkupShape kind is added without a +// scaleShape branch, this fails to type-check at the cast site below. +const _exhaustive: MarkupShape['kind'][] = ['pen', 'highlight', 'arrow', 'rect', 'ellipse', 'text'] +void _exhaustive diff --git a/src/renderer/src/components/browser-pane/markup/markup-drawing-model.ts b/src/renderer/src/components/browser-pane/markup/markup-drawing-model.ts new file mode 100644 index 000000000000..790065bf60ae --- /dev/null +++ b/src/renderer/src/components/browser-pane/markup/markup-drawing-model.ts @@ -0,0 +1,213 @@ +// Pure drawing model for browser screenshot markup. No DOM access — shapes are +// vector objects in CSS-viewport coordinates so the same data drives both the +// live canvas overlay and the final composited PNG. Keeping this canvas-free +// makes the undo/redo and geometry logic unit-testable. + +export type MarkupToolKind = 'pen' | 'highlight' | 'arrow' | 'rect' | 'ellipse' | 'text' + +// Toolbar selection. Draw-only: markup is a throwaway scribble the user copies +// once, so there is no select/move/restyle cursor. +export type MarkupTool = MarkupToolKind + +export type MarkupPoint = { x: number; y: number } + +type MarkupShapeBase = { id: string; color: string } + +export type PenShape = MarkupShapeBase & { kind: 'pen'; points: MarkupPoint[]; width: number } +export type HighlightShape = MarkupShapeBase & { + kind: 'highlight' + points: MarkupPoint[] + width: number +} +export type ArrowShape = MarkupShapeBase & { + kind: 'arrow' + from: MarkupPoint + to: MarkupPoint + width: number +} +export type RectShape = MarkupShapeBase & { + kind: 'rect' + from: MarkupPoint + to: MarkupPoint + width: number +} +export type EllipseShape = MarkupShapeBase & { + kind: 'ellipse' + from: MarkupPoint + to: MarkupPoint + width: number +} +export type TextShape = MarkupShapeBase & { + kind: 'text' + at: MarkupPoint + text: string + fontSize: number +} + +export type MarkupShape = + | PenShape + | HighlightShape + | ArrowShape + | RectShape + | EllipseShape + | TextShape + +// Concrete ink colors (baked into the exported PNG, so these are content colors, +// not UI-chrome tokens). Ordered for a compact swatch row. +export const MARKUP_COLORS = [ + '#ef4444', + '#f97316', + '#eab308', + '#22c55e', + '#3b82f6', + '#111827', + '#ffffff' +] as const +export const DEFAULT_MARKUP_COLOR: string = MARKUP_COLORS[0] + +export const MARKUP_WIDTHS = [2, 4, 8] as const +export const DEFAULT_MARKUP_WIDTH = 4 +export const MARKUP_FONT_SIZES = [14, 18, 24, 32, 48] as const +export const DEFAULT_MARKUP_FONT_SIZE = 18 + +// Highlight strokes are intentionally fat and translucent. +export const HIGHLIGHT_WIDTH_MULTIPLIER = 4 +export const HIGHLIGHT_ALPHA = 0.35 + +// ─── Document with undo/redo ──────────────────────────────────────────────── + +export type MarkupDocument = { + shapes: MarkupShape[] + /** Prior whole-list states (oldest first) for undo. */ + past: MarkupShape[][] + /** Undone whole-list states (newest first) for redo. */ + future: MarkupShape[][] +} + +export function createMarkupDocument(): MarkupDocument { + return { shapes: [], past: [], future: [] } +} + +// Replace the whole shape list as one undoable step. Snapshot-based so every +// edit — add, move, restyle, delete — is reversible, not just adding a shape. +export function setShapes(doc: MarkupDocument, shapes: MarkupShape[]): MarkupDocument { + return { shapes, past: [...doc.past, doc.shapes], future: [] } +} + +export function commitShape(doc: MarkupDocument, shape: MarkupShape): MarkupDocument { + return setShapes(doc, [...doc.shapes, shape]) +} + +export function undoShape(doc: MarkupDocument): MarkupDocument { + const prev = doc.past.at(-1) + if (!prev) { + return doc + } + return { shapes: prev, past: doc.past.slice(0, -1), future: [doc.shapes, ...doc.future] } +} + +export function redoShape(doc: MarkupDocument): MarkupDocument { + const next = doc.future.at(0) + if (!next) { + return doc + } + return { shapes: next, past: [...doc.past, doc.shapes], future: doc.future.slice(1) } +} + +export function clearShapes(doc: MarkupDocument): MarkupDocument { + return doc.shapes.length === 0 ? doc : setShapes(doc, []) +} + +export function canUndo(doc: MarkupDocument): boolean { + return doc.past.length > 0 +} + +export function canRedo(doc: MarkupDocument): boolean { + return doc.future.length > 0 +} + +export function isEmptyDocument(doc: MarkupDocument): boolean { + return doc.shapes.length === 0 +} + +// ─── Geometry helpers (shared by live overlay + compositor) ───────────────── + +export type NormalizedRect = { x: number; y: number; width: number; height: number } + +export function normalizeRect(from: MarkupPoint, to: MarkupPoint): NormalizedRect { + return { + x: Math.min(from.x, to.x), + y: Math.min(from.y, to.y), + width: Math.abs(to.x - from.x), + height: Math.abs(to.y - from.y) + } +} + +export function scalePoint(point: MarkupPoint, scale: number): MarkupPoint { + return { x: point.x * scale, y: point.y * scale } +} + +// Why: the compositor draws into a canvas sized to the base-image's physical +// pixels, while shapes are authored in CSS-viewport pixels. Scaling every shape +// by (imagePx / cssPx) keeps strokes aligned with the underlying screenshot. +export function scaleShape(shape: MarkupShape, scale: number): MarkupShape { + switch (shape.kind) { + case 'pen': + case 'highlight': + return { + ...shape, + width: shape.width * scale, + points: shape.points.map((point) => scalePoint(point, scale)) + } + case 'arrow': + case 'rect': + case 'ellipse': + return { + ...shape, + width: shape.width * scale, + from: scalePoint(shape.from, scale), + to: scalePoint(shape.to, scale) + } + case 'text': + return { + ...shape, + fontSize: shape.fontSize * scale, + at: scalePoint(shape.at, scale) + } + } +} + +export type ArrowHeadGeometry = { tip: MarkupPoint; left: MarkupPoint; right: MarkupPoint } + +const ARROW_HEAD_ANGLE = 0.45 + +export function arrowHeadLength(width: number): number { + return Math.max(10, width * 3.5) +} + +// Returns the two wing points of an arrowhead at `to`, or null when the segment +// has no direction (from === to) so callers can skip drawing a degenerate head. +export function arrowHeadGeometry( + from: MarkupPoint, + to: MarkupPoint, + width: number +): ArrowHeadGeometry | null { + const dx = to.x - from.x + const dy = to.y - from.y + if (dx === 0 && dy === 0) { + return null + } + const angle = Math.atan2(dy, dx) + const size = arrowHeadLength(width) + const leftAngle = angle + Math.PI - ARROW_HEAD_ANGLE + const rightAngle = angle + Math.PI + ARROW_HEAD_ANGLE + return { + tip: to, + left: { x: to.x + size * Math.cos(leftAngle), y: to.y + size * Math.sin(leftAngle) }, + right: { x: to.x + size * Math.cos(rightAngle), y: to.y + size * Math.sin(rightAngle) } + } +} + +export function highlightWidth(width: number): number { + return width * HIGHLIGHT_WIDTH_MULTIPLIER +} diff --git a/src/renderer/src/components/browser-pane/markup/markup-screenshot-compose.test.ts b/src/renderer/src/components/browser-pane/markup/markup-screenshot-compose.test.ts new file mode 100644 index 000000000000..40f540327b16 --- /dev/null +++ b/src/renderer/src/components/browser-pane/markup/markup-screenshot-compose.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' +import { + clampMarkupScale, + dataUrlByteLength, + effectiveMarkupScale, + markupCanvasSize, + MARKUP_DOWNSCALE_STEPS +} from './markup-screenshot-compose' + +describe('clampMarkupScale', () => { + it('keeps sane scales and clamps to [1, 4]', () => { + expect(clampMarkupScale(2)).toBe(2) + expect(clampMarkupScale(0.5)).toBe(1) + expect(clampMarkupScale(10)).toBe(4) + }) + + it('guards against non-finite / non-positive values', () => { + expect(clampMarkupScale(0)).toBe(1) + expect(clampMarkupScale(-3)).toBe(1) + expect(clampMarkupScale(Number.NaN)).toBe(1) + }) +}) + +describe('markupCanvasSize', () => { + it('sizes the output to the content box times the (clamped) scale', () => { + expect(markupCanvasSize(800, 600, 2)).toEqual({ width: 1600, height: 1200 }) + expect(markupCanvasSize(800, 600, 0.5)).toEqual({ width: 800, height: 600 }) + }) + + it('keeps the composite within the pixel budget (never over)', () => { + // A large HiDPI pane: 4000×3000 CSS × dpr 2 = 48M px > the ~33.55M (32×1024×1024) ceiling. + const maxPixels = 32 * 1024 * 1024 + const size = markupCanvasSize(4000, 3000, 2, maxPixels) + expect(size.width * size.height).toBeLessThanOrEqual(maxPixels) + // Aspect ratio is preserved (one uniform scale). + expect(size.width / size.height).toBeCloseTo(4000 / 3000, 3) + }) +}) + +describe('effectiveMarkupScale', () => { + it('returns the clamped scale when the composite fits the pixel budget', () => { + expect(effectiveMarkupScale(800, 600, 2, 32 * 1024 * 1024)).toBe(2) + }) + + it('reduces the scale so area stays within the pixel budget', () => { + const scale = effectiveMarkupScale(4000, 3000, 2, 32 * 1024 * 1024) + expect(scale).toBeLessThan(2) + expect(4000 * scale * (3000 * scale)).toBeLessThanOrEqual(32 * 1024 * 1024 + 1) + }) + + it('defaults to the clamped scale with no pixel budget', () => { + expect(effectiveMarkupScale(3840, 2160, 2)).toBe(2) + }) +}) + +describe('dataUrlByteLength', () => { + it('decodes the base64 payload size', () => { + // "hi" -> base64 "aGk=" (1 pad char) -> 2 bytes + expect(dataUrlByteLength('data:image/png;base64,aGk=')).toBe(2) + // "man" -> "bWFu" (no padding) -> 3 bytes + expect(dataUrlByteLength('data:image/png;base64,bWFu')).toBe(3) + }) + + it('returns 0 for a malformed data url', () => { + expect(dataUrlByteLength('not-a-data-url')).toBe(0) + }) +}) + +describe('compose budget plans', () => { + it('tries full size first, then shrinks (PNG only, descending)', () => { + expect(MARKUP_DOWNSCALE_STEPS[0]).toBe(1) + expect([...MARKUP_DOWNSCALE_STEPS]).toEqual([...MARKUP_DOWNSCALE_STEPS].sort((a, b) => b - a)) + expect(MARKUP_DOWNSCALE_STEPS.every((s) => s > 0 && s <= 1)).toBe(true) + }) +}) diff --git a/src/renderer/src/components/browser-pane/markup/markup-screenshot-compose.ts b/src/renderer/src/components/browser-pane/markup/markup-screenshot-compose.ts new file mode 100644 index 000000000000..ad2af0969b76 --- /dev/null +++ b/src/renderer/src/components/browser-pane/markup/markup-screenshot-compose.ts @@ -0,0 +1,201 @@ +// Composites the frozen base screenshot + markup shapes into a single PNG under +// the delivery byte and pixel budgets (PNG only — see MarkupComposeResult). +// +// WYSIWYG: the output canvas matches the on-screen content box (its CSS width × +// height) times an output scale for crispness. The base image is stretched to +// fill that box exactly as the user saw it (so a distorted objectFit:fill remote +// frame composites correctly), and shapes — authored in CSS coordinates — are +// scaled by the same single factor. One uniform scale keeps strokes aligned. + +import { scaleShape, type MarkupShape } from './markup-drawing-model' +import { drawShapes } from './markup-shape-render' + +export type MarkupComposeResult = { + dataUrl: string + // Why: PNG only — the clipboard:writeImage handler accepts a PNG data URL and + // silently drops anything else, so a JPEG fallback would "succeed" with an + // empty clipboard. + mimeType: 'image/png' + width: number + height: number + byteLength: number +} + +export type MarkupComposeInput = { + image: CanvasImageSource + /** On-screen size of the content box the canvas overlaid (CSS pixels). */ + displayCssWidth: number + displayCssHeight: number + /** Output resolution multiplier (typically devicePixelRatio). */ + outputScale: number + shapes: readonly MarkupShape[] + /** Hard byte ceiling for the encoded data URL payload. */ + maxBytes: number + /** Hard pixel (width×height) ceiling — the clipboard handler silently drops + * images over its own dimension limit, so the composite must fit it too. */ + maxPixels: number +} + +// ─── Pure helpers (unit-tested) ───────────────────────────────────────────── + +// Keep the output scale sane so a huge DPR or bogus value can't allocate an +// absurd canvas. +export function clampMarkupScale(scale: number): number { + if (!Number.isFinite(scale) || scale <= 0) { + return 1 + } + return Math.min(Math.max(scale, 1), 4) +} + +// The single uniform scale actually applied to the base image and every shape: +// the clamped output scale, reduced further if needed so the composite's pixel +// area stays within `maxPixels`. Reducing one uniform factor keeps strokes aligned. +export function effectiveMarkupScale( + displayCssWidth: number, + displayCssHeight: number, + outputScale: number, + maxPixels: number = Number.POSITIVE_INFINITY +): number { + const scale = clampMarkupScale(outputScale) + const area = displayCssWidth * displayCssHeight + if (area <= 0 || !Number.isFinite(maxPixels) || maxPixels <= 0) { + return scale + } + return Math.min(scale, Math.sqrt(maxPixels / area)) +} + +export function markupCanvasSize( + displayCssWidth: number, + displayCssHeight: number, + outputScale: number, + maxPixels: number = Number.POSITIVE_INFINITY +): { width: number; height: number } { + const scale = effectiveMarkupScale(displayCssWidth, displayCssHeight, outputScale, maxPixels) + // Why: floor (not round) so a rounded-up dimension can't nudge the area back + // over maxPixels at the budget boundary. + return { + width: Math.max(1, Math.floor(displayCssWidth * scale)), + height: Math.max(1, Math.floor(displayCssHeight * scale)) + } +} + +// Bytes represented by a `data:...;base64,` URL's payload. +export function dataUrlByteLength(dataUrl: string): number { + const commaIndex = dataUrl.indexOf(',') + if (commaIndex === -1) { + return 0 + } + const payload = dataUrl.slice(commaIndex + 1) + const padding = payload.endsWith('==') ? 2 : payload.endsWith('=') ? 1 : 0 + return Math.max(0, Math.floor((payload.length * 3) / 4) - padding) +} + +// Progressive raster sizes (full, then shrink) tried to fit the byte budget. +// PNG at every step — never JPEG (see MarkupComposeResult). +export const MARKUP_DOWNSCALE_STEPS = [1, 0.85, 0.7, 0.55, 0.4, 0.3] as const + +// ─── Canvas raster (thin) ─────────────────────────────────────────────────── + +function createCanvas(width: number, height: number): HTMLCanvasElement { + const canvas = document.createElement('canvas') + canvas.width = Math.max(1, Math.round(width)) + canvas.height = Math.max(1, Math.round(height)) + return canvas +} + +function renderComposite(input: MarkupComposeInput): HTMLCanvasElement { + const scale = effectiveMarkupScale( + input.displayCssWidth, + input.displayCssHeight, + input.outputScale, + input.maxPixels + ) + const size = markupCanvasSize( + input.displayCssWidth, + input.displayCssHeight, + input.outputScale, + input.maxPixels + ) + const canvas = createCanvas(size.width, size.height) + const ctx = canvas.getContext('2d') + if (!ctx) { + throw new Error('markup compose: 2d context unavailable') + } + ctx.drawImage(input.image, 0, 0, size.width, size.height) + for (const shape of input.shapes) { + drawShapes(ctx, [scaleShape(shape, scale)]) + } + return canvas +} + +function downscaleCanvas(source: HTMLCanvasElement, factor: number): HTMLCanvasElement { + if (factor >= 1) { + return source + } + const next = createCanvas(source.width * factor, source.height * factor) + const ctx = next.getContext('2d') + if (!ctx) { + return source + } + ctx.drawImage(source, 0, 0, next.width, next.height) + return next +} + +// Encodes a canvas to a PNG data URL. Prefers toBlob because Chromium encodes it +// off the main thread — a multi-megapixel toData('image/png') is synchronous and +// freezes the renderer. Falls back to the sync encode if toBlob yields no blob. +function canvasToPngDataUrl(canvas: HTMLCanvasElement): Promise { + return new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (!blob) { + try { + resolve(canvas.toDataURL('image/png')) + } catch (error) { + reject(error instanceof Error ? error : new Error('markup compose: toDataURL failed')) + } + return + } + const reader = new FileReader() + reader.onload = () => resolve(reader.result as string) + reader.onerror = () => reject(reader.error ?? new Error('markup compose: blob read failed')) + reader.readAsDataURL(blob) + }, 'image/png') + }) +} + +// Encodes a PNG within budget: full size, then progressively smaller. Always +// returns a PNG (best effort at the smallest step) so the clipboard handler +// accepts it and delivery never silently drops. Async so the per-step PNG encode +// runs off the main thread instead of blocking the renderer. +export async function composeMarkupDataUrl( + input: MarkupComposeInput +): Promise { + const composite = renderComposite(input) + + let smallest: MarkupComposeResult | null = null + for (const step of MARKUP_DOWNSCALE_STEPS) { + const canvas = downscaleCanvas(composite, step) + const dataUrl = await canvasToPngDataUrl(canvas) + const byteLength = dataUrlByteLength(dataUrl) + const result: MarkupComposeResult = { + dataUrl, + mimeType: 'image/png', + width: canvas.width, + height: canvas.height, + byteLength + } + if (byteLength <= input.maxBytes) { + return result + } + smallest = result + } + + // Why: still over the byte budget at the smallest step — return it anyway. It's + // a PNG within the pixel budget (every step is), so the clipboard handler + // accepts it; a slightly large attachment beats dropping the user's markup. + if (!smallest) { + // Unreachable: MARKUP_DOWNSCALE_STEPS is non-empty, so the loop always sets it. + throw new Error('markup compose: no downscale step produced output') + } + return smallest +} diff --git a/src/renderer/src/components/browser-pane/markup/markup-shape-render.test.ts b/src/renderer/src/components/browser-pane/markup/markup-shape-render.test.ts new file mode 100644 index 000000000000..444f8d012655 --- /dev/null +++ b/src/renderer/src/components/browser-pane/markup/markup-shape-render.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from 'vitest' +import { drawShape } from './markup-shape-render' +import { HIGHLIGHT_ALPHA, type MarkupShape } from './markup-drawing-model' + +// Minimal recording stand-in for CanvasRenderingContext2D — drawShape only uses +// this subset, so we can assert the dispatch without a real canvas. +function makeRecordingCtx() { + const calls: { method: string; args: unknown[] }[] = [] + const record = + (method: string) => + (...args: unknown[]) => { + calls.push({ method, args }) + } + const ctx = { + save: record('save'), + restore: record('restore'), + beginPath: record('beginPath'), + moveTo: record('moveTo'), + lineTo: record('lineTo'), + arc: record('arc'), + ellipse: record('ellipse'), + stroke: record('stroke'), + fill: record('fill'), + strokeRect: record('strokeRect'), + fillText: record('fillText'), + strokeText: record('strokeText'), + lineCap: '', + lineJoin: '', + strokeStyle: '', + fillStyle: '', + lineWidth: 0, + globalAlpha: 1, + font: '', + textBaseline: '' + } + const methods = (name: string) => calls.filter((c) => c.method === name) + return { ctx: ctx as unknown as CanvasRenderingContext2D, calls, methods } +} + +describe('drawShape dispatch', () => { + it('strokes a multi-point pen as a polyline', () => { + const { ctx, methods } = makeRecordingCtx() + const shape: MarkupShape = { + id: 'p', + kind: 'pen', + color: '#ef4444', + width: 4, + points: [ + { x: 0, y: 0 }, + { x: 5, y: 5 }, + { x: 9, y: 1 } + ] + } + drawShape(ctx, shape) + expect(methods('moveTo')).toHaveLength(1) + expect(methods('lineTo')).toHaveLength(2) + expect(methods('stroke')).toHaveLength(1) + }) + + it('draws a dot for a single-point pen', () => { + const { ctx, methods } = makeRecordingCtx() + drawShape(ctx, { id: 'p', kind: 'pen', color: '#fff', width: 4, points: [{ x: 3, y: 3 }] }) + expect(methods('arc')).toHaveLength(1) + expect(methods('fill')).toHaveLength(1) + }) + + it('applies translucent alpha for highlights', () => { + const { ctx } = makeRecordingCtx() + drawShape(ctx, { + id: 'h', + kind: 'highlight', + color: '#eab308', + width: 4, + points: [ + { x: 0, y: 0 }, + { x: 8, y: 0 } + ] + }) + expect(ctx.globalAlpha).toBe(HIGHLIGHT_ALPHA) + }) + + it('draws an arrow as a shaft plus a head', () => { + const { ctx, methods } = makeRecordingCtx() + drawShape(ctx, { + id: 'a', + kind: 'arrow', + color: '#fff', + width: 4, + from: { x: 0, y: 0 }, + to: { x: 20, y: 0 } + }) + // shaft (moveTo+lineTo) + head (moveTo+lineTo+lineTo) + expect(methods('moveTo').length).toBeGreaterThanOrEqual(2) + expect(methods('stroke').length).toBeGreaterThanOrEqual(2) + }) + + it('uses strokeRect for rectangles', () => { + const { ctx, methods } = makeRecordingCtx() + drawShape(ctx, { + id: 'r', + kind: 'rect', + color: '#fff', + width: 2, + from: { x: 10, y: 10 }, + to: { x: 4, y: 30 } + }) + expect(methods('strokeRect')).toHaveLength(1) + expect(methods('strokeRect')[0].args).toEqual([4, 10, 6, 20]) + }) + + it('uses ellipse for ovals', () => { + const { ctx, methods } = makeRecordingCtx() + drawShape(ctx, { + id: 'e', + kind: 'ellipse', + color: '#fff', + width: 2, + from: { x: 0, y: 0 }, + to: { x: 20, y: 10 } + }) + expect(methods('ellipse')).toHaveLength(1) + expect(methods('ellipse')[0].args.slice(0, 4)).toEqual([10, 5, 10, 5]) + }) + + it('renders single-line text with a halo (stroke behind fill)', () => { + const { ctx, methods } = makeRecordingCtx() + drawShape(ctx, { + id: 't', + kind: 'text', + color: '#fff', + at: { x: 5, y: 5 }, + text: 'fix this', + fontSize: 18 + }) + expect(methods('strokeText')).toHaveLength(1) + expect(methods('fillText')).toHaveLength(1) + expect(methods('fillText')[0].args[0]).toBe('fix this') + }) + + it('save/restore brackets every shape so state never leaks', () => { + const { ctx, methods } = makeRecordingCtx() + drawShape(ctx, { + id: 'r', + kind: 'rect', + color: '#fff', + width: 2, + from: { x: 0, y: 0 }, + to: { x: 1, y: 1 } + }) + expect(methods('save')).toHaveLength(1) + expect(methods('restore')).toHaveLength(1) + }) +}) diff --git a/src/renderer/src/components/browser-pane/markup/markup-shape-render.ts b/src/renderer/src/components/browser-pane/markup/markup-shape-render.ts new file mode 100644 index 000000000000..1df81a939f92 --- /dev/null +++ b/src/renderer/src/components/browser-pane/markup/markup-shape-render.ts @@ -0,0 +1,144 @@ +// Canvas rendering for markup shapes. Shared by the live overlay (draws into the +// visible canvas) and the compositor (draws into the offscreen export canvas), so +// on-screen preview and the delivered PNG are pixel-identical. + +import { + arrowHeadGeometry, + highlightWidth, + normalizeRect, + HIGHLIGHT_ALPHA, + type ArrowShape, + type EllipseShape, + type MarkupShape, + type PenShape, + type HighlightShape, + type RectShape, + type TextShape +} from './markup-drawing-model' + +export const TEXT_FONT_FAMILY = + 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif' + +export function drawShape(ctx: CanvasRenderingContext2D, shape: MarkupShape): void { + ctx.save() + ctx.lineCap = 'round' + ctx.lineJoin = 'round' + ctx.strokeStyle = shape.color + ctx.fillStyle = shape.color + switch (shape.kind) { + case 'pen': + drawStroke(ctx, shape) + break + case 'highlight': + drawHighlight(ctx, shape) + break + case 'arrow': + drawArrow(ctx, shape) + break + case 'rect': + drawRect(ctx, shape) + break + case 'ellipse': + drawEllipse(ctx, shape) + break + case 'text': + drawText(ctx, shape) + break + } + ctx.restore() +} + +export function drawShapes(ctx: CanvasRenderingContext2D, shapes: readonly MarkupShape[]): void { + for (const shape of shapes) { + drawShape(ctx, shape) + } +} + +function drawStroke(ctx: CanvasRenderingContext2D, shape: PenShape): void { + ctx.lineWidth = shape.width + strokePolyline(ctx, shape.points, shape.width) +} + +function drawHighlight(ctx: CanvasRenderingContext2D, shape: HighlightShape): void { + // Why: translucent + fat so it reads as a marker pass over the screenshot + // without hiding the pixels beneath. + ctx.globalAlpha = HIGHLIGHT_ALPHA + const width = highlightWidth(shape.width) + ctx.lineWidth = width + strokePolyline(ctx, shape.points, width) +} + +function strokePolyline( + ctx: CanvasRenderingContext2D, + points: readonly { x: number; y: number }[], + width: number +): void { + if (points.length === 0) { + return + } + if (points.length === 1) { + // Why: a tap (single point) still leaves a visible dot. + const point = points[0] + ctx.beginPath() + ctx.arc(point.x, point.y, Math.max(width / 2, 1), 0, Math.PI * 2) + ctx.fill() + return + } + ctx.beginPath() + ctx.moveTo(points[0].x, points[0].y) + for (let i = 1; i < points.length; i += 1) { + ctx.lineTo(points[i].x, points[i].y) + } + ctx.stroke() +} + +function drawArrow(ctx: CanvasRenderingContext2D, shape: ArrowShape): void { + ctx.lineWidth = shape.width + ctx.beginPath() + ctx.moveTo(shape.from.x, shape.from.y) + ctx.lineTo(shape.to.x, shape.to.y) + ctx.stroke() + const head = arrowHeadGeometry(shape.from, shape.to, shape.width) + if (!head) { + return + } + ctx.beginPath() + ctx.moveTo(head.left.x, head.left.y) + ctx.lineTo(head.tip.x, head.tip.y) + ctx.lineTo(head.right.x, head.right.y) + ctx.stroke() +} + +function drawRect(ctx: CanvasRenderingContext2D, shape: RectShape): void { + ctx.lineWidth = shape.width + const rect = normalizeRect(shape.from, shape.to) + ctx.strokeRect(rect.x, rect.y, rect.width, rect.height) +} + +function drawEllipse(ctx: CanvasRenderingContext2D, shape: EllipseShape): void { + ctx.lineWidth = shape.width + const rect = normalizeRect(shape.from, shape.to) + const cx = rect.x + rect.width / 2 + const cy = rect.y + rect.height / 2 + ctx.beginPath() + ctx.ellipse(cx, cy, rect.width / 2, rect.height / 2, 0, 0, Math.PI * 2) + ctx.stroke() +} + +function drawText(ctx: CanvasRenderingContext2D, shape: TextShape): void { + ctx.font = `600 ${shape.fontSize}px ${TEXT_FONT_FAMILY}` + ctx.textBaseline = 'top' + // Why: a thin contrasting halo keeps text legible over busy screenshots + // regardless of the underlying pixels. + ctx.lineWidth = Math.max(shape.fontSize / 6, 2) + ctx.strokeStyle = haloColor(shape.color) + ctx.lineJoin = 'round' + // Text comes from a single-line input, so there are no newlines to lay out. + ctx.strokeText(shape.text, shape.at.x, shape.at.y) + ctx.fillText(shape.text, shape.at.x, shape.at.y) +} + +// White text gets a dark halo, everything else a light halo. +function haloColor(color: string): string { + return color.toLowerCase() === '#ffffff' ? 'rgba(0,0,0,0.65)' : 'rgba(255,255,255,0.85)' +} diff --git a/src/renderer/src/components/browser-pane/markup/use-markup-draw-hint.ts b/src/renderer/src/components/browser-pane/markup/use-markup-draw-hint.ts new file mode 100644 index 000000000000..f6d1ab2257e4 --- /dev/null +++ b/src/renderer/src/components/browser-pane/markup/use-markup-draw-hint.ts @@ -0,0 +1,48 @@ +import { useCallback, useEffect, useState } from 'react' +import { useAppStore } from '@/store' + +// One-time discovery highlight for the screenshot-markup Draw button. Shows once +// per install — the first time the button is usable — so users notice the new +// tool. Gated on its own localStorage flag (not a contextual tour), so it fires +// for everyone, including users who already finished the capped browser tour. +// It stays open until the user acts (clicks Draw) or the button stops being +// usable; there is no auto-timeout. +const MARKUP_DRAW_HINT_SEEN_KEY = 'orca.browser.markup-draw-hint-seen' + +// Records the first-ever view and returns whether this call is that first view. +// Returns false when storage is unavailable so a private-mode session never +// risks nagging on every open. +function claimFirstView(): boolean { + try { + if (window.localStorage.getItem(MARKUP_DRAW_HINT_SEEN_KEY) === 'true') { + return false + } + window.localStorage.setItem(MARKUP_DRAW_HINT_SEEN_KEY, 'true') + return true + } catch { + return false + } +} + +export type MarkupDrawHint = { hintOpen: boolean; dismissHint: () => void } + +export function useMarkupDrawHint(eligible: boolean): MarkupDrawHint { + const persistedUIReady = useAppStore((state) => state.persistedUIReady) + const [hintOpen, setHintOpen] = useState(false) + + useEffect(() => { + // Why: only nudge once the app is ready and the button is usable. If it stops + // being usable while showing (grab started, markup open, blank tab), close it + // so the forced-open tooltip can't get stuck over a now-disabled button. + if (!persistedUIReady || !eligible) { + setHintOpen(false) + return + } + if (claimFirstView()) { + setHintOpen(true) + } + }, [eligible, persistedUIReady]) + + const dismissHint = useCallback(() => setHintOpen(false), []) + return { hintOpen, dismissHint } +} diff --git a/src/renderer/src/components/browser-pane/markup/useMarkupEditor.ts b/src/renderer/src/components/browser-pane/markup/useMarkupEditor.ts new file mode 100644 index 000000000000..242d17993cbe --- /dev/null +++ b/src/renderer/src/components/browser-pane/markup/useMarkupEditor.ts @@ -0,0 +1,179 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { blitMarkupScene, renderCommittedLayer } from './markup-canvas-render' +import { useMarkupKeyboardShortcuts, type PendingText } from './useMarkupKeyboardShortcuts' +import { useMarkupPointerHandlers } from './useMarkupPointerHandlers' +import { + canRedo, + canUndo, + clearShapes, + commitShape, + createMarkupDocument, + DEFAULT_MARKUP_COLOR, + DEFAULT_MARKUP_FONT_SIZE, + DEFAULT_MARKUP_WIDTH, + redoShape, + undoShape, + type MarkupDocument, + type MarkupShape, + type MarkupTool +} from './markup-drawing-model' + +type Size = { width: number; height: number; dpr: number } + +// Owns the markup surface: document, active tool/style, the pending text box, and +// the canvas paint effect. Draw-only — committed shapes are not re-editable. +export function useMarkupEditor(busy: boolean, onCancel: () => void) { + const rootRef = useRef(null) + const canvasRef = useRef(null) + const textInputRef = useRef(null) + // Why: committed shapes are rasterized once into this offscreen layer; the live + // paint blits it instead of re-stroking every committed shape each pointermove. + const committedLayerRef = useRef(null) + if (committedLayerRef.current === null) { + committedLayerRef.current = document.createElement('canvas') + } + + const [size, setSize] = useState({ width: 0, height: 0, dpr: 1 }) + const [doc, setDoc] = useState(() => createMarkupDocument()) + const [inProgress, setInProgress] = useState(null) + const [tool, setTool] = useState('pen') + const [color, setColor] = useState(DEFAULT_MARKUP_COLOR) + const [width, setWidth] = useState(DEFAULT_MARKUP_WIDTH) + const [fontSize, setFontSize] = useState(DEFAULT_MARKUP_FONT_SIZE) + const [pendingText, setPendingText] = useState(null) + + // Track the content-box size so the canvas matches the frozen backdrop exactly. + useEffect(() => { + const root = rootRef.current + if (!root) { + return undefined + } + const measure = () => { + const rect = root.getBoundingClientRect() + const dpr = window.devicePixelRatio || 1 + // Why: bail on an unchanged measurement so an identical ResizeObserver/resize + // tick can't schedule a redundant repaint. + setSize((prev) => + prev.width === rect.width && prev.height === rect.height && prev.dpr === dpr + ? prev + : { width: rect.width, height: rect.height, dpr } + ) + } + measure() + const observer = new ResizeObserver(measure) + observer.observe(root) + // Why: a monitor move changes devicePixelRatio without changing the element's + // CSS box, so ResizeObserver won't fire — window resize (which Chromium emits + // on dpr changes) re-measures the dpr so the canvas repaints at the new scale. + window.addEventListener('resize', measure) + return () => { + observer.disconnect() + window.removeEventListener('resize', measure) + } + }, []) + + // Rasterize committed shapes into the offscreen layer only when they (or the + // size) change — not on every in-progress pointermove. + useEffect(() => { + const layer = committedLayerRef.current + if (!layer) { + return + } + renderCommittedLayer(layer, doc.shapes, size.width, size.height, size.dpr) + }, [doc.shapes, size]) + + // Blit the cached layer + the in-progress shape, coalesced to one paint per + // frame so a burst of pointermove events can't queue redundant full repaints. + useEffect(() => { + const canvas = canvasRef.current + const layer = committedLayerRef.current + if (!canvas || !layer) { + return undefined + } + const handle = requestAnimationFrame(() => { + blitMarkupScene(canvas, layer, inProgress, size.width, size.height, size.dpr) + }) + return () => cancelAnimationFrame(handle) + }, [doc.shapes, inProgress, size]) + + // Why: focus the text input on mount — a placement click can beat autoFocus. + useEffect(() => { + if (!pendingText) { + return undefined + } + const handle = requestAnimationFrame(() => textInputRef.current?.focus()) + return () => cancelAnimationFrame(handle) + }, [pendingText]) + + const undo = useCallback(() => setDoc((current) => undoShape(current)), []) + const redo = useCallback(() => setDoc((current) => redoShape(current)), []) + const clear = useCallback(() => { + // Why: also drop any open text input / in-progress stroke so a clear leaves a + // truly clean slate — otherwise a pending input blur can re-add text. + setPendingText(null) + setInProgress(null) + setDoc((current) => clearShapes(current)) + }, []) + + useMarkupKeyboardShortcuts({ pendingText, setPendingText, undo, redo, onCancel }) + + const pointerHandlers = useMarkupPointerHandlers({ + busy, + tool, + color, + width, + pendingText, + canvasRef, + setInProgress, + setPendingText, + setDoc + }) + + const commitPendingText = useCallback( + (text: string) => { + const at = pendingText + setPendingText(null) + const trimmed = text.trim() + if (!at || trimmed.length === 0) { + return + } + setDoc((document) => + commitShape(document, { + id: crypto.randomUUID(), + kind: 'text', + color, + at, + text: trimmed, + fontSize + }) + ) + }, + [color, fontSize, pendingText] + ) + + const cancelPendingText = useCallback(() => setPendingText(null), []) + + return { + rootRef, + canvasRef, + textInputRef, + tool, + color, + width, + fontSize, + pendingText, + shapes: doc.shapes, + canUndo: canUndo(doc), + canRedo: canRedo(doc), + setTool, + setColor, + setWidth, + setFontSize, + undo, + redo, + clear, + ...pointerHandlers, + commitPendingText, + cancelPendingText + } +} diff --git a/src/renderer/src/components/browser-pane/markup/useMarkupKeyboardShortcuts.ts b/src/renderer/src/components/browser-pane/markup/useMarkupKeyboardShortcuts.ts new file mode 100644 index 000000000000..3fc9ca97f2d2 --- /dev/null +++ b/src/renderer/src/components/browser-pane/markup/useMarkupKeyboardShortcuts.ts @@ -0,0 +1,52 @@ +import { useEffect } from 'react' + +export type PendingText = { x: number; y: number; initial: string } + +function isTypingTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) { + return false + } + const tag = target.tagName + return tag === 'INPUT' || tag === 'TEXTAREA' || target.isContentEditable +} + +export type MarkupKeyboardParams = { + pendingText: PendingText | null + setPendingText: (value: PendingText | null) => void + undo: () => void + redo: () => void + onCancel: () => void +} + +// Window-level shortcuts for the markup overlay: Escape (close the open text box, +// else exit markup) and platform-correct undo/redo. +export function useMarkupKeyboardShortcuts(params: MarkupKeyboardParams): void { + const { pendingText, setPendingText, undo, redo, onCancel } = params + useEffect(() => { + const isMac = navigator.userAgent.includes('Mac') + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + if (pendingText) { + setPendingText(null) + } else { + onCancel() + } + return + } + if (isTypingTarget(event.target)) { + return + } + const mod = isMac ? event.metaKey : event.ctrlKey + if (mod && event.key.toLowerCase() === 'z') { + event.preventDefault() + if (event.shiftKey) { + redo() + } else { + undo() + } + } + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, [pendingText, setPendingText, undo, redo, onCancel]) +} diff --git a/src/renderer/src/components/browser-pane/markup/useMarkupMode.ts b/src/renderer/src/components/browser-pane/markup/useMarkupMode.ts new file mode 100644 index 000000000000..f7e02df3dbf3 --- /dev/null +++ b/src/renderer/src/components/browser-pane/markup/useMarkupMode.ts @@ -0,0 +1,172 @@ +import { useCallback, useRef, useState } from 'react' +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import { + CLIPBOARD_IMAGE_MAX_PIXELS, + CLIPBOARD_IMAGE_MAX_SOURCE_BYTES +} from '../../../../../shared/clipboard-image' +import { + captureMarkupBaseImage, + type MarkupBaseImage, + type MarkupCaptureSource +} from './markup-base-image' +import { composeMarkupDataUrl, type MarkupComposeResult } from './markup-screenshot-compose' +import type { MarkupShape } from './markup-drawing-model' + +export type MarkupModeState = 'idle' | 'capturing' | 'drawing' | 'composing' + +// Where to capture the base image from, plus the on-screen geometry used to +// size and scale the composite. Resolved lazily at start() time by the owner +// (BrowserPane) since it depends on which environment the pane is showing. +export type MarkupCaptureContext = { + source: MarkupCaptureSource + cssWidth: number + cssHeight: number + outputScale: number +} + +export type MarkupCompleteInput = { + imageElement: CanvasImageSource + shapes: readonly MarkupShape[] +} + +type UseMarkupModeParams = { + getCaptureContext: () => MarkupCaptureContext | null + onDeliver: (result: MarkupComposeResult) => Promise | void +} + +export type MarkupModeController = { + state: MarkupModeState + isActive: boolean + baseImage: MarkupBaseImage | null + start: () => Promise + cancel: () => void + complete: (input: MarkupCompleteInput) => Promise +} + +export function useMarkupMode({ + getCaptureContext, + onDeliver +}: UseMarkupModeParams): MarkupModeController { + const [state, setState] = useState('idle') + const [baseImage, setBaseImage] = useState(null) + const contextRef = useRef(null) + // Why: a token invalidates an in-flight capture if the user cancels before it + // resolves, so the stale promise can't flip state back to 'drawing'. + const captureTokenRef = useRef(0) + + const reset = useCallback(() => { + captureTokenRef.current += 1 + contextRef.current = null + setBaseImage(null) + setState('idle') + }, []) + + const reportError = useCallback((key: string, fallback: string) => { + toast.error(translate(key, fallback)) + }, []) + + const start = useCallback(async () => { + const context = getCaptureContext() + if (!context) { + reportError( + 'auto.components.browser-pane.markup.errorUnavailable', + 'Screenshot markup is not available on this page.' + ) + return + } + const token = (captureTokenRef.current += 1) + contextRef.current = context + setState('capturing') + try { + const image = await captureMarkupBaseImage(context.source) + if (captureTokenRef.current !== token) { + return + } + setBaseImage(image) + setState('drawing') + } catch { + if (captureTokenRef.current !== token) { + return + } + // Why: a capture failure has no overlay to fall back to, so return to idle + // (not a stuck 'active' state with no surface and an inert Escape). + reportError( + 'auto.components.browser-pane.markup.errorCapture', + 'Could not capture the page to draw on.' + ) + reset() + } + }, [getCaptureContext, reportError, reset]) + + const cancel = useCallback(() => { + reset() + }, [reset]) + + const complete = useCallback( + async ({ imageElement, shapes }: MarkupCompleteInput) => { + const context = contextRef.current + if (!context) { + reset() + return + } + // Why: invalidate this completion if the user cancels or restarts while + // onDeliver is pending, so a stale callback can't reset/error the new session. + const token = captureTokenRef.current + setState('composing') + // Why: yield a frame so the busy 'composing' UI (disabled buttons, progress + // cursor) paints before the synchronous composite raster runs — otherwise + // Copy freezes with no visible feedback. Skip the work if cancelled meanwhile. + await new Promise((resolve) => requestAnimationFrame(() => resolve())) + if (captureTokenRef.current !== token) { + return + } + try { + const result = await composeMarkupDataUrl({ + image: imageElement, + displayCssWidth: context.cssWidth, + displayCssHeight: context.cssHeight, + outputScale: context.outputScale, + shapes, + // Why: target the clipboard handler's own ceilings — the byte limit + // (≈18 MB, well above a normal viewport PNG) and the pixel limit, which + // the handler otherwise enforces by silently dropping oversize images. + maxBytes: CLIPBOARD_IMAGE_MAX_SOURCE_BYTES, + maxPixels: CLIPBOARD_IMAGE_MAX_PIXELS + }) + // Why: re-check before delivering — the async compose is a wide window in + // which the user can Escape/cancel, and onDeliver writes the clipboard + // irreversibly. Without this, a cancelled session still overwrites it. + if (captureTokenRef.current !== token) { + return + } + await onDeliver(result) + if (captureTokenRef.current !== token) { + return + } + reset() + } catch { + if (captureTokenRef.current !== token) { + return + } + // Why: the frozen backdrop is still valid, so return to drawing (not a + // dead-end error state) — the user can retry Copy or Cancel out. + reportError( + 'auto.components.browser-pane.markup.errorAttach', + 'Could not attach the markup screenshot.' + ) + setState('drawing') + } + }, + [onDeliver, reset, reportError] + ) + + return { + state, + isActive: state !== 'idle', + baseImage, + start, + cancel, + complete + } +} diff --git a/src/renderer/src/components/browser-pane/markup/useMarkupPointerHandlers.ts b/src/renderer/src/components/browser-pane/markup/useMarkupPointerHandlers.ts new file mode 100644 index 000000000000..3ddf41fd4586 --- /dev/null +++ b/src/renderer/src/components/browser-pane/markup/useMarkupPointerHandlers.ts @@ -0,0 +1,99 @@ +import { useCallback } from 'react' +import type React from 'react' +import type { PendingText } from './useMarkupKeyboardShortcuts' +import { + commitShape, + type MarkupDocument, + type MarkupPoint, + type MarkupShape, + type MarkupTool +} from './markup-drawing-model' + +export type MarkupPointerParams = { + busy: boolean + tool: MarkupTool + color: string + width: number + pendingText: PendingText | null + canvasRef: React.RefObject + setInProgress: React.Dispatch> + setPendingText: (value: PendingText | null) => void + setDoc: React.Dispatch> +} + +// Canvas pointer interactions: draw a new shape, or place text. Split out of +// useMarkupEditor to keep that hook focused. +export function useMarkupPointerHandlers(params: MarkupPointerParams) { + const { busy, tool, color, width, pendingText, canvasRef, setInProgress, setPendingText, setDoc } = + params + + const pointFromEvent = useCallback( + (event: { clientX: number; clientY: number }): MarkupPoint => { + const canvas = canvasRef.current + if (!canvas) { + return { x: 0, y: 0 } + } + const rect = canvas.getBoundingClientRect() + return { x: event.clientX - rect.left, y: event.clientY - rect.top } + }, + [canvasRef] + ) + + const onPointerDown = useCallback( + (event: React.PointerEvent) => { + if (busy || event.button !== 0) { + return + } + const point = pointFromEvent(event) + if (tool === 'text') { + // Why: a box is already open — this click's job is only to commit it (the + // input's blur fires), not to open a second box at the click point. + if (pendingText) { + return + } + // Why: keep focus off the canvas so the mounting text input keeps it. + event.preventDefault() + setPendingText({ x: point.x, y: point.y, initial: '' }) + return + } + event.currentTarget.setPointerCapture(event.pointerId) + const id = crypto.randomUUID() + if (tool === 'pen' || tool === 'highlight') { + setInProgress({ id, kind: tool, color, width, points: [point] }) + } else { + setInProgress({ id, kind: tool, color, width, from: point, to: point }) + } + }, + [busy, color, pendingText, pointFromEvent, setInProgress, setPendingText, tool, width] + ) + + const onPointerMove = useCallback( + (event: React.PointerEvent) => { + setInProgress((current) => { + if (!current) { + return current + } + const point = pointFromEvent(event) + if (current.kind === 'pen' || current.kind === 'highlight') { + return { ...current, points: [...current.points, point] } + } + if (current.kind === 'text') { + return current + } + return { ...current, to: point } + }) + }, + [pointFromEvent, setInProgress] + ) + + const onPointerUp = useCallback(() => { + setInProgress((current) => { + if (current) { + setDoc((document) => commitShape(document, current)) + } + return null + }) + }, [setDoc, setInProgress]) + + return { onPointerDown, onPointerMove, onPointerUp } +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 06e0f50482cc..904551378c92 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -13020,6 +13020,34 @@ "searchLabels": "Filter labels…", "searchAssignee": "Filter assignee…", "back": "Back" + }, + "browser-pane": { + "markup": { + "cancel": "Cancel", + "clear": "Clear all", + "copiedToast": "Markup copied — paste it into your agent ({{value0}})", + "copy": "Copy Markup", + "drawButton": "Draw on screenshot", + "drawHint": "New — draw on the page, then copy it to your agent", + "errorAttach": "Could not attach the markup screenshot.", + "errorCapture": "Could not capture the page to draw on.", + "errorUnavailable": "Screenshot markup is not available on this page.", + "fontSize": "Font size", + "hint": "Draw on the page, then copy the markup to paste into your agent.", + "redo": "Redo", + "style": "Color and thickness", + "textInput": "Annotation text", + "tool": { + "arrow": "Arrow", + "ellipse": "Ellipse", + "highlight": "Highlighter", + "pen": "Pen", + "rect": "Rectangle", + "text": "Text" + }, + "undo": "Undo", + "widthOption": "{{value0}} px" + } } }, "i18n": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 2d093ebe8f88..4561dd25d61c 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -13020,6 +13020,34 @@ "searchLabels": "Filtrar etiquetas…", "searchAssignee": "Filtrar asignatario…", "back": "Atrás" + }, + "browser-pane": { + "markup": { + "cancel": "Cancelar", + "clear": "Borrar todo", + "copiedToast": "Marcado copiado: pégalo en tu agente ({{value0}})", + "copy": "Copiar marcado", + "drawButton": "Dibujar en la captura", + "drawHint": "Nuevo: dibuja en la página y cópialo en tu agente", + "errorAttach": "No se pudo adjuntar la captura de pantalla con marcado.", + "errorCapture": "No se pudo capturar la página para dibujar.", + "errorUnavailable": "El marcado de captura de pantalla no está disponible en esta página.", + "fontSize": "Tamaño de letra", + "hint": "Dibuja en la página y luego copia el marcado para pegarlo en tu agente.", + "redo": "Rehacer", + "style": "Color y grosor", + "textInput": "Texto de anotación", + "tool": { + "arrow": "Flecha", + "ellipse": "Elipse", + "highlight": "Resaltador", + "pen": "Lápiz", + "rect": "Rectángulo", + "text": "Texto" + }, + "undo": "Deshacer", + "widthOption": "{{value0}} px" + } } }, "i18n": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 4ad9de324bee..f012157d5888 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -13020,6 +13020,34 @@ "searchLabels": "ラベルをフィルター…", "searchAssignee": "担当者をフィルター…", "back": "戻る" + }, + "browser-pane": { + "markup": { + "cancel": "キャンセル", + "clear": "すべて消去", + "copiedToast": "注釈をコピーしました — エージェントに貼り付けてください({{value0}})", + "copy": "注釈をコピー", + "drawButton": "スクリーンショットに描き込み", + "drawHint": "新機能 — ページに描き込み、エージェントに貼り付けられます", + "errorAttach": "注釈付きスクリーンショットを添付できませんでした。", + "errorCapture": "描画用にページを取得できませんでした。", + "errorUnavailable": "このページではスクリーンショット注釈を利用できません。", + "fontSize": "文字サイズ", + "hint": "ページに描き込み、注釈をコピーしてエージェントに貼り付けてください。", + "redo": "やり直し", + "style": "色と太さ", + "textInput": "注釈テキスト", + "tool": { + "arrow": "矢印", + "ellipse": "楕円", + "highlight": "ハイライト", + "pen": "ペン", + "rect": "四角形", + "text": "テキスト" + }, + "undo": "元に戻す", + "widthOption": "{{value0}} px" + } } }, "i18n": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index dfbefd0700b7..2fae58392304 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -13020,6 +13020,34 @@ "searchLabels": "레이블 필터…", "searchAssignee": "담당자 필터…", "back": "뒤로" + }, + "browser-pane": { + "markup": { + "cancel": "취소", + "clear": "모두 지우기", + "copiedToast": "마크업을 복사했습니다 — 에이전트에 붙여넣으세요 ({{value0}})", + "copy": "마크업 복사", + "drawButton": "스크린샷에 그리기", + "drawHint": "새 기능 — 페이지에 그린 다음 에이전트에 붙여넣으세요", + "errorAttach": "마크업 스크린샷을 첨부하지 못했습니다.", + "errorCapture": "그릴 페이지를 캡처하지 못했습니다.", + "errorUnavailable": "이 페이지에서는 스크린샷 마크업을 사용할 수 없습니다.", + "fontSize": "글자 크기", + "hint": "페이지에 그린 다음 마크업을 복사해 에이전트에 붙여넣으세요.", + "redo": "다시 실행", + "style": "색상 및 두께", + "textInput": "주석 텍스트", + "tool": { + "arrow": "화살표", + "ellipse": "타원", + "highlight": "형광펜", + "pen": "펜", + "rect": "사각형", + "text": "텍스트" + }, + "undo": "실행 취소", + "widthOption": "{{value0}} px" + } } }, "i18n": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index eca7076c294a..e23f6853fe3d 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -13020,6 +13020,34 @@ "searchLabels": "筛选标签…", "searchAssignee": "筛选负责人…", "back": "返回" + }, + "browser-pane": { + "markup": { + "cancel": "取消", + "clear": "全部清除", + "copiedToast": "已复制标注 — 粘贴到你的智能体({{value0}})", + "copy": "复制标注", + "drawButton": "在截图上绘制", + "drawHint": "新功能 — 在页面上绘制,然后复制到你的智能体", + "errorAttach": "无法附加标注后的截图。", + "errorCapture": "无法捕获页面进行绘制。", + "errorUnavailable": "此页面无法使用截图标注。", + "fontSize": "字号", + "hint": "在页面上绘制,然后复制标注以粘贴到你的智能体。", + "redo": "重做", + "style": "颜色和粗细", + "textInput": "标注文字", + "tool": { + "arrow": "箭头", + "ellipse": "椭圆", + "highlight": "高亮", + "pen": "画笔", + "rect": "矩形", + "text": "文字" + }, + "undo": "撤销", + "widthOption": "{{value0}} px" + } } }, "i18n": { From d44696a74c6b690c86c3cffe000a05e794065d16 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 00:48:41 +0000 Subject: [PATCH 006/351] Update README downloads badge --- docs/assets/readme-downloads.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index db2a6fd8806a..2c60ec6df8da 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 5.3m + + downloads: 5.4m @@ -15,7 +15,7 @@ downloads downloads - 5.3m - 5.3m + 5.4m + 5.4m From 33a8df93fc0900cee9555c72afb6f35e9a897ccd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:49:13 -0700 Subject: [PATCH 007/351] fix(speech): resume interrupted voice model downloads and surface real errors (#8775) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(speech): resume interrupted voice model downloads and surface real errors Voice model downloads that died late in the transfer (flaky networks, scanning proxies) failed at the UI's 90% progress cap and restarted from byte zero on every retry, so affected users could never complete any model download (#8620 / STA-1775). - Retry transient download failures (net::ERR_* transfer cuts, resets, timeouts, HTTP 5xx/429) with an HTTP Range resume from the bytes already on disk, restarting from the canonical URL each attempt since signed CDN redirect URLs expire; bounded backoff, gives up after repeated zero-progress attempts with a diagnosable error. - Correct catalog sizeBytes to exact upstream asset sizes (parakeet v2/v3 are 482/487 MB, not 170/180; paraformer is 1,047 MB, not 115) — also the progress denominator when content-length is missing. - Show the underlying failure cause in the download-error toast and log it in main; it was previously invisible everywhere. All 7 pinned catalog SHA-256 hashes verified against current upstream assets. Live-verified end to end: a transfer killed mid-body resumes with a 206 and completes through checksum, extraction, and validation. Fixes #8620 * fix(speech): harden resumed model downloads * fix(speech): validate resumed download completion * fix(speech): keep segmented download resumes progressing * fix(speech): don't abandon a download that keeps making progress The retry loop gave up after 8 total failures regardless of progress, so a large model (e.g. the ~1GB Paraformer) over a network that resets every few hundred MB was abandoned mid-download even though every attempt was resuming and advancing — the exact 'stuck then fails' symptom, just later. Replace the all-time failure budget and the separate advancing-segment cap with a single rule: give up only on a genuine stall (repeated attempts with no forward progress) or an absolute request ceiling that bounds a pathological tiny-segment server. Any download that keeps advancing now runs to completion. Verified live under Electron 43: a transfer reset mid-stream on every attempt but advancing each time previously gave up at 67% after 8 attempts; it now completes. All prior resume/cancel/timeout scenarios still pass. --- src/main/speech/model-catalog.ts | 16 +- .../model-manager-download-resume.test.ts | 671 ++++++++++++++++++ src/main/speech/model-manager.ts | 339 ++++++++- .../settings/VoiceSpeechModelSection.tsx | 26 +- 4 files changed, 1020 insertions(+), 32 deletions(-) create mode 100644 src/main/speech/model-manager-download-resume.test.ts diff --git a/src/main/speech/model-catalog.ts b/src/main/speech/model-catalog.ts index 266442eb78dd..b77f6c238d63 100644 --- a/src/main/speech/model-catalog.ts +++ b/src/main/speech/model-catalog.ts @@ -1,5 +1,7 @@ import type { SpeechModelManifest } from '../../shared/speech-types' +// Why: sizeBytes must be the exact upstream asset size — it is the UI size +// label and the download-progress denominator when content-length is missing. export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [ { id: 'parakeet-tdt-0.6b-v3-int8', @@ -9,7 +11,7 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [ type: 'transducer', provider: 'local', language: 'multilingual', - sizeBytes: 180_000_000, + sizeBytes: 487_170_055, downloadUrl: 'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8.tar.bz2', archiveSha256: '5793d0fd397c5778d2cf2126994d58e9d56b1be7c04d13c7a15bb1b4eafb16bf', @@ -28,7 +30,7 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [ type: 'transducer', provider: 'local', language: 'en', - sizeBytes: 170_000_000, + sizeBytes: 482_468_385, downloadUrl: 'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8.tar.bz2', archiveSha256: '157c157bc51155e03e37d2466522a3a737dd9c72bb25f36eb18912964161e1ad', @@ -45,7 +47,7 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [ type: 'transducer', provider: 'local', language: 'zh-en', - sizeBytes: 130_000_000, + sizeBytes: 511_274_346, downloadUrl: 'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20.tar.bz2', archiveSha256: '27ffbd9ee24ad186d99acc2f6354d7992b27bcab490812510665fa8f9389c5f8', @@ -68,7 +70,7 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [ type: 'paraformer', provider: 'local', language: 'zh-en', - sizeBytes: 115_000_000, + sizeBytes: 1_047_319_737, downloadUrl: 'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-streaming-paraformer-bilingual-zh-en.tar.bz2', archiveSha256: '5462a1fce42693deae572af1e8c4687124b12aa85fe61ff4d3168bb5280e205f', @@ -84,7 +86,7 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [ type: 'transducer', provider: 'local', language: 'en', - sizeBytes: 128_000_000, + sizeBytes: 127_887_156, downloadUrl: 'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-streaming-zipformer-en-20M-2023-02-17.tar.bz2', archiveSha256: '9c559283e8498d3fe95913c79ca1cb454bb26281ac2b102b41306c7d752765d9', @@ -106,7 +108,7 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [ type: 'transducer', provider: 'local', language: 'zh', - sizeBytes: 74_000_000, + sizeBytes: 74_004_050, downloadUrl: 'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-streaming-zipformer-zh-14M-2023-02-23.tar.bz2', archiveSha256: '2cbd71b640d9c37d3784f29367333a4577b0398b62e9deeed418170b081cba8b', @@ -128,7 +130,7 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [ type: 'whisper', provider: 'local', language: 'multilingual', - sizeBytes: 116_000_000, + sizeBytes: 116_204_861, downloadUrl: 'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-whisper-tiny.tar.bz2', archiveSha256: 'c46116994e539aa165266d96b325252728429c12535eb9d8b6a2b10f129e66b1', diff --git a/src/main/speech/model-manager-download-resume.test.ts b/src/main/speech/model-manager-download-resume.test.ts new file mode 100644 index 000000000000..bbfc88e74873 --- /dev/null +++ b/src/main/speech/model-manager-download-resume.test.ts @@ -0,0 +1,671 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Readable } from 'node:stream' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ModelManager } from './model-manager' + +const { netRequestMock } = vi.hoisted(() => ({ + netRequestMock: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { + getPath: () => '/tmp/orca-speech-models-test' + }, + net: { + request: netRequestMock + } +})) + +type ModelManagerInternals = { + downloadArchiveWithRetry: ( + url: string, + archivePath: string, + expectedSize: number, + modelId: string, + isAborted: () => boolean, + signal: AbortSignal + ) => Promise + downloadFile: ( + url: string, + archivePath: string, + expectedSize: number, + modelId: string, + isAborted: () => boolean, + signal?: AbortSignal, + redirectCount?: number, + resumeOffset?: number + ) => Promise +} + +type ScriptedResponse = { + statusCode: number + headers?: Record + chunks?: Buffer[] + failWith?: string +} + +type ScriptedResponseFactory = (sentHeaders: Record) => ScriptedResponse + +// Emulates Electron's ClientRequest/IncomingMessage closely enough for the +// download pipeline: a real Readable body so stream.pipeline semantics +// (including mid-body destroy) match production behavior. +function scriptRequest(factory: ScriptedResponseFactory): { + sentHeaders: Record + abortMock: ReturnType +} { + const sentHeaders: Record = {} + const listeners = new Map void>>() + const addListener = (event: string, cb: (...args: unknown[]) => void): void => { + const set = listeners.get(event) ?? new Set() + set.add(cb) + listeners.set(event, set) + } + const abortMock = vi.fn(() => request) + const request = { + setHeader: vi.fn((name: string, value: string) => { + sentHeaders[name.toLowerCase()] = value + }), + on: vi.fn((event: string, cb: (...args: unknown[]) => void) => { + addListener(event, cb) + return request + }), + off: vi.fn((event: string, cb: (...args: unknown[]) => void) => { + listeners.get(event)?.delete(cb) + return request + }), + abort: abortMock, + end: vi.fn(() => { + queueMicrotask(() => { + const spec = factory(sentHeaders) + const response = Object.assign(new Readable({ read() {} }), { + statusCode: spec.statusCode, + headers: spec.headers ?? {} + }) + for (const cb of listeners.get('response') ?? []) { + cb(response) + } + setTimeout(() => { + for (const chunk of spec.chunks ?? []) { + response.push(chunk) + } + // Why: fail on a later tick so pushed chunks flush to the file + // stream first, mirroring a transfer that dies mid-body. + setTimeout(() => { + if (spec.failWith) { + response.destroy(new Error(spec.failWith)) + } else { + response.push(null) + } + }, 20) + }, 20) + }) + return request + }) + } + netRequestMock.mockImplementationOnce(() => request) + return { sentHeaders, abortMock } +} + +const PAYLOAD = Buffer.from('0123456789abcdefghij') + +describe('ModelManager download resume', () => { + beforeEach(() => { + netRequestMock.mockReset() + }) + + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('resumes an interrupted download with a Range request and assembles the full file', async () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-model-resume-')) + try { + scriptRequest(() => ({ + statusCode: 200, + headers: { 'content-length': String(PAYLOAD.length) }, + chunks: [PAYLOAD.subarray(0, 10)], + failWith: 'net::ERR_CONTENT_LENGTH_MISMATCH' + })) + const second = scriptRequest((sentHeaders) => { + expect(sentHeaders.range).toBe('bytes=10-') + return { + statusCode: 206, + headers: { + 'content-length': String(PAYLOAD.length - 10), + 'content-range': `bytes 10-${PAYLOAD.length - 1}/${PAYLOAD.length}` + }, + chunks: [PAYLOAD.subarray(10)] + } + }) + const manager = new ModelManager(dir) as unknown as ModelManagerInternals + const archivePath = join(dir, 'model.tar.bz2') + + await manager.downloadArchiveWithRetry( + 'https://example.com/model.tar.bz2', + archivePath, + PAYLOAD.length, + 'm', + () => false, + new AbortController().signal + ) + + expect(netRequestMock).toHaveBeenCalledTimes(2) + expect(second.sentHeaders.range).toBe('bytes=10-') + expect(readFileSync(archivePath)).toEqual(PAYLOAD) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('uses a complete archive after a late transport failure without requesting past EOF', async () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-model-resume-')) + try { + scriptRequest(() => ({ + statusCode: 200, + headers: { 'content-length': String(PAYLOAD.length) }, + chunks: [PAYLOAD], + failWith: 'net::ERR_CONNECTION_RESET' + })) + const manager = new ModelManager(dir) as unknown as ModelManagerInternals + const archivePath = join(dir, 'model.tar.bz2') + + await manager.downloadArchiveWithRetry( + 'https://example.com/model.tar.bz2', + archivePath, + PAYLOAD.length, + 'm', + () => false, + new AbortController().signal + ) + + expect(netRequestMock).toHaveBeenCalledTimes(1) + expect(readFileSync(archivePath)).toEqual(PAYLOAD) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('requests the remaining bytes when a clean range response ends before the archive total', async () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-model-resume-')) + try { + const first = scriptRequest((sentHeaders) => { + expect(sentHeaders.range).toBe('bytes=10-') + return { + statusCode: 206, + headers: { + 'content-length': '5', + 'content-range': `bytes 10-14/${PAYLOAD.length}` + }, + chunks: [PAYLOAD.subarray(10, 15)] + } + }) + const second = scriptRequest((sentHeaders) => { + expect(sentHeaders.range).toBe('bytes=15-') + return { + statusCode: 206, + headers: { + 'content-length': '5', + 'content-range': `bytes 15-19/${PAYLOAD.length}` + }, + chunks: [PAYLOAD.subarray(15)] + } + }) + const manager = new ModelManager(dir) as unknown as ModelManagerInternals + const archivePath = join(dir, 'model.tar.bz2') + writeFileSync(archivePath, PAYLOAD.subarray(0, 10)) + + await manager.downloadArchiveWithRetry( + 'https://example.com/model.tar.bz2', + archivePath, + PAYLOAD.length, + 'm', + () => false, + new AbortController().signal + ) + + expect(netRequestMock).toHaveBeenCalledTimes(2) + expect(first.sentHeaders.range).toBe('bytes=10-') + expect(second.sentHeaders.range).toBe('bytes=15-') + expect(readFileSync(archivePath)).toEqual(PAYLOAD) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('continues through more than eight advancing range segments', async () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-model-resume-')) + try { + for (let offset = 1; offset <= 9; offset += 1) { + scriptRequest((sentHeaders) => { + expect(sentHeaders.range).toBe(`bytes=${offset}-`) + return { + statusCode: 206, + headers: { + 'content-length': '1', + 'content-range': `bytes ${offset}-${offset}/${PAYLOAD.length}` + }, + chunks: [PAYLOAD.subarray(offset, offset + 1)] + } + }) + } + scriptRequest((sentHeaders) => { + expect(sentHeaders.range).toBe('bytes=10-') + return { + statusCode: 206, + headers: { + 'content-length': '10', + 'content-range': `bytes 10-19/${PAYLOAD.length}` + }, + chunks: [PAYLOAD.subarray(10)] + } + }) + const manager = new ModelManager(dir) as unknown as ModelManagerInternals + const archivePath = join(dir, 'model.tar.bz2') + writeFileSync(archivePath, PAYLOAD.subarray(0, 1)) + + await manager.downloadArchiveWithRetry( + 'https://example.com/model.tar.bz2', + archivePath, + PAYLOAD.length, + 'm', + () => false, + new AbortController().signal + ) + + expect(netRequestMock).toHaveBeenCalledTimes(10) + expect(readFileSync(archivePath)).toEqual(PAYLOAD) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('bounds a server that advances by pathologically tiny segments forever', async () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-model-resume-')) + try { + const manager = new ModelManager(dir) as unknown as ModelManagerInternals + const archivePath = join(dir, 'model.tar.bz2') + let bytesWritten = 0 + // Advances one byte per request against a total larger than the request + // ceiling, so it makes forward progress forever without ever completing. + const downloadFileMock = vi.spyOn(manager, 'downloadFile').mockImplementation(() => { + bytesWritten += 1 + writeFileSync(archivePath, Buffer.alloc(bytesWritten)) + return Promise.resolve() + }) + + await expect( + manager.downloadArchiveWithRetry( + 'https://example.com/model.tar.bz2', + archivePath, + 1_000_000, + 'm', + () => false, + new AbortController().signal + ) + ).rejects.toThrow(/too many download requests/) + + // The absolute request ceiling (MAX_TOTAL_DOWNLOAD_REQUESTS) bounds it. + expect(downloadFileMock).toHaveBeenCalledTimes(4_096) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('keeps resuming a download that advances across many mid-stream drops', async () => { + vi.useFakeTimers() + const dir = mkdtempSync(join(tmpdir(), 'orca-model-resume-')) + try { + // Every attempt delivers a small slice then drops mid-stream — the + // classic "dies partway, resumes" pattern. Because each attempt makes + // forward progress, the download must complete no matter how many drops + // it takes (regression guard: a fixed failure budget used to abandon a + // still-advancing large download around attempt 8). + const manager = new ModelManager(dir) as unknown as ModelManagerInternals + const archivePath = join(dir, 'model.tar.bz2') + const SLICE = 2 + let delivered = 0 + const downloadFileMock = vi.spyOn(manager, 'downloadFile').mockImplementation(() => { + delivered = Math.min(delivered + SLICE, PAYLOAD.length) + writeFileSync(archivePath, PAYLOAD.subarray(0, delivered)) + return Promise.reject(new Error('net::ERR_CONNECTION_RESET')) + }) + + const download = manager.downloadArchiveWithRetry( + 'https://example.com/model.tar.bz2', + archivePath, + PAYLOAD.length, + 'm', + () => false, + new AbortController().signal + ) + const outcome = download.then( + () => 'resolved', + (error: unknown) => (error instanceof Error ? error.message : String(error)) + ) + // Drive the per-attempt backoff (1s each, since progress resets the + // stall counter) well past the ten attempts this needs. + await vi.advanceTimersByTimeAsync(30_000) + + await expect(outcome).resolves.toBe('resolved') + expect(downloadFileMock).toHaveBeenCalledTimes(PAYLOAD.length / SLICE) + expect(readFileSync(archivePath)).toEqual(PAYLOAD) + } finally { + vi.useRealTimers() + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('keeps the known archive total when Content-Range omits it', async () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-model-resume-')) + try { + scriptRequest((sentHeaders) => { + expect(sentHeaders.range).toBe('bytes=10-') + return { + statusCode: 206, + headers: { + 'content-length': '5', + 'content-range': 'bytes 10-14/*' + }, + chunks: [PAYLOAD.subarray(10, 15)] + } + }) + scriptRequest((sentHeaders) => { + expect(sentHeaders.range).toBe('bytes=15-') + return { + statusCode: 206, + headers: { + 'content-length': '5', + 'content-range': 'bytes 15-19/*' + }, + chunks: [PAYLOAD.subarray(15)] + } + }) + const manager = new ModelManager(dir) as unknown as ModelManagerInternals + const archivePath = join(dir, 'model.tar.bz2') + writeFileSync(archivePath, PAYLOAD.subarray(0, 10)) + + await manager.downloadArchiveWithRetry( + 'https://example.com/model.tar.bz2', + archivePath, + PAYLOAD.length, + 'm', + () => false, + new AbortController().signal + ) + + expect(netRequestMock).toHaveBeenCalledTimes(2) + expect(readFileSync(archivePath)).toEqual(PAYLOAD) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('rejects and discards a partial when Content-Range does not match the offset', async () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-model-resume-')) + try { + const mismatched = scriptRequest(() => ({ + statusCode: 206, + headers: { + 'content-length': '10', + 'content-range': `bytes 0-9/${PAYLOAD.length}` + }, + chunks: [PAYLOAD.subarray(0, 10)] + })) + const manager = new ModelManager(dir) as unknown as ModelManagerInternals + const archivePath = join(dir, 'model.tar.bz2') + writeFileSync(archivePath, PAYLOAD.subarray(0, 10)) + + const error = await manager + .downloadFile( + 'https://example.com/model.tar.bz2', + archivePath, + PAYLOAD.length, + 'm', + () => false, + new AbortController().signal, + 0, + 10 + ) + .catch((cause: unknown) => cause) + + expect(error).toMatchObject({ + message: 'Invalid Content-Range for resume at byte 10', + retryable: true + }) + expect(existsSync(archivePath)).toBe(false) + expect(mismatched.abortMock).toHaveBeenCalledTimes(1) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('restarts from scratch when the server ignores the Range request', async () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-model-resume-')) + try { + scriptRequest(() => ({ + statusCode: 200, + headers: { 'content-length': String(PAYLOAD.length) }, + chunks: [PAYLOAD.subarray(0, 10)], + failWith: 'net::ERR_CONNECTION_RESET' + })) + scriptRequest(() => ({ + statusCode: 200, + headers: { 'content-length': String(PAYLOAD.length) }, + chunks: [PAYLOAD] + })) + const manager = new ModelManager(dir) as unknown as ModelManagerInternals + const archivePath = join(dir, 'model.tar.bz2') + + await manager.downloadArchiveWithRetry( + 'https://example.com/model.tar.bz2', + archivePath, + PAYLOAD.length, + 'm', + () => false, + new AbortController().signal + ) + + expect(netRequestMock).toHaveBeenCalledTimes(2) + expect(readFileSync(archivePath)).toEqual(PAYLOAD) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('does not retry non-transient failures', async () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-model-resume-')) + try { + scriptRequest(() => ({ statusCode: 404 })) + const manager = new ModelManager(dir) as unknown as ModelManagerInternals + + await expect( + manager.downloadArchiveWithRetry( + 'https://example.com/model.tar.bz2', + join(dir, 'model.tar.bz2'), + PAYLOAD.length, + 'm', + () => false, + new AbortController().signal + ) + ).rejects.toThrow('HTTP 404') + + expect(netRequestMock).toHaveBeenCalledTimes(1) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('aborts an HTTP error response instead of draining it after rejection', async () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-model-resume-')) + try { + const scripted = scriptRequest(() => ({ + statusCode: 429, + headers: { 'retry-after': '3' } + })) + const manager = new ModelManager(dir) as unknown as ModelManagerInternals + + const error = await manager + .downloadFile( + 'https://example.com/model.tar.bz2', + join(dir, 'model.tar.bz2'), + PAYLOAD.length, + 'm', + () => false + ) + .catch((cause: unknown) => cause) + + expect(error).toMatchObject({ message: 'HTTP 429', httpStatusCode: 429, retryAfterMs: 3_000 }) + expect(scripted.abortMock).toHaveBeenCalledTimes(1) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('honors Retry-After before issuing another request', async () => { + vi.useFakeTimers() + const dir = mkdtempSync(join(tmpdir(), 'orca-model-resume-')) + try { + const manager = new ModelManager(dir) as unknown as ModelManagerInternals + const archivePath = join(dir, 'model.tar.bz2') + const rateLimitError = Object.assign(new Error('HTTP 429'), { + httpStatusCode: 429, + retryAfterMs: 3_000 + }) + const downloadFileMock = vi + .spyOn(manager, 'downloadFile') + .mockRejectedValueOnce(rateLimitError) + .mockImplementationOnce(() => { + writeFileSync(archivePath, PAYLOAD) + return Promise.resolve() + }) + const download = manager.downloadArchiveWithRetry( + 'https://example.com/model.tar.bz2', + archivePath, + PAYLOAD.length, + 'm', + () => false, + new AbortController().signal + ) + + await vi.advanceTimersByTimeAsync(2_999) + expect(downloadFileMock).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + + await expect(download).resolves.toBeUndefined() + expect(downloadFileMock).toHaveBeenCalledTimes(2) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('does not retry before an excessively long Retry-After window', async () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-model-resume-')) + try { + const manager = new ModelManager(dir) as unknown as ModelManagerInternals + const rateLimitError = Object.assign(new Error('HTTP 429'), { + httpStatusCode: 429, + retryAfterMs: 300_000 + }) + const downloadFileMock = vi.spyOn(manager, 'downloadFile').mockRejectedValue(rateLimitError) + + await expect( + manager.downloadArchiveWithRetry( + 'https://example.com/model.tar.bz2', + join(dir, 'model.tar.bz2'), + PAYLOAD.length, + 'm', + () => false, + new AbortController().signal + ) + ).rejects.toThrow('server requested retry after 300 seconds') + + expect(downloadFileMock).toHaveBeenCalledTimes(1) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('gives up after repeated zero-progress failures with a diagnosable error', async () => { + vi.useFakeTimers() + const dir = mkdtempSync(join(tmpdir(), 'orca-model-resume-')) + try { + const manager = new ModelManager(dir) as unknown as ModelManagerInternals + const downloadFileMock = vi + .spyOn(manager, 'downloadFile') + .mockRejectedValue(new Error('net::ERR_CONNECTION_RESET')) + + const download = manager.downloadArchiveWithRetry( + 'https://example.com/model.tar.bz2', + join(dir, 'model.tar.bz2'), + PAYLOAD.length, + 'm', + () => false, + new AbortController().signal + ) + const outcome = download.then( + () => 'resolved', + (error: unknown) => (error instanceof Error ? error.message : String(error)) + ) + // Let the first rejection schedule its backoff before advancing timers; + // this avoids racing fake time against stream/file I/O under full-suite load. + await vi.advanceTimersByTimeAsync(0) + expect(downloadFileMock).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(999) + expect(downloadFileMock).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + expect(downloadFileMock).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(1_999) + expect(downloadFileMock).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(1) + expect(downloadFileMock).toHaveBeenCalledTimes(3) + await vi.advanceTimersByTimeAsync(3_999) + expect(downloadFileMock).toHaveBeenCalledTimes(3) + await vi.advanceTimersByTimeAsync(1) + + await expect(outcome).resolves.toMatch( + /Model download interrupted at 0% \(0 of 20 bytes\) after 4 attempts: .*net::ERR_CONNECTION_RESET/ + ) + expect(downloadFileMock).toHaveBeenCalledTimes(4) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('stops retrying once the download is aborted', async () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-model-resume-')) + try { + scriptRequest(() => ({ + statusCode: 200, + headers: { 'content-length': String(PAYLOAD.length) }, + failWith: 'net::ERR_CONNECTION_RESET' + })) + const controller = new AbortController() + const manager = new ModelManager(dir) as unknown as ModelManagerInternals + + const download = manager.downloadArchiveWithRetry( + 'https://example.com/model.tar.bz2', + join(dir, 'model.tar.bz2'), + PAYLOAD.length, + 'm', + () => false, + controller.signal + ) + const outcome = download.then( + () => 'resolved', + (error: unknown) => (error instanceof Error ? error.message : String(error)) + ) + // Abort after the transfer failure enters backoff; the next attempt must + // settle as Aborted without issuing another request. + setTimeout(() => controller.abort(), 100) + const message = await outcome + + expect(message).toBe('Aborted') + expect(netRequestMock).toHaveBeenCalledTimes(1) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/speech/model-manager.ts b/src/main/speech/model-manager.ts index 5671f9f87d3b..c8f918f15262 100644 --- a/src/main/speech/model-manager.ts +++ b/src/main/speech/model-manager.ts @@ -1,7 +1,14 @@ /* eslint-disable max-lines -- Why: model download, checksum, extraction, and cleanup share one state machine so progress/error transitions stay coupled. */ import { app, net } from 'electron' import { join, resolve, relative } from 'node:path' -import { existsSync, mkdirSync, createWriteStream, createReadStream, rmSync } from 'node:fs' +import { + existsSync, + mkdirSync, + createWriteStream, + createReadStream, + rmSync, + statSync +} from 'node:fs' import { readdir, rm } from 'node:fs/promises' import { createHash } from 'node:crypto' import { pipeline } from 'node:stream/promises' @@ -28,11 +35,129 @@ type ProgressCallback = (modelId: string, progress: number) => void type DownloadIncomingMessage = Electron.IncomingMessage & NodeJS.ReadableStream & { headers: Record - resume: () => void destroy?: () => void } +type HttpStatusError = Error & { + httpStatusCode?: number + retryAfterMs?: number + retryable?: boolean +} +type DownloadTotals = { totalBytes: number } +type ContentRange = { start: number; end: number; totalBytes?: number } const DOWNLOAD_IDLE_TIMEOUT_MS = 120_000 +// Why: flaky networks and scanning proxies commonly kill long CDN transfers +// near the end (reported as "stuck at 90%, then fails"); resuming with a Range +// request lets those connections finish instead of failing the same way on +// every full restart. +const DOWNLOAD_RETRY_DELAYS_MS = [1_000, 2_000, 4_000] +// Why: abandon a download only after this many CONSECUTIVE attempts make no +// forward progress. A resume that keeps advancing (even across many mid-stream +// drops) is never given up on — that is the whole point of resumable downloads +// for large models over flaky networks, and a fixed all-time failure budget +// would wrongly kill a steadily-progressing large download. +const MAX_NO_PROGRESS_ATTEMPTS = DOWNLOAD_RETRY_DELAYS_MS.length + 1 +// Why: an absolute backstop so a server handing back pathologically tiny (or +// zero-net) segments cannot loop forever; sized to cover the largest (~1GB) +// model even if a proxy caps each range response near ~256KB, far below any +// real CDN chunk. +const MAX_TOTAL_DOWNLOAD_REQUESTS = 4_096 +// Why: a longer server window should be surfaced for a later manual retry, +// rather than leaving the settings download looking stuck for minutes. +const MAX_RETRY_AFTER_MS = 120_000 +const RETRYABLE_NET_ERROR = + /net::ERR_(CONTENT_LENGTH_MISMATCH|INCOMPLETE_CHUNKED_ENCODING|CONNECTION_(RESET|CLOSED|ABORTED|REFUSED|TIMED_OUT)|EMPTY_RESPONSE|NETWORK_CHANGED|TIMED_OUT|INTERNET_DISCONNECTED|ADDRESS_UNREACHABLE|NAME_NOT_RESOLVED|SOCKET_NOT_CONNECTED|HTTP2_PROTOCOL_ERROR|QUIC_PROTOCOL_ERROR)\b/ +const RETRYABLE_HTTP_STATUSES = new Set([408, 416, 425, 429, 500, 502, 503, 504]) + +function isRetryableDownloadError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false + } + const downloadError = error as HttpStatusError + if (downloadError.retryable === true) { + return true + } + const statusCode = downloadError.httpStatusCode + if (statusCode !== undefined) { + return RETRYABLE_HTTP_STATUSES.has(statusCode) + } + return ( + RETRYABLE_NET_ERROR.test(error.message) || error.message.includes('without network activity') + ) +} + +function getHeaderValue(value: string | string[] | undefined): string | undefined { + return Array.isArray(value) ? value[0] : value +} + +function parseContentRange(value: string | string[] | undefined): ContentRange | null { + const match = getHeaderValue(value) + ?.trim() + .match(/^bytes\s+(\d+)-(\d+)\/(\d+|\*)$/i) + if (!match) { + return null + } + const start = Number.parseInt(match[1], 10) + const end = Number.parseInt(match[2], 10) + const totalBytes = match[3] === '*' ? undefined : Number.parseInt(match[3], 10) + if ( + !Number.isSafeInteger(start) || + !Number.isSafeInteger(end) || + end < start || + (totalBytes !== undefined && (!Number.isSafeInteger(totalBytes) || totalBytes <= end)) + ) { + return null + } + return { start, end, totalBytes } +} + +function parseRetryAfterMs(value: string | string[] | undefined): number | undefined { + const header = getHeaderValue(value)?.trim() + if (!header) { + return undefined + } + if (/^\d+$/.test(header)) { + const seconds = Number.parseInt(header, 10) + const delayMs = seconds * 1_000 + return Number.isSafeInteger(delayMs) ? delayMs : undefined + } + const retryAt = Date.parse(header) + return Number.isNaN(retryAt) ? undefined : Math.max(0, retryAt - Date.now()) +} + +function describeInterruptedDownload( + cause: unknown, + receivedBytes: number, + totalBytes: number, + attempts: number +): Error { + const causeMessage = cause instanceof Error ? cause.message : String(cause) + const received = + totalBytes > 0 + ? `${Math.min(99, Math.floor((receivedBytes / totalBytes) * 100))}% (${receivedBytes} of ${totalBytes} bytes)` + : `${receivedBytes} bytes` + return new Error( + `Model download interrupted at ${received} after ${attempts} attempts: ${causeMessage}` + ) +} + +function sleepUnlessAborted(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal.aborted) { + resolve() + return + } + const onAbort = (): void => { + clearTimeout(timer) + resolve() + } + const timer = setTimeout(() => { + signal.removeEventListener('abort', onAbort) + resolve() + }, ms) + signal.addEventListener('abort', onAbort, { once: true }) + }) +} export class ModelManager { private modelsDir: string @@ -177,6 +302,15 @@ export class ModelManager { this.updateState(modelId, 'downloading', 0) const archivePath = join(this.modelsDir, `${modelId}.tar.bz2`) + // Why: resume appends to the partial archive from this download run only; + // a leftover file from a crashed run would corrupt the resumed archive. + try { + if (existsSync(archivePath)) { + rmSync(archivePath) + } + } catch { + // best-effort; the first (non-resumed) attempt truncates on write + } let aborted = false const abortController = new AbortController() @@ -191,7 +325,7 @@ export class ModelManager { this.activeDownloads.set(modelId, handle) try { - await this.downloadFile( + await this.downloadArchiveWithRetry( manifest.downloadUrl, archivePath, manifest.sizeBytes, @@ -239,6 +373,7 @@ export class ModelManager { this.updateState(modelId, 'ready') } catch (err) { if (!aborted) { + console.error('[speech] Model download failed:', modelId, err) this.updateState(modelId, 'error', undefined, String(err)) } this.cleanup(modelId, archivePath) @@ -309,6 +444,120 @@ export class ModelManager { } } + private getPartialArchiveBytes(archivePath: string): number { + try { + return statSync(archivePath).size + } catch { + return 0 + } + } + + private async downloadArchiveWithRetry( + url: string, + archivePath: string, + expectedSize: number, + modelId: string, + isAborted: () => boolean, + signal: AbortSignal + ): Promise { + let requestCount = 0 + let noProgressStreak = 0 + const totals: DownloadTotals = { totalBytes: expectedSize } + for (;;) { + requestCount += 1 + const offset = this.getPartialArchiveBytes(archivePath) + // Why: the transport can fail after the final byte reached disk; the + // caller's SHA-256 check is the authoritative completion test. + if (offset === totals.totalBytes) { + return + } + // Why: absolute backstop against a server that never lets the download + // finish; the stall and abort paths handle the common cases first. + if (requestCount > MAX_TOTAL_DOWNLOAD_REQUESTS) { + throw describeInterruptedDownload( + new Error('too many download requests'), + offset, + totals.totalBytes, + requestCount - 1 + ) + } + try { + // Why: each attempt restarts from the canonical URL rather than the + // last redirect target, because CDN redirect URLs are signed and expire. + await this.downloadFile( + url, + archivePath, + expectedSize, + modelId, + isAborted, + signal, + 0, + offset, + totals + ) + const receivedBytes = this.getPartialArchiveBytes(archivePath) + if (receivedBytes === totals.totalBytes) { + return + } + if (receivedBytes > totals.totalBytes) { + throw new Error( + `Model download exceeded its expected size (${receivedBytes} of ${totals.totalBytes} bytes)` + ) + } + const incompleteResponse = new Error( + `Model download response ended at ${receivedBytes} of ${totals.totalBytes} bytes` + ) + if (receivedBytes > offset) { + // Why: some proxies return a valid but bounded range segment; request + // the next segment immediately instead of failing checksum or waiting. + // Forward progress resets the stall counter; the total-request ceiling + // at the loop top bounds a pathological tiny-segment server. + noProgressStreak = 0 + continue + } + const retryableIncompleteResponse = incompleteResponse as HttpStatusError + retryableIncompleteResponse.retryable = true + throw retryableIncompleteResponse + } catch (err) { + if (isAborted() || signal.aborted) { + throw err + } + const receivedBytes = this.getPartialArchiveBytes(archivePath) + if (receivedBytes === totals.totalBytes) { + return + } + noProgressStreak = receivedBytes > offset ? 0 : noProgressStreak + 1 + if (!isRetryableDownloadError(err)) { + throw err + } + // Why: give up only on a genuine stall (repeated no forward progress); + // a download that keeps advancing across drops is allowed to continue. + if (noProgressStreak >= MAX_NO_PROGRESS_ATTEMPTS) { + throw describeInterruptedDownload(err, receivedBytes, totals.totalBytes, requestCount) + } + const retryAfterMs = (err as HttpStatusError).retryAfterMs + if (retryAfterMs !== undefined && retryAfterMs > MAX_RETRY_AFTER_MS) { + const statusCode = (err as HttpStatusError).httpStatusCode + throw new Error( + `HTTP ${statusCode}; server requested retry after ${Math.ceil(retryAfterMs / 1_000)} seconds` + ) + } + console.warn( + `[speech] Model download attempt ${requestCount} failed, retrying:`, + modelId, + err + ) + await sleepUnlessAborted( + retryAfterMs ?? + DOWNLOAD_RETRY_DELAYS_MS[ + Math.min(Math.max(0, noProgressStreak - 1), DOWNLOAD_RETRY_DELAYS_MS.length - 1) + ], + signal + ) + } + } + } + private downloadFile( url: string, dest: string, @@ -316,7 +565,9 @@ export class ModelManager { modelId: string, isAborted: () => boolean, signal?: AbortSignal, - redirectCount = 0 + redirectCount = 0, + resumeOffset = 0, + totals?: DownloadTotals ): Promise { return new Promise((resolve, reject) => { if (signal?.aborted) { @@ -425,28 +676,81 @@ export class ModelManager { modelId, isAborted, signal, - redirectCount + 1 + redirectCount + 1, + resumeOffset, + totals ) .then(resolveOnce) .catch(rejectOnce) } const onResponse = (incoming: Electron.IncomingMessage): void => { const response = incoming as DownloadIncomingMessage - if (response.statusCode !== 200) { - response.resume() - rejectOnce(new Error(`HTTP ${response.statusCode}`)) + const contentLength = response.headers['content-length'] + const headerLength = Number.parseInt(getHeaderValue(contentLength) || '0', 10) + const parsedLength = + Number.isSafeInteger(headerLength) && headerLength > 0 ? headerLength : 0 + const contentRange = parseContentRange(response.headers['content-range']) + const resumed = + resumeOffset > 0 && + response.statusCode === 206 && + contentRange?.start === resumeOffset && + (parsedLength <= 0 || parsedLength === contentRange.end - contentRange.start + 1) + + if (resumeOffset > 0 && response.statusCode === 206 && !resumed) { + // Why: appending an unverified range can silently corrupt the archive; + // discard it so the canonical URL is retried from byte zero. + try { + rmSync(dest) + } catch { + // best-effort + } + const activeRequest = request + const rangeError: HttpStatusError = new Error( + `Invalid Content-Range for resume at byte ${resumeOffset}` + ) + rangeError.retryable = true + rejectOnce(rangeError) + activeRequest?.abort() return } - const contentLength = response.headers['content-length'] - const totalSize = - Number.parseInt( - Array.isArray(contentLength) ? contentLength[0] : contentLength || '0', - 10 - ) || expectedSize + if (response.statusCode !== 200 && !resumed) { + if (response.statusCode === 416) { + // Why: the server rejected our resume offset; drop the partial so + // the retry loop can restart this download from scratch. + try { + rmSync(dest) + } catch { + // best-effort + } + } + const activeRequest = request + const statusError: HttpStatusError = new Error(`HTTP ${response.statusCode}`) + statusError.httpStatusCode = response.statusCode + statusError.retryAfterMs = parseRetryAfterMs(response.headers['retry-after']) + rejectOnce(statusError) + // Why: retrying must not leave a prior error body draining without + // cancellation or idle-timeout ownership. + activeRequest?.abort() + return + } + + // Why: a 200 despite our Range request means the server restarted the + // transfer from byte zero, so the partial file must be overwritten. + const progressBase = resumed ? resumeOffset : 0 + // Why: Content-Length on a 206 describes only this segment; when + // Content-Range uses '*', retain the known complete archive size. + const totalSize = resumed + ? (contentRange?.totalBytes ?? totals?.totalBytes ?? expectedSize) + : parsedLength > 0 + ? parsedLength + : expectedSize + if (totals) { + totals.totalBytes = totalSize + } let downloaded = 0 - const fileStream = createWriteStream(dest) + const fileStream = createWriteStream(dest, { flags: resumed ? 'a' : 'w' }) const cleanupResponseProgressListener = (): void => { response.off('data', onResponseData) @@ -460,7 +764,7 @@ export class ModelManager { return } downloaded += chunk.length - const progress = Math.min(0.9, downloaded / totalSize) + const progress = Math.min(0.9, (progressBase + downloaded) / totalSize) this.updateState(modelId, 'downloading', progress) } @@ -481,6 +785,9 @@ export class ModelManager { } request = net.request({ method: 'GET', url: parsedUrl.toString() }) + if (resumeOffset > 0) { + request.setHeader('Range', `bytes=${resumeOffset}-`) + } // Why: Electron's net stack honors app proxy settings, unlike Node's // https client, but it does not expose request.setTimeout(). diff --git a/src/renderer/src/components/settings/VoiceSpeechModelSection.tsx b/src/renderer/src/components/settings/VoiceSpeechModelSection.tsx index ea96c68fd39a..acd8409e74dd 100644 --- a/src/renderer/src/components/settings/VoiceSpeechModelSection.tsx +++ b/src/renderer/src/components/settings/VoiceSpeechModelSection.tsx @@ -13,6 +13,13 @@ import { import { Cloud, Download, Trash2, Loader2, ChevronDown, Check } from 'lucide-react' import { translate } from '@/i18n/i18n' +function describeSpeechModelDownloadError(error: unknown): string { + const message = error instanceof Error ? error.message : String(error) + // Why: ipcRenderer.invoke wraps main-process rejections; strip the transport + // prefix so the toast shows only the underlying download failure. + return message.replace(/^Error invoking remote method '[^']+': (?:Error: )?/, '') +} + type VoiceSpeechModelSectionProps = { voiceSettings: VoiceSettings catalog: SpeechModelManifest[] @@ -90,16 +97,17 @@ export function VoiceSpeechModelSection({ } else if (!isDownloading) { // Why: download progress appears in this menu, so starting one should not dismiss it. event.preventDefault() - void window.api.speech - .downloadModel(manifest.id) - .catch(() => - toast.error( - translate( - 'auto.components.settings.VoicePane.cfde55c7b0', - 'Failed to download model.' - ) - ) + void window.api.speech.downloadModel(manifest.id).catch((error: unknown) => + toast.error( + translate( + 'auto.components.settings.VoicePane.cfde55c7b0', + 'Failed to download model.' + ), + // Why: the raw cause (e.g. net::ERR_CONTENT_LENGTH_MISMATCH) + // is the only diagnosable signal users can report back. + { description: describeSpeechModelDownloadError(error) } ) + ) } }} className={`group flex items-center gap-2.5 py-2.5 ${ From a03377fe0fefc698831731f636e237e85e635917 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 00:50:08 +0000 Subject: [PATCH 008/351] release: v1.4.142-rc.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index dfe8c1c51b2e..58fbc7aabefe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.142-rc.1", + "version": "1.4.142-rc.2", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 53c8a5583314c01c15e3769885f501aaf3293f29 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 14 Jul 2026 19:15:50 -0700 Subject: [PATCH 009/351] Add mobile notification opt-in onboarding (#8780) * feat(mobile): add notification opt-in onboarding * fix(mobile): deliver alerts despite desktop focus --- mobile/app/_layout.tsx | 4 + mobile/app/index.tsx | 19 +- mobile/app/notification-opt-in.tsx | 219 ++++++++++++++++++ mobile/app/notifications.tsx | 4 +- mobile/app/pair-confirm.tsx | 11 +- mobile/app/pair-scan.tsx | 11 +- .../notification-opt-in-gate.test.ts | 76 ++++++ .../notifications/notification-opt-in-gate.ts | 33 +++ mobile/src/storage/preferences.test.ts | 43 ++++ mobile/src/storage/preferences.ts | 28 ++- src/main/ipc/notifications.test.ts | 24 +- src/main/ipc/notifications.ts | 62 ++--- 12 files changed, 481 insertions(+), 53 deletions(-) create mode 100644 mobile/app/notification-opt-in.tsx create mode 100644 mobile/src/notifications/notification-opt-in-gate.test.ts create mode 100644 mobile/src/notifications/notification-opt-in-gate.ts diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index b3bd5c85b146..1059c95502a5 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -178,6 +178,10 @@ export default function RootLayout() { + diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index 45821de712b2..e26893afd84c 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -36,6 +36,7 @@ import { } from '../src/transport/client-context' import { classifyConnection } from '../src/transport/connection-health' import { subscribeToDesktopNotifications } from '../src/notifications/mobile-notifications' +import { shouldPresentNotificationOptIn } from '../src/notifications/notification-opt-in-gate' import type { ConnectionState, HostProfile } from '../src/transport/types' import { triggerMediumImpact } from '../src/platform/haptics' import { OrcaLogo } from '../src/components/OrcaLogo' @@ -317,6 +318,7 @@ export default function HomeScreen() { const [lastVisited, setLastVisited] = useState<{ hostId: string; worktreeId: string } | null>( null ) + const notificationOptInCheckedRef = useRef(false) // Why: read shared clients from the per-host store. Replaces the prior // pattern of opening N independent WebSockets here. See @@ -395,9 +397,18 @@ export default function HomeScreen() { useFocusEffect( useCallback(() => { let stale = false - void loadHosts().then((h) => { - if (!stale) { - setHosts(h) + void loadHosts().then(async (h) => { + if (stale) { + return + } + setHosts(h) + if (h.length === 0 || notificationOptInCheckedRef.current) { + return + } + notificationOptInCheckedRef.current = true + const showNotificationOptIn = await shouldPresentNotificationOptIn() + if (!stale && showNotificationOptIn) { + router.replace('/notification-opt-in') } }) void AsyncStorage.getItem('orca:last-visited-worktree').then((raw) => { @@ -419,7 +430,7 @@ export default function HomeScreen() { return () => { stale = true } - }, []) + }, [router]) ) const sortedHosts = useMemo( diff --git a/mobile/app/notification-opt-in.tsx b/mobile/app/notification-opt-in.tsx new file mode 100644 index 000000000000..41f2c2168e23 --- /dev/null +++ b/mobile/app/notification-opt-in.tsx @@ -0,0 +1,219 @@ +import { useCallback, useState } from 'react' +import { ActivityIndicator, BackHandler, Pressable, StyleSheet, Text, View } from 'react-native' +import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router' +import { SafeAreaView } from 'react-native-safe-area-context' +import { BellRing } from 'lucide-react-native' +import { OrcaLogo } from '../src/components/OrcaLogo' +import { ensureNotificationPermissions } from '../src/notifications/mobile-notifications' +import { savePushNotificationsEnabled } from '../src/storage/preferences' +import { colors, radii, spacing, typography } from '../src/theme/mobile-theme' + +export default function NotificationOptInScreen() { + const router = useRouter() + const params = useLocalSearchParams<{ hostId?: string | string[] }>() + const hostId = Array.isArray(params.hostId) ? params.hostId[0] : params.hostId + const [busyChoice, setBusyChoice] = useState<'enable' | 'skip' | null>(null) + const [error, setError] = useState(null) + + // Why: this one-time screen requires an explicit Enable or Not now choice; + // disabling back gestures alone would still leave Android hardware back open. + useFocusEffect( + useCallback(() => { + const subscription = BackHandler.addEventListener('hardwareBackPress', () => true) + return () => subscription.remove() + }, []) + ) + + const continueToApp = useCallback(() => { + router.replace(hostId ? `/h/${hostId}` : '/') + }, [hostId, router]) + + const choose = useCallback( + async (choice: 'enable' | 'skip') => { + if (busyChoice) { + return + } + setBusyChoice(choice) + setError(null) + try { + const enabled = choice === 'enable' ? await ensureNotificationPermissions() : false + await savePushNotificationsEnabled(enabled) + continueToApp() + } catch { + setError('Notification settings could not be updated. Try again.') + setBusyChoice(null) + } + }, + [busyChoice, continueToApp] + ) + + return ( + + + + Orca + + + + + + + Notifications + Stay updated while away + + Get notified on this device when an agent needs your input or finishes a task. + + + + + {error ? ( + + {error} + + ) : null} + [ + styles.primaryButton, + pressed && styles.buttonPressed, + busyChoice !== null && styles.buttonDisabled + ]} + onPress={() => void choose('enable')} + > + {busyChoice === 'enable' ? ( + + ) : ( + Enable notifications + )} + + [ + styles.secondaryButton, + pressed && styles.buttonPressed, + busyChoice !== null && styles.buttonDisabled + ]} + onPress={() => void choose('skip')} + > + {busyChoice === 'skip' ? ( + + ) : ( + Not now + )} + + You can change this any time in Settings. + + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.bgBase, + paddingHorizontal: spacing.xl + }, + brandRow: { + minHeight: 52, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + }, + brandName: { + color: colors.textPrimary, + fontSize: 17, + fontWeight: '700' + }, + content: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + paddingBottom: spacing.xl + }, + iconSurface: { + width: 64, + height: 64, + borderRadius: radii.card, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.bgRaised, + marginBottom: spacing.xl + }, + eyebrow: { + color: colors.textMuted, + fontSize: 11, + fontWeight: '600', + letterSpacing: 0.55, + textTransform: 'uppercase', + marginBottom: spacing.sm + }, + title: { + maxWidth: 420, + color: colors.textPrimary, + fontSize: 26, + fontWeight: '700', + letterSpacing: -0.3, + textAlign: 'center' + }, + body: { + maxWidth: 420, + color: colors.textSecondary, + fontSize: typography.bodySize, + lineHeight: 21, + textAlign: 'center', + marginTop: spacing.md + }, + footer: { + width: '100%', + maxWidth: 420, + alignSelf: 'center', + paddingBottom: spacing.lg + }, + primaryButton: { + height: 44, + alignItems: 'center', + justifyContent: 'center', + borderRadius: radii.button, + backgroundColor: colors.surfaceBright + }, + primaryButtonText: { + color: colors.bgBase, + fontSize: typography.bodySize, + fontWeight: '600' + }, + secondaryButton: { + height: 44, + alignItems: 'center', + justifyContent: 'center', + borderRadius: radii.button, + marginTop: spacing.xs + }, + secondaryButtonText: { + color: colors.textSecondary, + fontSize: typography.bodySize, + fontWeight: '500' + }, + buttonPressed: { + opacity: 0.72 + }, + buttonDisabled: { + opacity: 0.58 + }, + footerNote: { + color: colors.textMuted, + fontSize: typography.metaSize, + lineHeight: 18, + textAlign: 'center', + marginTop: spacing.sm + }, + error: { + color: colors.statusRed, + fontSize: typography.metaSize, + lineHeight: 18, + textAlign: 'center', + marginBottom: spacing.sm + } +}) diff --git a/mobile/app/notifications.tsx b/mobile/app/notifications.tsx index f7922074d6e7..4adacfc016fc 100644 --- a/mobile/app/notifications.tsx +++ b/mobile/app/notifications.tsx @@ -69,7 +69,7 @@ export default function NotificationsScreen() { const notificationsBlocked = permissionState.status === 'denied' const hint = notificationsBlocked ? 'Notifications are disabled in system settings.' - : 'Receive notifications when an agent task completes on your desktop.' + : 'Get notified on this device when an agent needs your input or finishes a task.' return ( @@ -82,7 +82,7 @@ export default function NotificationsScreen() { - Push Notifications + Agent notifications ({ + readPushNotificationsPreference: vi.fn(), + savePushNotificationsEnabled: vi.fn() +})) + +vi.mock('./mobile-notifications', () => ({ + getNotificationPermissionState: vi.fn() +})) + +describe('notification opt-in gate', () => { + beforeEach(() => { + vi.mocked(readPushNotificationsPreference).mockReset() + vi.mocked(savePushNotificationsEnabled).mockReset() + vi.mocked(getNotificationPermissionState).mockReset() + }) + + it('presents only when the local preference and system decision are both unset', async () => { + vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: true }) + vi.mocked(getNotificationPermissionState).mockResolvedValue({ + granted: false, + status: 'undetermined', + canAskAgain: true + }) + + await expect(shouldPresentNotificationOptIn()).resolves.toBe(true) + expect(savePushNotificationsEnabled).not.toHaveBeenCalled() + }) + + it.each([true, false])('preserves an existing %s mobile preference', async (value) => { + vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value, loaded: true }) + + await expect(shouldPresentNotificationOptIn()).resolves.toBe(false) + expect(getNotificationPermissionState).not.toHaveBeenCalled() + }) + + it('adopts existing system authorization without prompting', async () => { + vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: true }) + vi.mocked(getNotificationPermissionState).mockResolvedValue({ + granted: true, + status: 'granted', + canAskAgain: true + }) + + await expect(shouldPresentNotificationOptIn()).resolves.toBe(false) + expect(savePushNotificationsEnabled).toHaveBeenCalledWith(true) + }) + + it('skips the gate when iOS has already denied permission', async () => { + vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: true }) + vi.mocked(getNotificationPermissionState).mockResolvedValue({ + granted: false, + status: 'denied', + canAskAgain: false + }) + + await expect(shouldPresentNotificationOptIn()).resolves.toBe(false) + expect(savePushNotificationsEnabled).toHaveBeenCalledWith(false) + }) + + it('does not block startup when storage or permission checks fail', async () => { + vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: false }) + await expect(shouldPresentNotificationOptIn()).resolves.toBe(false) + + vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: true }) + vi.mocked(getNotificationPermissionState).mockRejectedValue(new Error('unavailable')) + await expect(shouldPresentNotificationOptIn()).resolves.toBe(false) + }) +}) diff --git a/mobile/src/notifications/notification-opt-in-gate.ts b/mobile/src/notifications/notification-opt-in-gate.ts new file mode 100644 index 000000000000..9d7b4eed6cc1 --- /dev/null +++ b/mobile/src/notifications/notification-opt-in-gate.ts @@ -0,0 +1,33 @@ +import { + readPushNotificationsPreference, + savePushNotificationsEnabled +} from '../storage/preferences' +import { getNotificationPermissionState } from './mobile-notifications' + +export async function shouldPresentNotificationOptIn(): Promise { + const preference = await readPushNotificationsPreference() + if (!preference.loaded || preference.value !== null) { + return false + } + + try { + const permission = await getNotificationPermissionState() + if (permission.granted) { + // Why: an already-authorized device should inherit the useful default + // without seeing an onboarding decision it has effectively made. + await savePushNotificationsEnabled(true) + return false + } + if (permission.status === 'denied' || !permission.canAskAgain) { + // Why: iOS cannot show its authorization prompt again, so a blocking + // onboarding screen would be a dead end; Settings remains the recovery. + await savePushNotificationsEnabled(false) + return false + } + return permission.status === 'undetermined' + } catch { + // Why: permission or persistence failures must not trap startup behind a + // decision screen whose result cannot be applied reliably. + return false + } +} diff --git a/mobile/src/storage/preferences.test.ts b/mobile/src/storage/preferences.test.ts index 1e5aee95de1a..f8e07fb4d98a 100644 --- a/mobile/src/storage/preferences.test.ts +++ b/mobile/src/storage/preferences.test.ts @@ -10,11 +10,14 @@ import { clampHostSidebarWidth, loadDisabledTerminalLiveInputHandles, loadHostSidebarWidth, + loadPushNotificationsEnabled, loadTerminalAutocompleteEnabled, loadTerminalLinkOpenMode, + readPushNotificationsPreference, readDisabledTerminalLiveInputHandlesPreference, saveDisabledTerminalLiveInputHandles, saveHostSidebarWidth, + savePushNotificationsEnabled, saveTerminalAutocompleteEnabled, saveTerminalLinkOpenMode } from './preferences' @@ -26,6 +29,46 @@ vi.mock('@react-native-async-storage/async-storage', () => ({ } })) +describe('push notification preference', () => { + beforeEach(() => { + vi.mocked(AsyncStorage.getItem).mockReset() + vi.mocked(AsyncStorage.setItem).mockReset() + }) + + it('distinguishes an unset preference from an explicit disabled choice', async () => { + vi.mocked(AsyncStorage.getItem).mockResolvedValue(null) + await expect(readPushNotificationsPreference()).resolves.toEqual({ + value: null, + loaded: true + }) + await expect(loadPushNotificationsEnabled()).resolves.toBe(false) + + vi.mocked(AsyncStorage.getItem).mockResolvedValue('false') + await expect(readPushNotificationsPreference()).resolves.toEqual({ + value: false, + loaded: true + }) + }) + + it('reports storage failures without enabling notifications', async () => { + vi.mocked(AsyncStorage.getItem).mockRejectedValue(new Error('storage unavailable')) + + await expect(readPushNotificationsPreference()).resolves.toEqual({ + value: null, + loaded: false + }) + await expect(loadPushNotificationsEnabled()).resolves.toBe(false) + }) + + it('persists the onboarding decision in the existing mobile toggle', async () => { + await savePushNotificationsEnabled(true) + expect(AsyncStorage.setItem).toHaveBeenCalledWith('orca:pushNotificationsEnabled', 'true') + + await savePushNotificationsEnabled(false) + expect(AsyncStorage.setItem).toHaveBeenCalledWith('orca:pushNotificationsEnabled', 'false') + }) +}) + describe('terminal autocomplete preference', () => { beforeEach(() => { vi.mocked(AsyncStorage.getItem).mockReset() diff --git a/mobile/src/storage/preferences.ts b/mobile/src/storage/preferences.ts index d5b0db3a6e70..5173ac5bc8ad 100644 --- a/mobile/src/storage/preferences.ts +++ b/mobile/src/storage/preferences.ts @@ -3,23 +3,29 @@ import AsyncStorage from '@react-native-async-storage/async-storage' const PINS_PREFIX = 'orca:pins:' const NOTIF_KEY = 'orca:pushNotificationsEnabled' -// Why: default-off so the iOS notification permission prompt never -// fires until the user explicitly opts in via Settings → Notifications. -// Apple's review guideline 4.5.4 and HIG both prefer user-initiated -// permission prompts; default-on would fire the prompt the moment the -// desktop sent its first notification, which can read as unsolicited. -export async function loadPushNotificationsEnabled(): Promise { +export type PushNotificationsPreference = { + readonly value: boolean | null + readonly loaded: boolean +} + +// Why: null distinguishes people who have never made the one-time onboarding +// decision from people who explicitly chose Not now or disabled notifications. +export async function readPushNotificationsPreference(): Promise { try { const raw = await AsyncStorage.getItem(NOTIF_KEY) - if (raw === null) { - return false - } - return raw === 'true' + return { value: raw === null ? null : raw === 'true', loaded: true } } catch { - return false + return { value: null, loaded: false } } } +// Why: default-off prevents background notification events from opening the +// system prompt; only the onboarding CTA or Settings switch requests permission. +export async function loadPushNotificationsEnabled(): Promise { + const preference = await readPushNotificationsPreference() + return preference.value ?? false +} + export async function savePushNotificationsEnabled(enabled: boolean): Promise { await AsyncStorage.setItem(NOTIF_KEY, String(enabled)) } diff --git a/src/main/ipc/notifications.test.ts b/src/main/ipc/notifications.test.ts index 8fb5566a8955..d7930066ad7d 100644 --- a/src/main/ipc/notifications.test.ts +++ b/src/main/ipc/notifications.test.ts @@ -899,7 +899,7 @@ describe('registerNotificationHandlers', () => { expect(dispatchMobileNotification).not.toHaveBeenCalled() }) - it('does not dispatch mobile notifications for focused active-worktree notifications', async () => { + it('dispatches one mobile notification when the active worktree is focused on desktop', async () => { getAllWindowsMock.mockReturnValue([ { isDestroyed: () => false, @@ -922,17 +922,25 @@ describe('registerNotificationHandlers', () => { ) const handler = getDispatchHandler() - expect( - await handler( - {}, - { source: 'agent-task-complete', worktreeId: 'repo::wt1', isActiveWorktree: true } - ) - ).toEqual({ + const focusedNotification = { + source: 'agent-task-complete' as const, + worktreeId: 'repo::wt1', + isActiveWorktree: true + } + expect(await handler({}, focusedNotification)).toEqual({ + delivered: false, + reason: 'suppressed-focus' + }) + expect(await handler({}, focusedNotification)).toEqual({ delivered: false, reason: 'suppressed-focus' }) - expect(dispatchMobileNotification).not.toHaveBeenCalled() + expect(dispatchMobileNotification).toHaveBeenCalledTimes(1) + expect(dispatchMobileNotification).toHaveBeenCalledWith( + expect.objectContaining({ source: 'agent-task-complete', worktreeId: 'repo::wt1' }) + ) + expect(notificationCtorMock).not.toHaveBeenCalled() }) it('does not dispatch mobile notifications for cooldown-suppressed bursts', async () => { diff --git a/src/main/ipc/notifications.ts b/src/main/ipc/notifications.ts index 47baa7e77251..4eb8938f2b4c 100644 --- a/src/main/ipc/notifications.ts +++ b/src/main/ipc/notifications.ts @@ -312,8 +312,24 @@ function pruneRecentNotifications(recentNotifications: Map, now: } } +function reserveNotificationCooldown( + recentNotifications: Map, + dedupeKey: string, + now: number +): boolean { + const lastSentAt = recentNotifications.get(dedupeKey) ?? 0 + if (now - lastSentAt < NOTIFICATION_COOLDOWN_MS) { + return false + } + recentNotifications.delete(dedupeKey) + recentNotifications.set(dedupeKey, now) + pruneRecentNotifications(recentNotifications, now) + return true +} + export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntimeService): void { - const recentNotifications = new Map() + const recentDesktopNotifications = new Map() + const recentMobileNotifications = new Map() // Why: handler registration marks a fresh session — permission evidence // from a previous registration must not leak into the new one. lastObservedDeliveryOutcome = null @@ -441,6 +457,24 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime return { delivered: false, reason: 'source-disabled' } } + const notificationOptions = buildNotificationOptions(args) + + // Why: desktop focus only means this computer has the worktree visible; + // the paired phone may be locked or elsewhere and still needs the alert. + if (runtime && args.source !== 'test') { + const dedupeKey = args.worktreeId ?? args.worktreeLabel ?? 'global' + if (reserveNotificationCooldown(recentMobileNotifications, dedupeKey, Date.now())) { + runtime.dispatchMobileNotification({ + type: 'notification', + source: args.source, + title: notificationOptions.title, + body: notificationOptions.body, + worktreeId: args.worktreeId, + ...(args.notificationId ? { notificationId: args.notificationId } : {}) + }) + } + } + const browserWindow = BrowserWindow.getAllWindows().find((window) => !window.isDestroyed()) ?? null if ( @@ -458,33 +492,9 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime // Dedupe by worktree, not by source — an agent finishing and a terminal bell // often fire within the same data chunk so only the first one should surface. const dedupeKey = args.worktreeId ?? args.worktreeLabel ?? 'global' - const now = Date.now() - const lastSentAt = recentNotifications.get(dedupeKey) ?? 0 - if (now - lastSentAt < NOTIFICATION_COOLDOWN_MS) { + if (!reserveNotificationCooldown(recentDesktopNotifications, dedupeKey, Date.now())) { return { delivered: false, reason: 'cooldown' } } - recentNotifications.delete(dedupeKey) - recentNotifications.set(dedupeKey, now) - - // Why: a storm across many worktrees should not make every - // notification dispatch scan an ever-growing cooldown table. - pruneRecentNotifications(recentNotifications, now) - } - - const notificationOptions = buildNotificationOptions(args) - - // Why: paired mobile clients should follow the same user-facing - // notification gates as desktop delivery, while still working on hosts - // where Electron native notifications are unavailable. - if (runtime && args.source !== 'test') { - runtime.dispatchMobileNotification({ - type: 'notification', - source: args.source, - title: notificationOptions.title, - body: notificationOptions.body, - worktreeId: args.worktreeId, - ...(args.notificationId ? { notificationId: args.notificationId } : {}) - }) } if (!Notification.isSupported()) { From 70e16cb9003603c6d27f71ae0a22079979033edb Mon Sep 17 00:00:00 2001 From: iridite Date: Wed, 15 Jul 2026 10:19:43 +0800 Subject: [PATCH 010/351] fix: stop renderer heap leak from unhandled terminal teardown rejections (#8279) Two renderer paths let Promise rejections escape unhandled, where the crash-breadcrumb system retains them and grows the heap until the renderer white-screens (#8260): - The terminal file-link provider fires a `void Promise.all(...)` whose async mappers probe path existence over SSH. On disconnect these reject with "Remote connection dropped/reconnecting"; add a `.catch` that resolves the callback with no links so the rejection is handled. - "ResizeObserver loop completed" is a benign Chromium quirk; suppress it in crash-diagnostics so it stops filling the breadcrumb buffer and inflating the renderer error count. Scope note: the original fix for #8260 also proposed Windows pty:kill signal handling (killPtyProcess / isPtyAlreadyGoneError) and a ResizeObserver double-rAF. Those landed on main independently (Windows kill handling by v1.4.135; the pane-title-overlay rect-equality guard from #2756 already breaks the loop at the source), so this change carries only the two guards still missing upstream. Co-authored-by: Yixian HU Co-authored-by: Claude --- .../terminal-link-handlers.test.ts | 15 +++++++ .../terminal-pane/terminal-link-handlers.ts | 40 +++++++++++-------- .../src/lib/crash-diagnostics.test.ts | 14 +++++++ src/renderer/src/lib/crash-diagnostics.ts | 7 ++++ 4 files changed, 60 insertions(+), 16 deletions(-) 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 3840c7c962ab..dbd2057e3098 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 @@ -1217,6 +1217,21 @@ describe('createFilePathLinkProvider range bounds', () => { ) }) + it('recovers with no links when a path-existence probe rejects (SSH teardown)', async () => { + // Regression: a rejected probe used to escape the void Promise.all as an + // unhandled rejection the crash-breadcrumb buffer retained, leaking heap (#8260). + const shellPathExists = vi.mocked(window.api.shell.pathExists) + shellPathExists.mockRejectedValueOnce(new Error('Remote connection dropped/reconnecting')) + const { provider } = createProviderSetup([makeBufferLine('CLAUDE.md')], new Map()) + + const links = await new Promise((resolve) => { + provider.provideLinks(1, (provided) => resolve(provided ?? [])) + }) + + expect(links).toEqual([]) + expect(shellPathExists).toHaveBeenCalled() + }) + it('shows switch and external-open hint for known worktree root hover', async () => { setPlatform('Macintosh') storeState.worktreesByRepo = { 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 462dbdeeedce..8ca9fe0bef15 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts @@ -214,23 +214,31 @@ export function createFilePathLinkProvider( } ) ) - ).then((resolvedLinks) => { - const latestFingerprints = new Set( - buildCandidateLogicalLinesForBufferPosition(buffer, bufferLineNumber).map( - (logicalLine) => logicalLine.fingerprint + ) + .then((resolvedLinks) => { + const latestFingerprints = new Set( + buildCandidateLogicalLinesForBufferPosition(buffer, bufferLineNumber).map( + (logicalLine) => logicalLine.fingerprint + ) ) - ) - const providedLinks = resolvedLinks.filter( - (link): link is ProvidedFileLink => link !== null - ) - const links = preferLongestNonOverlappingLinks(providedLinks) - .filter(({ logicalLine }) => latestFingerprints.has(logicalLine.fingerprint)) - .map(({ link }) => link) - if (providedLinks.length > 0 && links.length === 0) { - return - } - callback(links.length > 0 ? links : undefined) - }) + const providedLinks = resolvedLinks.filter( + (link): link is ProvidedFileLink => link !== null + ) + const links = preferLongestNonOverlappingLinks(providedLinks) + .filter(({ logicalLine }) => latestFingerprints.has(logicalLine.fingerprint)) + .map(({ link }) => link) + if (providedLinks.length > 0 && links.length === 0) { + return + } + callback(links.length > 0 ? links : undefined) + }) + .catch(() => { + // Why: remote path-existence probes reject with "Remote connection + // dropped/reconnecting" during SSH teardown. Without a catch the + // rejected Promise.all is unhandled and the crash-breadcrumb buffer + // retains it, growing the renderer heap until it crashes (#8260). + callback(undefined) + }) } } } diff --git a/src/renderer/src/lib/crash-diagnostics.test.ts b/src/renderer/src/lib/crash-diagnostics.test.ts index 8abe9aaf9eaa..d093c8faf61c 100644 --- a/src/renderer/src/lib/crash-diagnostics.test.ts +++ b/src/renderer/src/lib/crash-diagnostics.test.ts @@ -116,6 +116,20 @@ describe('renderer crash diagnostics', () => { }) }) + it('suppresses benign ResizeObserver loop errors instead of recording them', () => { + diagnostics.installRendererCrashDiagnostics() + recordBreadcrumbMock.mockClear() + + const preventDefault = vi.fn() + listeners.get('error')?.[0]?.({ + message: 'ResizeObserver loop completed with undelivered notifications.', + preventDefault + }) + + expect(preventDefault).toHaveBeenCalledTimes(1) + expect(recordBreadcrumbMock).not.toHaveBeenCalled() + }) + it('disposes global listeners and the memory interval', () => { diagnostics.installRendererCrashDiagnostics() diff --git a/src/renderer/src/lib/crash-diagnostics.ts b/src/renderer/src/lib/crash-diagnostics.ts index bb0875f14336..b718917d9ed0 100644 --- a/src/renderer/src/lib/crash-diagnostics.ts +++ b/src/renderer/src/lib/crash-diagnostics.ts @@ -64,6 +64,13 @@ if (typeof import.meta !== 'undefined' && import.meta.hot) { } function recordRendererError(event: ErrorEvent): void { + // Why: "ResizeObserver loop completed" is a benign, self-resolving Chromium + // quirk. Recording it fills the breadcrumb buffer and inflates the error + // count without diagnostic value, contributing to renderer heap growth (#8260). + if (/ResizeObserver loop/i.test(event.message)) { + event.preventDefault() + return + } recordRendererCrashBreadcrumb( 'renderer_error', compactBreadcrumbData({ From d9f7fd08f71646d4d6d363b3a07f0634746c5c0d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 14 Jul 2026 19:20:36 -0700 Subject: [PATCH 011/351] fix(mobile): harden notification opt-in onboarding (#8792) --- mobile/app/notification-opt-in.tsx | 139 ++++++++++-------- mobile/app/notifications.tsx | 3 +- .../mobile-notifications.test.ts | 30 +++- .../src/notifications/mobile-notifications.ts | 7 +- .../notification-opt-in-gate.test.ts | 22 ++- .../notifications/notification-opt-in-gate.ts | 3 + 6 files changed, 137 insertions(+), 67 deletions(-) diff --git a/mobile/app/notification-opt-in.tsx b/mobile/app/notification-opt-in.tsx index 41f2c2168e23..a4476687e094 100644 --- a/mobile/app/notification-opt-in.tsx +++ b/mobile/app/notification-opt-in.tsx @@ -1,5 +1,13 @@ import { useCallback, useState } from 'react' -import { ActivityIndicator, BackHandler, Pressable, StyleSheet, Text, View } from 'react-native' +import { + ActivityIndicator, + BackHandler, + Pressable, + ScrollView, + StyleSheet, + Text, + View +} from 'react-native' import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router' import { SafeAreaView } from 'react-native-safe-area-context' import { BellRing } from 'lucide-react-native' @@ -49,63 +57,65 @@ export default function NotificationOptInScreen() { return ( - - - Orca - - - - - + + + + Orca - Notifications - Stay updated while away - - Get notified on this device when an agent needs your input or finishes a task. - - - - {error ? ( - - {error} + + + + + Notifications + Stay updated while away + + Get notified on this device when an agent needs your input or finishes a task. - ) : null} - [ - styles.primaryButton, - pressed && styles.buttonPressed, - busyChoice !== null && styles.buttonDisabled - ]} - onPress={() => void choose('enable')} - > - {busyChoice === 'enable' ? ( - - ) : ( - Enable notifications - )} - - [ - styles.secondaryButton, - pressed && styles.buttonPressed, - busyChoice !== null && styles.buttonDisabled - ]} - onPress={() => void choose('skip')} - > - {busyChoice === 'skip' ? ( - - ) : ( - Not now - )} - - You can change this any time in Settings. - + + + + {error ? ( + + {error} + + ) : null} + [ + styles.primaryButton, + pressed && styles.buttonPressed, + busyChoice !== null && styles.buttonDisabled + ]} + onPress={() => void choose('enable')} + > + {busyChoice === 'enable' ? ( + + ) : ( + Enable notifications + )} + + [ + styles.secondaryButton, + pressed && styles.buttonPressed, + busyChoice !== null && styles.buttonDisabled + ]} + onPress={() => void choose('skip')} + > + {busyChoice === 'skip' ? ( + + ) : ( + Not now + )} + + You can change this any time in Settings. + + ) } @@ -116,6 +126,11 @@ const styles = StyleSheet.create({ backgroundColor: colors.bgBase, paddingHorizontal: spacing.xl }, + // Why: this decision screen cannot be dismissed with Back, so every action + // must remain reachable in landscape and with accessibility text scaling. + scrollContent: { + flexGrow: 1 + }, brandRow: { minHeight: 52, flexDirection: 'row', @@ -128,10 +143,10 @@ const styles = StyleSheet.create({ fontWeight: '700' }, content: { - flex: 1, + flexGrow: 1, alignItems: 'center', justifyContent: 'center', - paddingBottom: spacing.xl + paddingVertical: spacing.xl }, iconSurface: { width: 64, @@ -173,11 +188,12 @@ const styles = StyleSheet.create({ paddingBottom: spacing.lg }, primaryButton: { - height: 44, + minHeight: 44, alignItems: 'center', justifyContent: 'center', borderRadius: radii.button, - backgroundColor: colors.surfaceBright + backgroundColor: colors.surfaceBright, + paddingVertical: spacing.sm }, primaryButtonText: { color: colors.bgBase, @@ -185,11 +201,12 @@ const styles = StyleSheet.create({ fontWeight: '600' }, secondaryButton: { - height: 44, + minHeight: 44, alignItems: 'center', justifyContent: 'center', borderRadius: radii.button, - marginTop: spacing.xs + marginTop: spacing.xs, + paddingVertical: spacing.sm }, secondaryButtonText: { color: colors.textSecondary, diff --git a/mobile/app/notifications.tsx b/mobile/app/notifications.tsx index 4adacfc016fc..d9696251a948 100644 --- a/mobile/app/notifications.tsx +++ b/mobile/app/notifications.tsx @@ -17,7 +17,8 @@ import { const DEFAULT_PERMISSION_STATE: NotificationPermissionState = { granted: false, status: 'undetermined', - canAskAgain: true + canAskAgain: true, + authorizationReflectsUserChoice: false } export default function NotificationsScreen() { diff --git a/mobile/src/notifications/mobile-notifications.test.ts b/mobile/src/notifications/mobile-notifications.test.ts index 2d6cf868461f..b1f7c89578ec 100644 --- a/mobile/src/notifications/mobile-notifications.test.ts +++ b/mobile/src/notifications/mobile-notifications.test.ts @@ -1,6 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import * as Notifications from 'expo-notifications' +import { Platform } from 'react-native' import { + getNotificationPermissionState, setScheduledNotificationsMaxForTests, subscribeToDesktopNotifications } from './mobile-notifications' @@ -18,7 +20,7 @@ vi.mock('expo-notifications', () => ({ })) vi.mock('react-native', () => ({ - Platform: { OS: 'ios' } + Platform: { OS: 'ios', Version: 18 } })) // Why: mobile-notifications now persists the catch-up watermark to @@ -35,6 +37,32 @@ vi.mock('../storage/preferences', () => ({ loadPushNotificationsEnabled: vi.fn() })) +beforeEach(() => { + Object.assign(Platform, { OS: 'ios', Version: 18 }) +}) + +describe('getNotificationPermissionState', () => { + it.each([ + { os: 'android', version: 32, expected: false }, + { os: 'android', version: 33, expected: true }, + { os: 'ios', version: 18, expected: true } + ])( + 'reports whether a granted $os $version authorization reflects user choice', + async ({ os, version, expected }) => { + Object.assign(Platform, { OS: os, Version: version }) + vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ + status: 'granted', + canAskAgain: true + } as never) + + await expect(getNotificationPermissionState()).resolves.toMatchObject({ + granted: true, + authorizationReflectsUserChoice: expected + }) + } + ) +}) + describe('subscribeToDesktopNotifications', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/mobile/src/notifications/mobile-notifications.ts b/mobile/src/notifications/mobile-notifications.ts index f91e59813662..d38663931167 100644 --- a/mobile/src/notifications/mobile-notifications.ts +++ b/mobile/src/notifications/mobile-notifications.ts @@ -82,6 +82,7 @@ export type NotificationPermissionState = { granted: boolean status: string canAskAgain: boolean + authorizationReflectsUserChoice: boolean } export async function getNotificationPermissionState(): Promise { @@ -89,7 +90,11 @@ export async function getNotificationPermissionState(): Promise= 33) } } diff --git a/mobile/src/notifications/notification-opt-in-gate.test.ts b/mobile/src/notifications/notification-opt-in-gate.test.ts index fb5a95ac6422..ded3d7eca398 100644 --- a/mobile/src/notifications/notification-opt-in-gate.test.ts +++ b/mobile/src/notifications/notification-opt-in-gate.test.ts @@ -27,7 +27,8 @@ describe('notification opt-in gate', () => { vi.mocked(getNotificationPermissionState).mockResolvedValue({ granted: false, status: 'undetermined', - canAskAgain: true + canAskAgain: true, + authorizationReflectsUserChoice: false }) await expect(shouldPresentNotificationOptIn()).resolves.toBe(true) @@ -46,19 +47,34 @@ describe('notification opt-in gate', () => { vi.mocked(getNotificationPermissionState).mockResolvedValue({ granted: true, status: 'granted', - canAskAgain: true + canAskAgain: true, + authorizationReflectsUserChoice: true }) await expect(shouldPresentNotificationOptIn()).resolves.toBe(false) expect(savePushNotificationsEnabled).toHaveBeenCalledWith(true) }) + it('still presents when a pre-Android 13 default grant is not an opt-in decision', async () => { + vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: true }) + vi.mocked(getNotificationPermissionState).mockResolvedValue({ + granted: true, + status: 'granted', + canAskAgain: true, + authorizationReflectsUserChoice: false + }) + + await expect(shouldPresentNotificationOptIn()).resolves.toBe(true) + expect(savePushNotificationsEnabled).not.toHaveBeenCalled() + }) + it('skips the gate when iOS has already denied permission', async () => { vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: true }) vi.mocked(getNotificationPermissionState).mockResolvedValue({ granted: false, status: 'denied', - canAskAgain: false + canAskAgain: false, + authorizationReflectsUserChoice: false }) await expect(shouldPresentNotificationOptIn()).resolves.toBe(false) diff --git a/mobile/src/notifications/notification-opt-in-gate.ts b/mobile/src/notifications/notification-opt-in-gate.ts index 9d7b4eed6cc1..a909fad16d70 100644 --- a/mobile/src/notifications/notification-opt-in-gate.ts +++ b/mobile/src/notifications/notification-opt-in-gate.ts @@ -13,6 +13,9 @@ export async function shouldPresentNotificationOptIn(): Promise { try { const permission = await getNotificationPermissionState() if (permission.granted) { + if (!permission.authorizationReflectsUserChoice) { + return true + } // Why: an already-authorized device should inherit the useful default // without seeing an onboarding decision it has effectively made. await savePushNotificationsEnabled(true) From 42ee45f392e431380e4bdadb8de2128791ec2b59 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 14 Jul 2026 19:36:47 -0700 Subject: [PATCH 012/351] Fix restored mobile terminals and workspace visibility parity (#8789) * Fix mobile cutover activation and usage refresh loops Co-authored-by: Orca * Fix restored mobile terminal state parity Co-authored-by: Orca * Fix migrated PTY workspace attribution Co-authored-by: Orca * Fix overlapping mobile terminal surface swaps Co-authored-by: Orca --------- Co-authored-by: Orca --- .../app/h/[hostId]/session/[worktreeId].tsx | 147 +++++---- .../session/mobile-session-route-helpers.ts | 28 ++ .../mobile-session-startup-source.test.ts | 17 +- .../mobile-session-tab-activation.test.ts | 75 +++++ .../session/mobile-session-tab-activation.ts | 90 ++++++ .../mobile-terminal-diagnostics.test.ts | 46 +++ .../session/mobile-terminal-diagnostics.ts | 291 ++++++++++++++++++ .../session/session-tab-snapshot-gate.test.ts | 13 + .../src/session/session-tab-snapshot-gate.ts | 6 + mobile/src/terminal/terminal-webview-html.ts | 31 +- .../terminal-webview-init-surface.test.ts | 132 ++++++++ .../terminal-webview-surface-swap-injected.ts | 49 +++ src/main/rate-limits/service.test.ts | 51 +++ src/main/rate-limits/service.ts | 11 + src/main/runtime/orca-runtime.test.ts | 182 +++++++++++ src/main/runtime/orca-runtime.ts | 96 +++++- ...ime-request-connection.integration.test.ts | 5 + src/main/runtime/rpc/methods/accounts.test.ts | 58 ++++ src/main/runtime/rpc/methods/accounts.ts | 8 +- 19 files changed, 1240 insertions(+), 96 deletions(-) create mode 100644 mobile/src/session/mobile-session-tab-activation.test.ts create mode 100644 mobile/src/session/mobile-session-tab-activation.ts create mode 100644 mobile/src/session/mobile-terminal-diagnostics.test.ts create mode 100644 mobile/src/session/mobile-terminal-diagnostics.ts create mode 100644 mobile/src/terminal/terminal-webview-init-surface.test.ts create mode 100644 mobile/src/terminal/terminal-webview-surface-swap-injected.ts create mode 100644 src/main/runtime/rpc/methods/accounts.test.ts diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index b4e7207d6a58..d4d1b35896fd 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -184,6 +184,7 @@ import { useLiveWorktreeName } from '../../../../src/session/use-live-worktree-n import { acceptSessionSnapshot, applyClosedTabTombstones, + confirmsMirroredTabSelection, type AppliedSnapshotMarker } from '../../../../src/session/session-tab-snapshot-gate' import { @@ -197,8 +198,14 @@ import { isDictationSetupRequiredError } from '../../../../src/dictation/mobile-dictation-setup' import { TerminalPaneView } from '../../../../src/session/TerminalPaneView' +import { + activateMobileSessionTab, + focusMobileTerminal +} from '../../../../src/session/mobile-session-tab-activation' +import { MobileTerminalDiagnostics } from '../../../../src/session/mobile-terminal-diagnostics' import { getRepoIdFromMobileWorktreeId, + getActiveTabIdForHandle, isFileExistsErrorMessage, isGestureMouseTrackingMode, MOBILE_SESSION_STATUS_LABELS, @@ -206,7 +213,8 @@ import { TERMINAL_GESTURE_INPUT_FLUSH_DELAY_MS, TERMINAL_GESTURE_INPUT_MAX_PENDING_SEQUENCES, TERMINAL_GESTURE_INPUT_MAX_QUEUE_AGE_MS, - TERMINAL_GESTURE_INPUT_REFILL_PER_SECOND + TERMINAL_GESTURE_INPUT_REFILL_PER_SECOND, + updateTerminalCwdFromStreamEvent } from '../../../../src/session/mobile-session-route-helpers' import { resolveMarkdownFloatingActionsBottom } from '../../../../src/session/markdown-floating-actions-layout' import { resolveTabStripScrollOffset } from '../../../../src/session/tab-strip-scroll' @@ -245,21 +253,6 @@ type TerminalLiveAccessoryInput = ReturnType => - tab.type === 'terminal' && tab.terminal === terminalHandle - )?.id ?? terminalHandle - ) -} - function MarkdownReader({ documentId, doc, @@ -800,21 +793,6 @@ function FileReader({ return renderSourceText(doc.content) } -function updateTerminalCwdFromStreamEvent( - handle: string, - data: Record, - terminalCwd: Map -): void { - if (!('cwd' in data)) { - return - } - if (typeof data.cwd === 'string' && data.cwd.trim().length > 0) { - terminalCwd.set(handle, data.cwd) - return - } - terminalCwd.delete(handle) -} - export default function SessionScreen() { const { hostId, @@ -1031,6 +1009,7 @@ export default function SessionScreen() { const terminalUnsubsRef = useRef void>>(new Map()) const subscribingHandlesRef = useRef>(new Set()) const initializedHandlesRef = useRef>(new Set()) + const terminalDiagnosticsRef = useRef(new MobileTerminalDiagnostics()) // Why: WebViews load xterm.js from CDN asynchronously. Hidden WebViews // (opacity:0) may have delayed JS execution on iOS. We must not subscribe // until the WebView has fired web-ready, otherwise init() messages queue @@ -1315,6 +1294,7 @@ export default function SessionScreen() { terminalUnsubsRef.current.get(handle)?.() terminalUnsubsRef.current.delete(handle) subscribingHandlesRef.current.delete(handle) + terminalDiagnosticsRef.current.terminalUnsubscribed(handle) subscribeSeqRef.current.set(handle, (subscribeSeqRef.current.get(handle) ?? 0) + 1) // Why: a fresh subscription will land on a new server-side state machine // run (or the same one with a higher seq); reset the high-water mark so @@ -1329,6 +1309,7 @@ export default function SessionScreen() { terminalUnsubsRef.current.clear() subscribingHandlesRef.current.clear() initializedHandlesRef.current.clear() + terminalDiagnosticsRef.current.clearTerminalCache() webReadyHandlesRef.current.clear() subscribeSeqRef.current.clear() layoutSeqRef.current.clear() @@ -1350,6 +1331,7 @@ export default function SessionScreen() { const dims = await getTerminalRef(handle)?.measureFitDimensions( terminalFrameHeightRef.current || undefined ) + terminalDiagnosticsRef.current.viewportMeasured(handle, dims, terminalFrameHeightRef.current) if (dims) { viewportRef.current = dims viewportMeasuredRef.current = true @@ -1360,25 +1342,34 @@ export default function SessionScreen() { const subscribeToTerminal = useCallback( (handle: string) => { + const diagnostics = terminalDiagnosticsRef.current + const logSkippedGate = (reason: string) => + diagnostics.streamSkipped(handle, reason, handle === activeHandleRef.current) if (!client) { + logSkippedGate('no-client') return } if (terminalUnsubsRef.current.has(handle)) { + logSkippedGate('already-subscribed') return } if (subscribingHandlesRef.current.has(handle)) { + logSkippedGate('subscribe-in-flight') return } if (!getTerminalRef(handle)) { + logSkippedGate('no-webview-ref') return } if (!webReadyHandlesRef.current.has(handle)) { + logSkippedGate('webview-not-ready') return } subscribingHandlesRef.current.add(handle) const seq = (subscribeSeqRef.current.get(handle) ?? 0) + 1 subscribeSeqRef.current.set(handle, seq) + diagnostics.streamArmed(handle, seq, viewportRef.current) // Why: server handles auto-fit on subscribe — no terminal.focus call needed. // The viewport is embedded in the subscribe params so the server resizes @@ -1397,6 +1388,7 @@ export default function SessionScreen() { return } const data = result as Record + diagnostics.firstStreamEvent(handle, seq, data.type) // Why: stale-event filter. Server-side state machine bumps a // monotonic seq on every applyLayout. Drop `resized` events // whose seq is strictly older than what we've already observed @@ -1429,6 +1421,7 @@ export default function SessionScreen() { return } if (data.type === 'scrollback') { + diagnostics.streamScrollback(handle, seq, eventSeq, data) if (initializedHandlesRef.current.has(handle)) { return } @@ -1520,6 +1513,7 @@ export default function SessionScreen() { // server still has a null viewport for THIS subscriber // record — we MUST resubscribe so the server stores it. if (dims) { + diagnostics.streamResubscribing(handle, seq, dims) viewportRef.current = dims viewportMeasuredRef.current = true unsubscribeTerminal(handle) @@ -1564,6 +1558,7 @@ export default function SessionScreen() { const cols = (data.cols as number) || 80 const rows = (data.rows as number) || 24 const serialized = typeof data.serialized === 'string' ? data.serialized : null + diagnostics.streamResized(handle, seq, eventSeq, data, getTerminalRef(handle) != null) const oscLinks = isTerminalOscLinkRanges(data.oscLinks) ? data.oscLinks : undefined if (serialized != null) { getTerminalRef(handle)?.init(cols, rows, serialized, true, oscLinks) @@ -1733,6 +1728,7 @@ export default function SessionScreen() { const applySessionTabs = useCallback( (result: SessionTabsResult) => { + const diagnostics = terminalDiagnosticsRef.current // Reject out-of-order snapshots, then suppress just-closed tabs until the // publisher confirms their absence. See session-tab-snapshot-gate. if (!acceptSessionSnapshot(result, appliedSnapshotMarkerRef.current)) { @@ -1789,15 +1785,21 @@ export default function SessionScreen() { const pendingActiveSessionTabId = pendingActiveSessionTabIdRef.current const pendingActiveTerminalHandle = pendingActiveTerminalHandleRef.current let active = snapshotActive + let selectionSource = 'snapshot' if (pendingActiveSessionTabId) { if (snapshotActive?.id === pendingActiveSessionTabId) { - pendingActiveSessionTabIdRef.current = null + if (confirmsMirroredTabSelection(result.publicationEpoch)) { + pendingActiveSessionTabIdRef.current = null + } else { + selectionSource = 'pending-tab-local-ack' + } } else { const pendingTab = nextTabs.find((tab) => tab.id === pendingActiveSessionTabId) if (pendingTab) { // Why: desktop tab snapshots can lag a mobile tap while activate RPC // is in flight. Keep the locally selected tab to avoid snapping back. active = pendingTab + selectionSource = 'pending-tab' } else { pendingActiveSessionTabIdRef.current = null } @@ -1815,12 +1817,17 @@ export default function SessionScreen() { snapshotActive?.type === 'terminal' && snapshotActive.terminal === pendingActiveTerminalHandle ) { - pendingActiveTerminalHandleRef.current = null + if (confirmsMirroredTabSelection(result.publicationEpoch)) { + pendingActiveTerminalHandleRef.current = null + } else { + selectionSource = 'pending-handle-local-ack' + } } else if (pendingTerminalTab) { // Why: desktop active flags can lag a mobile terminal tap. Key by // terminal handle too, because fallback PTY tabs may not yet have a // stable session tab id during new-worktree startup. active = pendingTerminalTab + selectionSource = 'pending-handle-tab' } else if (pendingTerminalExists) { const nextActiveTabId = getActiveTabIdForHandle(nextTabs, pendingActiveTerminalHandle) activeSessionTabIdRef.current = nextActiveTabId @@ -1833,6 +1840,7 @@ export default function SessionScreen() { pendingActiveTerminalHandleRef.current = null } } + diagnostics.tabsApplied(result, nextTabs, active, selectionSource) activeSessionTabTypeRef.current = active?.type ?? null activeSessionTabIdRef.current = active?.id ?? null setActiveSessionTabId(active?.id ?? null) @@ -2377,20 +2385,25 @@ export default function SessionScreen() { const fetchSessionTabs = useCallback(async () => { if (!client) { + terminalDiagnosticsRef.current.tabsFetchSkipped('no-client') return } if (fetchSessionTabsInFlightRef.current) { + terminalDiagnosticsRef.current.tabsFetchSkipped('already-in-flight') return } fetchSessionTabsInFlightRef.current = true + terminalDiagnosticsRef.current.tabsFetchStarted(worktreeId) try { const response = await client.sendRequest('session.tabs.list', { worktree: `id:${worktreeId}` }) if (!response.ok) { + terminalDiagnosticsRef.current.tabsFetchFailed((response as RpcFailure).error.code) return } const result = (response as RpcSuccess).result as SessionTabsResult + terminalDiagnosticsRef.current.tabsFetchSucceeded(result) applySessionTabs(result) // Focus a just-opened browser tab once it appears in the snapshot, via the // normal activate path so it sticks and the user can still switch away. @@ -2404,7 +2417,8 @@ export default function SessionScreen() { switchSessionTabRef.current?.(browserTab) } } - } catch { + } catch (error) { + terminalDiagnosticsRef.current.tabsFetchErrored(error) // Keep the last tab snapshot visible during reconnect/backoff. } finally { fetchSessionTabsInFlightRef.current = false @@ -2689,6 +2703,7 @@ export default function SessionScreen() { pendingBrowserFocusPageIdRef.current = null pendingTerminalActivationAttemptRef.current = null initialEmptySessionAutoCreateRef.current = null + terminalDiagnosticsRef.current.resetRoute() appliedSnapshotMarkerRef.current = { epoch: null, version: -1 } closedTabTombstonesRef.current.clear() for (const queued of terminalGestureInputQueuesRef.current.values()) { @@ -2912,6 +2927,7 @@ export default function SessionScreen() { (tab): tab is Extract => tab.type === 'terminal' && tab.terminal === handle ) + terminalDiagnosticsRef.current.tabSwitch('terminal', matchingTab?.id ?? '', false, handle) pendingActiveSessionTabIdRef.current = matchingTab?.id ?? null pendingActiveTerminalHandleRef.current = handle activeSessionTabTypeRef.current = 'terminal' @@ -2931,15 +2947,15 @@ export default function SessionScreen() { } subscribeToTerminal(handle) if (client) { - void client.sendRequest('terminal.focus', { terminal: handle }).catch(() => {}) + void focusMobileTerminal(client, handle).catch(() => {}) if (matchingTab) { - void client - .sendRequest('session.tabs.activate', { - worktree: `id:${worktreeId}`, - tabId: matchingTab.id, - notifyClients: false - }) - .catch(() => {}) + // Why: persist selection for headless hosts; the snapshot gate keeps + // this phone-local acknowledgement from impersonating desktop focus. + void activateMobileSessionTab(client, { + worktree: `id:${worktreeId}`, + tabId: matchingTab.id, + notifyClients: false + }).catch(() => {}) } } }, @@ -2960,6 +2976,7 @@ export default function SessionScreen() { switchTab(tab.terminal) return } + terminalDiagnosticsRef.current.tabSwitch('terminal', tab.id, true) triggerSelection() pendingActiveSessionTabIdRef.current = tab.id pendingActiveTerminalHandleRef.current = null @@ -2973,18 +2990,17 @@ export default function SessionScreen() { activeHandleRef.current = null setActiveHandle(null) if (client) { - void client - .sendRequest('session.tabs.activate', { - worktree: `id:${worktreeId}`, - tabId: tab.id, - notifyClients: false - }) - .catch(() => {}) + void activateMobileSessionTab(client, { + worktree: `id:${worktreeId}`, + tabId: tab.id, + notifyClients: false + }).catch(() => {}) } return } triggerSelection() + terminalDiagnosticsRef.current.tabSwitch(tab.type, tab.id, false) pendingActiveSessionTabIdRef.current = tab.id pendingActiveTerminalHandleRef.current = null activeSessionTabTypeRef.current = tab.type @@ -2997,13 +3013,11 @@ export default function SessionScreen() { activeHandleRef.current = null setActiveHandle(null) if (client) { - void client - .sendRequest('session.tabs.activate', { - worktree: `id:${worktreeId}`, - tabId: tab.id, - notifyClients: false - }) - .catch(() => {}) + void activateMobileSessionTab(client, { + worktree: `id:${worktreeId}`, + tabId: tab.id, + notifyClients: false + }).catch(() => {}) } if (tab.type === 'browser') { return @@ -3031,6 +3045,7 @@ export default function SessionScreen() { // init messages. This prevents the blank terminal race where init() was // queued before the WebView loaded. const setTerminalWebViewRef = useCallback((handle: string, ref: TerminalWebViewHandle | null) => { + terminalDiagnosticsRef.current.webViewRef(handle, ref != null) if (ref) { terminalRefs.current.set(handle, ref) } else { @@ -3049,6 +3064,11 @@ export default function SessionScreen() { (handle: string) => { const wasAlreadyReady = webReadyHandlesRef.current.has(handle) webReadyHandlesRef.current.add(handle) + terminalDiagnosticsRef.current.webViewReady( + handle, + wasAlreadyReady, + handle === activeHandleRef.current + ) if (wasAlreadyReady && initializedHandlesRef.current.has(handle)) { // Why: the native WebView reloaded (Metro hot reload or Android // process churn). The old xterm buffer is gone, so force a fresh @@ -3085,7 +3105,7 @@ export default function SessionScreen() { })() } }, - [measureViewportOnce, subscribeToTerminal, unsubscribeTerminal] + [getTerminalRef, measureViewportOnce, subscribeToTerminal, unsubscribeTerminal] ) useEffect(() => { @@ -4302,13 +4322,12 @@ export default function SessionScreen() { // Why: a hydrated headless/server-owned tab can already be active but still // pending; activation is the RPC that materializes or focuses its PTY handle. pendingTerminalActivationAttemptRef.current = activationKey - void client - .sendRequest('session.tabs.activate', { - worktree: `id:${worktreeId}`, - tabId: activePendingTerminalTab.id, - leafId: activePendingTerminalTab.leafId, - notifyClients: false - }) + void activateMobileSessionTab(client, { + worktree: `id:${worktreeId}`, + tabId: activePendingTerminalTab.id, + leafId: activePendingTerminalTab.leafId, + notifyClients: false + }) .then((response) => { if (!response.ok) { if (pendingTerminalActivationAttemptRef.current === activationKey) { diff --git a/mobile/src/session/mobile-session-route-helpers.ts b/mobile/src/session/mobile-session-route-helpers.ts index 18434e21f5af..b5a5049dd06e 100644 --- a/mobile/src/session/mobile-session-route-helpers.ts +++ b/mobile/src/session/mobile-session-route-helpers.ts @@ -33,3 +33,31 @@ export function isGestureMouseTrackingMode( ): boolean { return mode === 'x10' || mode === 'vt200' || mode === 'drag' || mode === 'any' } + +export function getActiveTabIdForHandle( + tabs: ReadonlyArray<{ id: string; type: string; terminal?: string | null }>, + terminalHandle: string | null +): string | null { + if (!terminalHandle) { + return null + } + return ( + tabs.find((tab) => tab.type === 'terminal' && tab.terminal === terminalHandle)?.id ?? + terminalHandle + ) +} + +export function updateTerminalCwdFromStreamEvent( + handle: string, + data: Readonly>, + terminalCwd: Map +): void { + if (!('cwd' in data)) { + return + } + if (typeof data.cwd === 'string' && data.cwd.trim().length > 0) { + terminalCwd.set(handle, data.cwd) + return + } + terminalCwd.delete(handle) +} diff --git a/mobile/src/session/mobile-session-startup-source.test.ts b/mobile/src/session/mobile-session-startup-source.test.ts index aca17cfb5096..c4083b1a5450 100644 --- a/mobile/src/session/mobile-session-startup-source.test.ts +++ b/mobile/src/session/mobile-session-startup-source.test.ts @@ -58,7 +58,7 @@ describe('mobile session startup', () => { expect(pendingActivationEffect).toContain( 'pendingTerminalActivationAttemptRef.current === activationKey' ) - expect(pendingActivationEffect).toContain("sendRequest('session.tabs.activate'") + expect(pendingActivationEffect).toContain('activateMobileSessionTab(client,') expect(pendingActivationEffect).toContain('tabId: activePendingTerminalTab.id') expect(pendingActivationEffect).toContain('leafId: activePendingTerminalTab.leafId') expect(pendingActivationEffect).toContain('notifyClients: false') @@ -68,8 +68,19 @@ describe('mobile session startup', () => { expect(pendingActivationEffect).toContain('scheduleDelayedAction(() => void fetchSessionTabs()') }) - it('keeps mobile session tab activation local to the phone', () => { - const activationRequests = source.split("sendRequest('session.tabs.activate'").slice(1) + it('mirrors ready terminal taps while persisting them for headless hosts', () => { + const readyTerminalSwitch = sliceBetween( + 'const switchTab = useCallback(', + 'const switchSessionTab = useCallback(' + ) + + expect(readyTerminalSwitch).toContain('focusMobileTerminal(client, handle)') + expect(readyTerminalSwitch).toContain('activateMobileSessionTab(client,') + expect(readyTerminalSwitch).toContain('notifyClients: false') + }) + + it('keeps background and pending session-tab activation local to the phone', () => { + const activationRequests = source.split('activateMobileSessionTab(client,').slice(1) expect(activationRequests).toHaveLength(4) for (const request of activationRequests) { diff --git a/mobile/src/session/mobile-session-tab-activation.test.ts b/mobile/src/session/mobile-session-tab-activation.test.ts new file mode 100644 index 000000000000..d448d7e2f533 --- /dev/null +++ b/mobile/src/session/mobile-session-tab-activation.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from 'vitest' +import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client' +import type { RpcClient } from '../transport/rpc-client' +import type { RpcResponse } from '../transport/types' +import { activateMobileSessionTab, focusMobileTerminal } from './mobile-session-tab-activation' + +function success(): RpcResponse { + return { id: 'rpc-1', ok: true, result: {}, _meta: { runtimeId: 'runtime-1' } } +} + +function clientWith(sendRequest: RpcClient['sendRequest']): Pick { + return { sendRequest } +} + +describe('mobile session tab activation', () => { + it('retries terminal focus once on the authenticated replacement after cutover', async () => { + const sendRequest = vi + .fn() + .mockRejectedValueOnce(new LogicalClientCutoverError()) + .mockResolvedValueOnce(success()) + + await expect(focusMobileTerminal(clientWith(sendRequest), 'terminal-1')).resolves.toMatchObject( + { + ok: true + } + ) + expect(sendRequest).toHaveBeenCalledTimes(2) + expect(sendRequest).toHaveBeenNthCalledWith(1, 'terminal.focus', { terminal: 'terminal-1' }) + expect(sendRequest).toHaveBeenNthCalledWith(2, 'terminal.focus', { terminal: 'terminal-1' }) + }) + + it('retries session-tab activation with the same target after cutover', async () => { + const sendRequest = vi + .fn() + .mockRejectedValueOnce(new LogicalClientCutoverError()) + .mockResolvedValueOnce(success()) + const params = { + worktree: 'id:worktree-1', + tabId: 'tab-1', + leafId: 'leaf-1', + notifyClients: false as const + } + + await expect(activateMobileSessionTab(clientWith(sendRequest), params)).resolves.toMatchObject({ + ok: true + }) + expect(sendRequest).toHaveBeenCalledTimes(2) + expect(sendRequest).toHaveBeenNthCalledWith(1, 'session.tabs.activate', params) + expect(sendRequest).toHaveBeenNthCalledWith(2, 'session.tabs.activate', params) + }) + + it('does not retry unrelated transport failures', async () => { + const sendRequest = vi.fn().mockRejectedValue(new Error('offline')) + + await expect(focusMobileTerminal(clientWith(sendRequest), 'terminal-1')).rejects.toThrow( + 'offline' + ) + expect(sendRequest).toHaveBeenCalledOnce() + }) + + it('retries at most once when consecutive cutovers interrupt activation', async () => { + const sendRequest = vi + .fn() + .mockRejectedValue(new LogicalClientCutoverError()) + + await expect( + activateMobileSessionTab(clientWith(sendRequest), { + worktree: 'id:worktree-1', + tabId: 'tab-1', + notifyClients: false + }) + ).rejects.toBeInstanceOf(LogicalClientCutoverError) + expect(sendRequest).toHaveBeenCalledTimes(2) + }) +}) diff --git a/mobile/src/session/mobile-session-tab-activation.ts b/mobile/src/session/mobile-session-tab-activation.ts new file mode 100644 index 000000000000..4b07bff1eeed --- /dev/null +++ b/mobile/src/session/mobile-session-tab-activation.ts @@ -0,0 +1,90 @@ +import type { RpcClient } from '../transport/rpc-client' +import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client' +import type { RpcResponse } from '../transport/types' +import { + getMobileTerminalDiagnosticErrorName, + logMobileTerminalDiagnostic, + shortenMobileTerminalDiagnosticId +} from './mobile-terminal-diagnostics' + +type ActivationClient = Pick + +type MobileSessionTabActivationParams = { + worktree: string + tabId: string + leafId?: string + notifyClients: false +} + +async function retryIdempotentActivationAfterCutover( + request: () => Promise, + operation: 'terminal.focus' | 'session.tabs.activate', + target: string +): Promise { + const diagnosticTarget = shortenMobileTerminalDiagnosticId(target) + logMobileTerminalDiagnostic('activation-request', { operation, target: diagnosticTarget }) + try { + const response = await request() + logMobileTerminalDiagnostic('activation-result', { + operation, + target: diagnosticTarget, + ok: response.ok, + rpcCode: response.ok ? null : response.error.code + }) + return response + } catch (error) { + if (!(error instanceof LogicalClientCutoverError)) { + logMobileTerminalDiagnostic('activation-error', { + operation, + target: diagnosticTarget, + errorName: getMobileTerminalDiagnosticErrorName(error) + }) + throw error + } + logMobileTerminalDiagnostic('activation-cutover-retry', { + operation, + target: diagnosticTarget + }) + // Why: cutover rejects ambiguous in-flight work after the replacement is + // active; these state-setting requests are idempotent and safe to repeat once. + try { + const response = await request() + logMobileTerminalDiagnostic('activation-result', { + operation, + target: diagnosticTarget, + ok: response.ok, + rpcCode: response.ok ? null : response.error.code + }) + return response + } catch (retryError) { + logMobileTerminalDiagnostic('activation-error', { + operation, + target: diagnosticTarget, + errorName: getMobileTerminalDiagnosticErrorName(retryError) + }) + throw retryError + } + } +} + +export function focusMobileTerminal( + client: ActivationClient, + terminal: string +): Promise { + return retryIdempotentActivationAfterCutover( + () => client.sendRequest('terminal.focus', { terminal }), + 'terminal.focus', + terminal + ) +} + +export function activateMobileSessionTab( + client: ActivationClient, + params: MobileSessionTabActivationParams +): Promise { + return retryIdempotentActivationAfterCutover( + () => client.sendRequest('session.tabs.activate', params), + 'session.tabs.activate', + params.tabId + ) +} diff --git a/mobile/src/session/mobile-terminal-diagnostics.test.ts b/mobile/src/session/mobile-terminal-diagnostics.test.ts new file mode 100644 index 000000000000..831ad4bfccc6 --- /dev/null +++ b/mobile/src/session/mobile-terminal-diagnostics.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from 'vitest' +import { + getMobileTerminalDiagnosticErrorName, + logMobileTerminalDiagnostic, + MobileTerminalDiagnostics, + shortenMobileTerminalDiagnosticId +} from './mobile-terminal-diagnostics' + +describe('mobile terminal diagnostics', () => { + it('keeps only the correlatable suffix of identifiers', () => { + expect(shortenMobileTerminalDiagnosticId('terminal-secret-prefix-12345678')).toBe('12345678') + expect(shortenMobileTerminalDiagnosticId('short')).toBe('short') + expect(shortenMobileTerminalDiagnosticId(null)).toBeNull() + }) + + it('reports thrown error types without copying potentially sensitive messages', () => { + expect(getMobileTerminalDiagnosticErrorName(new TypeError('/private/worktree failed'))).toBe( + 'TypeError' + ) + expect(getMobileTerminalDiagnosticErrorName('raw failure')).toBe('string') + }) + + it('uses one filterable structured log tag', () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + + logMobileTerminalDiagnostic('stream-armed', { handle: '12345678', seq: 2 }) + + expect(log).toHaveBeenCalledWith('[terminal-diagnostic]', 'stream-armed', { + handle: '12345678', + seq: 2 + }) + log.mockRestore() + }) + + it('forgets first-event state when a terminal unsubscribes', () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + const diagnostics = new MobileTerminalDiagnostics() + + diagnostics.firstStreamEvent('terminal-1', 1, 'subscribed') + diagnostics.terminalUnsubscribed('terminal-1') + diagnostics.firstStreamEvent('terminal-1', 1, 'subscribed') + + expect(log).toHaveBeenCalledTimes(2) + log.mockRestore() + }) +}) diff --git a/mobile/src/session/mobile-terminal-diagnostics.ts b/mobile/src/session/mobile-terminal-diagnostics.ts new file mode 100644 index 000000000000..8695928ed036 --- /dev/null +++ b/mobile/src/session/mobile-terminal-diagnostics.ts @@ -0,0 +1,291 @@ +const MOBILE_TERMINAL_DIAGNOSTIC_TAG = '[terminal-diagnostic]' + +type MobileTerminalDiagnosticValue = string | number | boolean | null | undefined + +export type MobileTerminalDiagnosticDetails = Readonly< + Record +> + +type DiagnosticTab = { + readonly id: string + readonly type: string + readonly isActive: boolean + readonly terminal?: string | null +} + +type DiagnosticTabsSnapshot = { + readonly publicationEpoch?: string + readonly snapshotVersion: number + readonly tabs: readonly DiagnosticTab[] +} + +type DiagnosticDimensions = { readonly cols: number; readonly rows: number } | null | undefined + +// Why: full runtime identifiers make shared logs unnecessarily sensitive; the +// suffix is enough to correlate lifecycle events within one reproduction. +export function shortenMobileTerminalDiagnosticId(value: string | null | undefined): string | null { + if (!value) { + return null + } + return value.slice(-8) +} + +export function getMobileTerminalDiagnosticErrorName(error: unknown): string { + if (error instanceof Error && error.name) { + return error.name + } + return typeof error +} + +export function logMobileTerminalDiagnostic( + event: string, + details: MobileTerminalDiagnosticDetails = {} +): void { + // Why: lifecycle diagnostics are intentionally available for HMR repros, + // but high-frequency WebView events must not add production log overhead. + if (typeof __DEV__ !== 'undefined' && !__DEV__) { + return + } + // Keep this structured and content-free so users can safely share a filtered log. + console.log(MOBILE_TERMINAL_DIAGNOSTIC_TAG, event, details) +} + +export class MobileTerminalDiagnostics { + private readonly streamGateByHandle = new Map() + private readonly firstStreamEventSeqByHandle = new Map() + private lastFetchedTabsSignature: string | null = null + private lastAppliedTabsSignature: string | null = null + private lastTabsFetchStartAt = 0 + private tabsFetchSkipLogged = false + + clearTerminalCache(): void { + this.streamGateByHandle.clear() + this.firstStreamEventSeqByHandle.clear() + } + + resetRoute(): void { + this.clearTerminalCache() + this.lastFetchedTabsSignature = null + this.lastAppliedTabsSignature = null + this.lastTabsFetchStartAt = 0 + this.tabsFetchSkipLogged = false + } + + terminalUnsubscribed(handle: string): void { + this.streamGateByHandle.delete(handle) + this.firstStreamEventSeqByHandle.delete(handle) + } + + viewportMeasured(handle: string, dims: DiagnosticDimensions, frameHeight: number): void { + logMobileTerminalDiagnostic('viewport-measure', { + handle: shortenMobileTerminalDiagnosticId(handle), + ok: dims != null, + cols: dims?.cols, + rows: dims?.rows, + frameHeight: Math.round(frameHeight) + }) + } + + streamSkipped(handle: string, reason: string, isActive: boolean): void { + if (this.streamGateByHandle.get(handle) === reason) { + return + } + this.streamGateByHandle.set(handle, reason) + logMobileTerminalDiagnostic('stream-skipped', { + handle: shortenMobileTerminalDiagnosticId(handle), + reason, + isActive + }) + } + + streamArmed(handle: string, seq: number, viewport: DiagnosticDimensions): void { + this.streamGateByHandle.delete(handle) + logMobileTerminalDiagnostic('stream-armed', { + handle: shortenMobileTerminalDiagnosticId(handle), + seq, + hasViewport: viewport != null, + viewportCols: viewport?.cols, + viewportRows: viewport?.rows + }) + } + + firstStreamEvent(handle: string, seq: number, type: unknown): void { + if (this.firstStreamEventSeqByHandle.get(handle) === seq) { + return + } + this.firstStreamEventSeqByHandle.set(handle, seq) + logMobileTerminalDiagnostic('stream-first-event', { + handle: shortenMobileTerminalDiagnosticId(handle), + seq, + type: typeof type === 'string' ? type : 'unknown' + }) + } + + streamScrollback( + handle: string, + seq: number, + eventSeq: number | null, + data: Readonly> + ): void { + logMobileTerminalDiagnostic('stream-scrollback', { + handle: shortenMobileTerminalDiagnosticId(handle), + seq, + eventSeq, + cols: typeof data.cols === 'number' ? data.cols : null, + rows: typeof data.rows === 'number' ? data.rows : null, + serializedLength: typeof data.serialized === 'string' ? data.serialized.length : 0, + displayMode: typeof data.displayMode === 'string' ? data.displayMode : null, + source: typeof data.source === 'string' ? data.source : null, + scrollbackRows: typeof data.scrollbackRows === 'number' ? data.scrollbackRows : null, + truncated: data.truncated === true || data.truncatedByByteBudget === true + }) + } + + streamResubscribing(handle: string, seq: number, dims: { cols: number; rows: number }): void { + logMobileTerminalDiagnostic('stream-resubscribe-for-viewport', { + handle: shortenMobileTerminalDiagnosticId(handle), + seq, + cols: dims.cols, + rows: dims.rows + }) + } + + streamResized( + handle: string, + seq: number, + eventSeq: number | null, + data: Readonly>, + hasRef: boolean + ): void { + logMobileTerminalDiagnostic('stream-resized', { + handle: shortenMobileTerminalDiagnosticId(handle), + seq, + eventSeq, + cols: typeof data.cols === 'number' ? data.cols : null, + rows: typeof data.rows === 'number' ? data.rows : null, + serializedLength: typeof data.serialized === 'string' ? data.serialized.length : 0, + hasRef + }) + } + + tabsApplied( + snapshot: DiagnosticTabsSnapshot, + tabs: readonly DiagnosticTab[], + activeTab: DiagnosticTab | null, + selectionSource: string + ): void { + const activeHandle = + activeTab?.type === 'terminal' && typeof activeTab.terminal === 'string' + ? activeTab.terminal + : null + const appliedSnapshot = { ...snapshot, tabs } + const signature = [ + appliedSnapshot.publicationEpoch ?? '', + appliedSnapshot.snapshotVersion, + activeTab?.id ?? '', + activeHandle ?? '', + selectionSource + ].join(':') + if (this.lastAppliedTabsSignature === signature) { + return + } + this.lastAppliedTabsSignature = signature + this.logTabs('tabs-applied', appliedSnapshot, activeTab, activeHandle, { selectionSource }) + } + + tabsFetchSkipped(reason: string): void { + if (reason === 'already-in-flight' && this.tabsFetchSkipLogged) { + return + } + this.tabsFetchSkipLogged = reason === 'already-in-flight' + logMobileTerminalDiagnostic('tabs-fetch-skipped', { reason }) + } + + tabsFetchStarted(worktreeId: string): void { + this.tabsFetchSkipLogged = false + const now = Date.now() + if (this.lastFetchedTabsSignature != null && now - this.lastTabsFetchStartAt < 10_000) { + return + } + this.lastTabsFetchStartAt = now + logMobileTerminalDiagnostic('tabs-fetch-start', { + worktree: shortenMobileTerminalDiagnosticId(worktreeId) + }) + } + + tabsFetchFailed(rpcCode: string): void { + logMobileTerminalDiagnostic('tabs-fetch-rpc-failure', { rpcCode }) + } + + tabsFetchErrored(error: unknown): void { + logMobileTerminalDiagnostic('tabs-fetch-error', { + errorName: getMobileTerminalDiagnosticErrorName(error) + }) + } + + tabsFetchSucceeded(snapshot: DiagnosticTabsSnapshot): void { + const activeTab = snapshot.tabs.find((tab) => tab.isActive) ?? null + const signature = [ + snapshot.publicationEpoch ?? '', + snapshot.snapshotVersion, + snapshot.tabs.length, + activeTab?.id ?? '', + activeTab?.type ?? '' + ].join(':') + if (this.lastFetchedTabsSignature === signature) { + return + } + this.lastFetchedTabsSignature = signature + const activeHandle = + activeTab?.type === 'terminal' && typeof activeTab.terminal === 'string' + ? activeTab.terminal + : null + this.logTabs('tabs-fetch-success', snapshot, activeTab, activeHandle) + } + + tabSwitch(tabType: string, tabId: string, pending: boolean, handle?: string): void { + logMobileTerminalDiagnostic('tab-switch', { + tabType, + tab: shortenMobileTerminalDiagnosticId(tabId), + handle: shortenMobileTerminalDiagnosticId(handle), + pending + }) + } + + webViewRef(handle: string, attached: boolean): void { + logMobileTerminalDiagnostic('webview-ref', { + handle: shortenMobileTerminalDiagnosticId(handle), + attached + }) + } + + webViewReady(handle: string, reload: boolean, isActive: boolean): void { + logMobileTerminalDiagnostic('webview-ready', { + handle: shortenMobileTerminalDiagnosticId(handle), + reload, + isActive + }) + } + + private logTabs( + event: 'tabs-applied' | 'tabs-fetch-success', + snapshot: DiagnosticTabsSnapshot, + activeTab: DiagnosticTab | null, + activeHandle: string | null, + extra: MobileTerminalDiagnosticDetails = {} + ): void { + logMobileTerminalDiagnostic(event, { + publication: shortenMobileTerminalDiagnosticId(snapshot.publicationEpoch), + snapshotVersion: snapshot.snapshotVersion, + tabCount: snapshot.tabs.length, + terminalTabCount: snapshot.tabs.filter((tab) => tab.type === 'terminal').length, + pendingTerminalCount: snapshot.tabs.filter( + (tab) => tab.type === 'terminal' && typeof tab.terminal !== 'string' + ).length, + activeTab: shortenMobileTerminalDiagnosticId(activeTab?.id), + activeType: activeTab?.type ?? null, + activeHandle: shortenMobileTerminalDiagnosticId(activeHandle), + ...extra + }) + } +} diff --git a/mobile/src/session/session-tab-snapshot-gate.test.ts b/mobile/src/session/session-tab-snapshot-gate.test.ts index 865c297fdb2d..9cb742629ec0 100644 --- a/mobile/src/session/session-tab-snapshot-gate.test.ts +++ b/mobile/src/session/session-tab-snapshot-gate.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { acceptSessionSnapshot, applyClosedTabTombstones, + confirmsMirroredTabSelection, type AppliedSnapshotMarker } from './session-tab-snapshot-gate' @@ -50,6 +51,18 @@ describe('acceptSessionSnapshot', () => { }) }) +describe('confirmsMirroredTabSelection', () => { + it('does not treat phone-local persistence as desktop focus confirmation', () => { + expect(confirmsMirroredTabSelection('mobile-local:abc')).toBe(false) + }) + + it('accepts renderer, headless, and legacy publications as confirmation', () => { + expect(confirmsMirroredTabSelection('renderer:abc')).toBe(true) + expect(confirmsMirroredTabSelection('headless:abc')).toBe(true) + expect(confirmsMirroredTabSelection()).toBe(true) + }) +}) + describe('applyClosedTabTombstones', () => { const tab = (id: string): { id: string } => ({ id }) diff --git a/mobile/src/session/session-tab-snapshot-gate.ts b/mobile/src/session/session-tab-snapshot-gate.ts index f3d7601ef4a1..1bcf9cadb995 100644 --- a/mobile/src/session/session-tab-snapshot-gate.ts +++ b/mobile/src/session/session-tab-snapshot-gate.ts @@ -33,6 +33,12 @@ export function acceptSessionSnapshot( return true } +export function confirmsMirroredTabSelection(publicationEpoch?: string): boolean { + // Why: phone-local persistence acknowledges the mutation, not host focus; + // keep the pending guard until any host publication confirms the selection. + return !publicationEpoch?.startsWith('mobile-local:') +} + /** * Drops tabs the user just closed locally (tombstoned) until the publisher's * snapshot also drops them or the tombstone expires. Mutates `tombstones`, diff --git a/mobile/src/terminal/terminal-webview-html.ts b/mobile/src/terminal/terminal-webview-html.ts index 69b2e6f6b511..e9d188b74ef0 100644 --- a/mobile/src/terminal/terminal-webview-html.ts +++ b/mobile/src/terminal/terminal-webview-html.ts @@ -6,6 +6,7 @@ import { TERMINAL_TEXT_SCALES } from '../storage/preferences' import { TERMINAL_PATH_TAP_JS } from './terminal-path-tap-injected' import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.generated' import { TERMINAL_REFLOW_JS } from './terminal-webview-reflow-injected' +import { TERMINAL_SURFACE_SWAP_JS } from './terminal-webview-surface-swap-injected' import { TERMINAL_TAP_DISPATCH_JS } from './terminal-webview-tap-dispatch-injected' import { TERMINAL_WEBVIEW_THEME_JS } from './terminal-webview-theme-injected' import { TERMINAL_QUERY_REPLY_JS } from './terminal-webview-query-reply-injected' @@ -217,6 +218,7 @@ window.onerror = function(msg) { var statusDotPendingSelector = false; var PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096; var term = null; ${TERMINAL_QUERY_REPLY_JS} + ${TERMINAL_SURFACE_SWAP_JS} var scrollIndicator = document.getElementById('scroll-indicator'); var scrollThumb = document.getElementById('scroll-thumb'); var scrollIndicatorHideTimer = null; @@ -713,22 +715,8 @@ ${TERMINAL_WEBGL_RECOVERY_JS} initialOscLinks = Array.isArray(nextOscLinks) ? nextOscLinks : []; initialOscLinkRowOffset = 0; initialOscLinkEvictionReady = false; - var oldTerm = term; - var oldSurface = surface; - var nextSurface = null; - disposeTermObservers(); - if (oldTerm) { - nextSurface = document.createElement('div'); - nextSurface.id = 'terminal-surface'; - nextSurface.style.visibility = 'hidden'; - nextSurface.style.position = 'absolute'; - nextSurface.style.left = '0'; - nextSurface.style.top = '0'; - document.getElementById('terminal-container').appendChild(nextSurface); - surface = nextSurface; - attachSurfaceEventHandlers(surface); - oldSurface.removeAttribute('id'); - } + var surfaceSwap = beginTerminalSurfaceSwap(); + var nextSurface = surfaceSwap.nextSurface; applyTerminalTheme(nextTheme); term = new Terminal({ @@ -749,6 +737,8 @@ ${TERMINAL_WEBGL_RECOVERY_JS} convertEol: false, allowProposedApi: true }); + var nextTerm = term; + pendingTerm = nextTerm; term.open(surface); attachWebglAddon(true); if (window.Unicode11Addon && window.Unicode11Addon.Unicode11Addon) try { term.loadAddon(new window.Unicode11Addon.Unicode11Addon()); term.unicode.activeVersion = '11'; } catch (e) {} @@ -768,14 +758,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS} everReady = true; afterWritesDrained(function() { if (gen !== terminalGeneration) return; - if (nextSurface && oldSurface) { - nextSurface.style.visibility = 'visible'; - nextSurface.style.position = ''; - nextSurface.style.left = ''; - nextSurface.style.top = ''; - oldSurface.remove(); - if (oldTerm) oldTerm.dispose(); - } + commitTerminalSurfaceSwap(surfaceSwap, nextTerm); // Why: restore the reader's place after the rewrapped buffer replays. // Replay lands at bottom, so only act when they were scrolled up (rows>0). if (scrollAnchorRows > 0 && term && term.buffer && term.buffer.active) { diff --git a/mobile/src/terminal/terminal-webview-init-surface.test.ts b/mobile/src/terminal/terminal-webview-init-surface.test.ts new file mode 100644 index 000000000000..6c7c4e5f1733 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-init-surface.test.ts @@ -0,0 +1,132 @@ +// @vitest-environment happy-dom +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { XTERM_HTML } from './terminal-webview-html' + +function iifeSource(): string { + const start = XTERM_HTML.indexOf('(function() {') + const end = XTERM_HTML.lastIndexOf('})();') + return XTERM_HTML.slice(start, end + '})();'.length) +} + +function bodyMarkup(): string { + const start = XTERM_HTML.indexOf('') + ''.length + const end = XTERM_HTML.indexOf(' + + + `) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const port = (server.address() as AddressInfo).port + return { + sourceUrl: `http://127.0.0.1:${port}/source`, + close: () => closeServer(server) + } +} + async function closeServer(server: Server): Promise { await new Promise((resolve, reject) => server.close((error) => { @@ -133,6 +211,88 @@ async function closeServer(server: Server): Promise { ) } +async function clickBrowserLink( + page: Parameters[0], + browserTabId: string, + selector: string, + options: { + modifiers?: ('meta' | 'control')[] + button?: 'left' | 'middle' + frameSelector?: string + } = {} +): Promise { + await page.evaluate( + async ({ targetBrowserTabId, targetSelector, inputModifiers, button, frameSelector }) => { + const slot = [...document.querySelectorAll('[data-browser-overlay-tab-id]')].find( + (candidate) => candidate.getAttribute('data-browser-overlay-tab-id') === targetBrowserTabId + ) + const webview = slot?.querySelector('webview') as Electron.WebviewTag | null + if (!webview) { + throw new Error(`Missing webview for browser tab ${targetBrowserTabId}`) + } + const point = (await webview.executeJavaScript(`(async () => { + const deadline = Date.now() + 5000 + while (Date.now() < deadline) { + const frame = ${JSON.stringify(frameSelector)} + ? document.querySelector(${JSON.stringify(frameSelector)}) + : null + const root = frame?.contentDocument ?? document + const linkRect = root.querySelector(${JSON.stringify(targetSelector)})?.getBoundingClientRect() + if (linkRect) { + const frameRect = frame?.getBoundingClientRect() + return { + x: (frameRect?.left ?? 0) + linkRect.left + linkRect.width / 2, + y: (frameRect?.top ?? 0) + linkRect.top + linkRect.height / 2 + } + } + await new Promise((resolve) => setTimeout(resolve, 25)) + } + return null + })()`)) as { x: number; y: number } | null + if (!point) { + throw new Error(`Missing browser link ${targetSelector}`) + } + await webview.sendInputEvent({ type: 'mouseMove', modifiers: inputModifiers, ...point }) + await webview.sendInputEvent({ + type: 'mouseDown', + button, + clickCount: 1, + modifiers: inputModifiers, + ...point + }) + await webview.sendInputEvent({ + type: 'mouseUp', + button, + clickCount: 1, + modifiers: inputModifiers, + ...point + }) + }, + { + targetBrowserTabId: browserTabId, + targetSelector: selector, + inputModifiers: options.modifiers ?? [], + button: options.button ?? 'left', + frameSelector: options.frameSelector ?? null + } + ) +} + +async function expectBrowserTabActive( + page: Parameters[0], + title: string +): Promise { + const resolveTabId = (): Promise => + page.locator('[data-tab-id]').evaluateAll((tabs, exactTitle) => { + const tab = tabs.find((candidate) => candidate.textContent?.trim() === exactTitle) + return tab?.getAttribute('data-tab-id') ?? null + }, title) + await expect.poll(resolveTabId, { timeout: 10_000 }).not.toBeNull() + const tabId = await resolveTabId() + expect(tabId).toBeTruthy() + await expect(page.locator(`[data-browser-overlay-tab-id="${tabId}"]`)).toHaveCSS('opacity', '1') +} + async function readBrowserInputValue( page: Parameters[0], browserTabId: string @@ -301,6 +461,80 @@ test.describe('Browser Tab', () => { } }) + test('plain links stay current while explicit new-tab gestures activate Orca tabs', async ({ + electronApp, + orcaPage + }) => { + const linkServer = await startBrowserLinkServer() + try { + const worktreeId = (await getActiveWorktreeId(orcaPage))! + const sourceTab = await createBrowserTab( + orcaPage, + worktreeId, + linkServer.sourceUrl, + 'Source page' + ) + expect(sourceTab?.id).toBeTruthy() + + const baseWindowCount = await electronApp.evaluate( + ({ BaseWindow }) => BaseWindow.getAllWindows().length + ) + const baseTabCount = await orcaPage.locator('[data-tab-id]').count() + await clickBrowserLink(orcaPage, sourceTab!.id, '#external-link') + + const sourceTabLocator = orcaPage.locator(`[data-tab-id="${sourceTab!.id}"]`) + await expect(sourceTabLocator).toContainText('Linked destination', { timeout: 10_000 }) + await expect(orcaPage.locator('[data-tab-id]')).toHaveCount(baseTabCount) + + await clickBrowserLink(orcaPage, sourceTab!.id, '#return-link') + await expect(sourceTabLocator).toContainText('Source page', { timeout: 10_000 }) + await clickBrowserLink(orcaPage, sourceTab!.id, '#frame-link', { + frameSelector: '#link-frame' + }) + await expect(sourceTabLocator).toContainText('Frame destination', { timeout: 10_000 }) + await expect(orcaPage.locator('[data-tab-id]')).toHaveCount(baseTabCount) + + await clickBrowserLink(orcaPage, sourceTab!.id, '#return-link') + await expect(sourceTabLocator).toContainText('Source page', { timeout: 10_000 }) + + await clickBrowserLink(orcaPage, sourceTab!.id, '#frame-modifier-link', { + frameSelector: '#link-frame', + modifiers: process.platform === 'darwin' ? ['meta'] : ['control'] + }) + await expectBrowserTabActive(orcaPage, 'Frame modifier destination') + await switchToBrowserTab(orcaPage, worktreeId, sourceTab!.id) + await clickBrowserLink(orcaPage, sourceTab!.id, '#frame-middle-link', { + button: 'middle', + frameSelector: '#link-frame' + }) + await expectBrowserTabActive(orcaPage, 'Frame middle destination') + await switchToBrowserTab(orcaPage, worktreeId, sourceTab!.id) + + await clickBrowserLink(orcaPage, sourceTab!.id, '#modifier-link', { + modifiers: process.platform === 'darwin' ? ['meta'] : ['control'] + }) + await expectBrowserTabActive(orcaPage, 'Modifier destination') + await switchToBrowserTab(orcaPage, worktreeId, sourceTab!.id) + + const tabCountBeforeCancelledClick = await orcaPage.locator('[data-tab-id]').count() + await clickBrowserLink(orcaPage, sourceTab!.id, '#cancelled-link') + await expect( + orcaPage.locator('[data-tab-id]').filter({ hasText: 'Click handled in page' }) + ).toBeVisible({ timeout: 10_000 }) + await expect(orcaPage.locator('[data-tab-id]')).toHaveCount(tabCountBeforeCancelledClick) + + await clickBrowserLink(orcaPage, sourceTab!.id, '#middle-link', { button: 'middle' }) + await expectBrowserTabActive(orcaPage, 'Middle-click destination') + await expect + .poll(() => electronApp.evaluate(({ BaseWindow }) => BaseWindow.getAllWindows().length), { + timeout: 5_000 + }) + .toBe(baseWindowCount) + } finally { + await linkServer.close() + } + }) + /** * User Prompt: * - Browser works and also retains state when switching tabs etc. From 5b5d37f211fca3030687bcea403d14f8fd97e425 Mon Sep 17 00:00:00 2001 From: itisbryan <55928614+itisbryan@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:54:15 +0700 Subject: [PATCH 063/351] fix(pi): suppress subagent agent_end so nested sessions don't fire false notifications (#8545) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(pi): suppress subagent agent_end so nested sessions don't fire false notifications Pi/OMP run subagents as nested child processes that inherit the lead pane's env (including ORCA_PANE_KEY), each loading its own copy of the orca-agent-status.ts extension. A child's agent_end was attributed to the lead pane, firing a false "agent finished" notification on every subagent completion. Gate status reporting to the top-level Pi process per pane via an ORCA_PI_STATUS_OWNED env marker: the first process claims the pane and sets the marker; any Pi it spawns inherits it and stays silent. Keyed on process nesting (not hasUI) so a top-level non-interactive `pi -p` run still reports its own completion. * test(pi): assert nested-subagent silence for every guarded event, not just agent_end * fix(pi): key status owner on pid so extension reload doesn't silence the lead The subagent-suppression gate marked the pane owner with a boolean ORCA_PI_STATUS_OWNED='1'. Pi re-invokes an extension's default export on every in-process reload (/reload, live edit, settings reload), so the lead process re-ran the factory, read the '1' it had set on first load, and treated itself as a nested subagent — silencing all its own status (working, tools, done, notifications) for the rest of the session. Record the owning process's pid instead. A same-process reload matches its own pid and keeps reporting; a spawned subagent inherits the lead's pid, sees it differs from its own, and stays silent. Startup was unaffected (bindExtensions runs the factory once), which is why the single-run trace missed it. Add a reload regression test that re-invokes the factory in the same process and asserts the lead still reports; it fails against the boolean marker. Co-authored-by: Orca * fix(pi): gate nested status hooks at registration --------- Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-authored-by: Orca --- .../pi/agent-status-extension-source.test.ts | 75 +++++++++++++++++-- src/main/pi/agent-status-handler-source.ts | 6 ++ 2 files changed, 76 insertions(+), 5 deletions(-) diff --git a/src/main/pi/agent-status-extension-source.test.ts b/src/main/pi/agent-status-extension-source.test.ts index 47ac259f1bb1..1dff2ecccf0e 100644 --- a/src/main/pi/agent-status-extension-source.test.ts +++ b/src/main/pi/agent-status-extension-source.test.ts @@ -24,7 +24,11 @@ type Harness = { readFileSync: ReturnType } handlers: Record + processEnv: Record callHook: (name: string, event?: unknown) => Promise + // Re-invoke the extension factory in the same process (as Pi does on an + // in-process extension reload), swapping in the freshly registered handlers. + reload: () => void } const BASE_ENV = { @@ -38,9 +42,14 @@ const BASE_ENV = { ORCA_AGENT_HOOK_VERSION: '1.2.3' } satisfies Record +// Why: ownership keys on process.pid, so reload and child-process tests need +// stable, distinct identities. +const SELF_PID = 4242 + function createHarness(args: { kind: 'pi' | 'omp' env?: Record + pid?: number title?: string argv?: string[] existsSync?: (path: string) => boolean @@ -95,6 +104,7 @@ function createHarness(args: { ...BASE_ENV, ...args.env }, + pid: args.pid ?? SELF_PID, title: args.title ?? 'node', argv: args.argv ?? ['node', '/usr/bin/orca'] } @@ -134,11 +144,14 @@ function createHarness(args: { } const handlers: Record = {} - register({ - on(name: string, handler: HookHandler) { - handlers[name] = handler - } - }) + const registerInto = (target: Record): void => { + register({ + on(name: string, handler: HookHandler) { + target[name] = handler + } + }) + } + registerInto(handlers) return { fetchMock, @@ -146,8 +159,15 @@ function createHarness(args: { spawnedChildren, fsMock, handlers, + processEnv: processMock.env, callHook: async (name, event) => { await handlers[name]?.(event) + }, + reload: () => { + for (const key of Object.keys(handlers)) { + delete handlers[key] + } + registerInto(handlers) } } } @@ -167,6 +187,51 @@ describe('getPiAgentStatusExtensionSource', () => { expect(harness.spawnMock).not.toHaveBeenCalled() }) + it.each(['pi', 'omp'] as const)( + 'registers no status handlers for a nested %s subagent process', + (kind) => { + // Why: inheriting the lead's owner PID must disable the extension as a + // whole, so future hook additions cannot reopen the notification leak. + const lead = createHarness({ kind, pid: SELF_PID }) + const child = createHarness({ kind, pid: SELF_PID + 1, env: lead.processEnv }) + const grandchild = createHarness({ kind, pid: SELF_PID + 2, env: child.processEnv }) + + expect(child.handlers).toEqual({}) + expect(grandchild.handlers).toEqual({}) + expect(child.processEnv.ORCA_PI_STATUS_OWNED).toBe(String(SELF_PID)) + expect(grandchild.processEnv.ORCA_PI_STATUS_OWNED).toBe(String(SELF_PID)) + expect(child.fetchMock).not.toHaveBeenCalled() + expect(child.spawnMock).not.toHaveBeenCalled() + } + ) + + it('reports agent_end for a top-level run (including non-interactive) and claims the pane by pid', async () => { + // Why: non-interactive top-level runs still own their pane and must report. + const harness = createHarness({ kind: 'pi', pid: SELF_PID, argv: ['node', 'pi', '-p'] }) + + await harness.callHook('agent_end') + + expect(harness.fetchMock).toHaveBeenCalledTimes(1) + const body = JSON.parse(String(harness.fetchMock.mock.calls[0]?.[1]?.body)) + expect(body.payload).toEqual({ hook_event_name: 'agent_end' }) + expect(harness.processEnv.ORCA_PI_STATUS_OWNED).toBe(String(SELF_PID)) + }) + + it('keeps reporting after the lead re-runs the extension factory on reload', async () => { + // Why: Pi reloads extensions in-process, so the lead must recognize its PID + // instead of mistaking its own marker for a nested child. + const harness = createHarness({ kind: 'pi', pid: SELF_PID }) + + expect(harness.processEnv.ORCA_PI_STATUS_OWNED).toBe(String(SELF_PID)) + + harness.reload() + await harness.callHook('agent_end') + + expect(harness.fetchMock).toHaveBeenCalledTimes(1) + const body = JSON.parse(String(harness.fetchMock.mock.calls[0]?.[1]?.body)) + expect(body.payload).toEqual({ hook_event_name: 'agent_end' }) + }) + it('keeps native fetch as the only path even when the runtime looks like WSL', async () => { const harness = createHarness({ kind: 'omp', diff --git a/src/main/pi/agent-status-handler-source.ts b/src/main/pi/agent-status-handler-source.ts index 625e89df7c0c..bc002cf5741a 100644 --- a/src/main/pi/agent-status-handler-source.ts +++ b/src/main/pi/agent-status-handler-source.ts @@ -29,7 +29,13 @@ export function getPiAgentStatusHandlerSourceLines(): string[] { '// etc.), so we forward the raw object verbatim under the same field', '// names Claude uses (tool_name / tool_input) and let the server pick the', '// preview. Keeps tool-name knowledge centralized on the receiver side.', + "// Why: child agents inherit the lead's pane env; only its process may", + '// register status hooks. PID identity keeps in-process reloads reporting.', 'export default function (pi): void {', + ' const ownerPid = process.env.ORCA_PI_STATUS_OWNED', + ' const selfPid = String(process.pid)', + ' if (ownerPid && ownerPid !== selfPid) return', + ' process.env.ORCA_PI_STATUS_OWNED = selfPid', " pi.on('before_agent_start', (event) => {", " post('before_agent_start', { prompt: event.prompt ?? '' })", ' })', From 9f8b25487656dafafcdef75553579dec9119a9c0 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Wed, 15 Jul 2026 21:56:25 -0700 Subject: [PATCH 064/351] fix(terminal): route Droid Shift+Enter to CSI-u in no-OSC shells (Git Bash) (#7620) (#8948) Shift+Enter still submitted instead of inserting a newline when Droid ran in Git Bash on Windows, even after the PowerShell fix (#7620/#8065/#8427). On Windows, Orca sends Droid the kitty CSI-u byte (ESC [ 13;2u) for Shift+Enter only when the active pane has fresh, trusted process evidence that Droid is the foreground app; otherwise it falls back to the Codex-compatible ESC+CR, which Droid decodes as a plain Enter and submits. That trusted evidence comes from a confirming foreground read. PowerShell triggers that read from its OSC 133 command-start event. Git Bash (and cmd.exe) emit no OSC command boundary, and an Orca-launched agent delivers its command programmatically (no typed-command inference), so a fresh Droid spawn in those shells never earned routing trust -- leaving Shift+Enter on the ESC+CR fallback forever. Fix: on a fresh spawn into a no-OSC shell, seed the SAME command-start confirmation the manually-typed launch path already uses (paneForegroundAgentTracker.onCommandStarted). Its bounded retry ladder spans Droid boot, and -- crucially -- a miss publishes shellForeground:false, so the pane stays recoverable by later focus/reveal samples. (A visible-pty sample was rejected here: on a pathologically slow boot its known-agent shell-confirm would latch, clear launch identity, and permanently poison Shift+Enter for the rest of the session.) A real OSC 133;C, if it arrives, simply supersedes this. Verified on Windows 11 + Git Bash with the real Droid CLI and node-pty ConPTY: - Droid's TUI treats ESC [ 13;2u as a newline (two-line composer) and ESC+CR as a submit -- exactly the reported bug and the fix's mechanism. - droid.exe is a descendant of bash inside the ConPTY console process list (so the main-side scan resolves it) and becomes a live process ~0.8s after launch, well within the retry ladder. Tests: regression test drives the real connectPanePty spawn path and asserts csi-u; a slow-boot test exercises the shell->droid ladder transition; a recoverability test proves a total-miss does NOT poison the pane (it fails against the visible-pty approach and passes with onCommandStarted). --- .../terminal-pane/pty-connection.test.ts | 128 ++++++++++++++++++ .../terminal-pane/pty-connection.ts | 34 ++++- 2 files changed, 160 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 2cfae6126366..18e99dac5a75 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -5108,6 +5108,134 @@ describe('connectPanePty', () => { expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('csi-u') }) + it('confirms an Orca-launched Droid fresh spawn in a no-OSC shell (Git Bash)', async () => { + // Why: PowerShell gets an OSC 133 command-start that triggers the confirming + // foreground read, but Git Bash / cmd.exe emit no command boundary and the + // launch command is injected programmatically (no typed inference). Without a + // fresh-spawn sample the pane never earns routing trust, so Shift+Enter falls + // back to Esc+CR and Droid submits instead of inserting a newline (#7620). + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + vi.mocked(window.api.pty.getForegroundProcess).mockResolvedValue('droid') + vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('droid') + const pane = createPane(1) + const ptyId = 'pty-launched-droid-no-osc' + const tabId = 'tab-launched-droid-no-osc' + const paneKey = makePaneKey(tabId, LEAF_1) + transportFactoryQueue.push(createMockTransport(ptyId)) + + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ + tabId, + startup: { command: 'droid', launchAgent: 'droid' } + }) as never + ) + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks() + // The transport reports the fresh spawn; no OSC 133, no typed inference. + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as ((id: string) => void) | undefined + expect(onPtySpawn).toBeTypeOf('function') + onPtySpawn?.(ptyId) + // Span the bounded confirmation retry ladder while Droid boots. + await vi.advanceTimersByTimeAsync(350 + 1200 + 6000) + await flushAsyncTicks() + + expect(window.api.pty.confirmForegroundProcess).toHaveBeenCalledWith(ptyId) + expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ + agent: 'droid', + routingTrusted: true, + shellForeground: false + }) + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('csi-u') + }) + + it('trusts a launched Droid whose no-OSC boot only becomes foreground after retries', async () => { + // Why: the confirmation ladder must span Droid's boot — the shell is still + // foreground on the first read(s) before Droid takes over. + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + const foregroundResults = ['bash.exe', 'bash.exe', 'droid'] + vi.mocked(window.api.pty.confirmForegroundProcess).mockImplementation( + async () => foregroundResults.shift() ?? 'droid' + ) + const pane = createPane(1) + const ptyId = 'pty-launched-droid-slow-boot' + const tabId = 'tab-launched-droid-slow-boot' + const paneKey = makePaneKey(tabId, LEAF_1) + transportFactoryQueue.push(createMockTransport(ptyId)) + + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ tabId, startup: { command: 'droid', launchAgent: 'droid' } }) as never + ) + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks() + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as ((id: string) => void) | undefined + onPtySpawn?.(ptyId) + await vi.advanceTimersByTimeAsync(350 + 1200 + 6000) + await flushAsyncTicks() + + expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ + agent: 'droid', + routingTrusted: true, + shellForeground: false + }) + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('csi-u') + }) + + it('stays recoverable when a launched Droid outlasts the fresh-spawn confirmation window', async () => { + // Why: if the bounded ladder misses (pathologically slow boot), the miss must + // NOT latch a known-agent shell-confirm — that would clear launch identity and + // block every later focus/reveal re-sample, permanently poisoning Shift+Enter + // for the session. A benign miss leaves the pane recoverable, so a later focus + // sample (once Droid is finally foreground) still earns routing trust. + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + let foreground = 'bash.exe' + vi.mocked(window.api.pty.confirmForegroundProcess).mockImplementation(async () => foreground) + const pane = createPane(1) + const ptyId = 'pty-launched-droid-slow' + const tabId = 'tab-launched-droid-slow' + const paneKey = makePaneKey(tabId, LEAF_1) + transportFactoryQueue.push(createMockTransport(ptyId)) + + const binding = connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ tabId, startup: { command: 'droid', launchAgent: 'droid' } }) as never + ) as unknown as { sampleForegroundAgentOnFocus: () => void } + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks() + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as ((id: string) => void) | undefined + onPtySpawn?.(ptyId) + // Whole ladder elapses while the shell is still foreground (Droid not up yet). + await vi.advanceTimersByTimeAsync(350 + 1200 + 6000) + await flushAsyncTicks() + + // Benign miss: shell never latched as foreground; encoding still safe fallback. + expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ + agent: null, + shellForeground: false + }) + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') + + // Droid finally boots and a focus event re-samples: trust is recoverable. + foreground = 'droid' + binding.sampleForegroundAgentOnFocus() + await vi.advanceTimersByTimeAsync(350 + 1200 + 6000) + await flushAsyncTicks() + + expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ + agent: 'droid', + routingTrusted: true, + shellForeground: false + }) + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('csi-u') + }) + it('revokes trusted Droid after accepted no-OSC exit input until shell confirmation', async () => { vi.useFakeTimers() const { connectPanePty } = await import('./pty-connection') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index a833ea24f350..859a71bba1a2 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -1892,6 +1892,21 @@ export function connectPanePty( (isTuiAgent(registeredLaunchAgent) ? registeredLaunchAgent : undefined) ) } + // Why: the concrete TUI agent a fresh spawn is expected to launch, used to seed + // a command-start confirmation on no-OSC shells (Git Bash/cmd) that never emit + // one. Returns null when the expectation isn't a recognized TUI agent. + const resolveExpectedLaunchTuiAgent = (): TuiAgent | null => { + const state = useAppStore.getState() + const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find( + (candidate) => candidate.id === deps.tabId + ) + const candidate = + tab?.launchAgent ?? + paneStartup?.launchAgent ?? + paneStartup?.initialAgentStatus?.agent ?? + state.agentLaunchConfigByPaneKey[cacheKey]?.identity.agentType + return isTuiAgent(candidate) ? candidate : null + } // Why: a launched/hook-known agent pane must confirm — not trust — a 133;D so a // full-screen agent's leaked nested-shell 133;D can't clear its tab identity, // even on a restore where no command-start read has recorded evidence yet. @@ -2734,10 +2749,25 @@ export function connectPanePty( // frame-level sync often runs before that async result arrives. scheduleRuntimeGraphSync() agentCompletionCoordinator.startProcessTracking() - // Why: fresh spawns receive future OSC command-start events; only adopted or - // restored PTYs may already be inside Codex with no new foreground signal. + // Why: fresh spawns normally rely on a future OSC 133 command-start read to + // identify the launched agent; only adopted or restored PTYs may already be + // inside Codex with no new foreground signal. But no-OSC shells (Git Bash, + // cmd.exe) never emit a command-start, so an expected-agent pane spawned into + // one would never gain the fresh process evidence that authorizes Droid's + // Windows Shift+Enter CSI-u routing — leaving Shift+Enter to submit (#7620). + // Seed the SAME command-start confirmation the manually-typed launch path + // uses (onCommandStarted): its bounded retry ladder spans agent boot, and a + // miss publishes shellForeground:false — recoverable by later focus/reveal + // samples. A visible-pty sample would instead let a slow boot latch a + // known-agent shell-confirm that clears launch identity and blocks recovery. + // A real OSC 133;C, if it arrives, simply supersedes this. if (options.sampleVisibleForegroundAgent === true) { sampleVisiblePaneForegroundAgent() + } else if (options.seedInitialAgentStatus === true) { + const freshSpawnLaunchAgent = resolveExpectedLaunchTuiAgent() + if (freshSpawnLaunchAgent) { + paneForegroundAgentTracker.onCommandStarted(freshSpawnLaunchAgent) + } } } From 319ae4e9eafe8505212851709d70e3c663368a59 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 15 Jul 2026 22:09:37 -0700 Subject: [PATCH 065/351] fix(terminal): make whole-tab close durable (#8958) Co-authored-by: Orca --- docs/reference/terminal-session-lifecycle.md | 16 + src/cli/args.ts | 1 + src/cli/handlers/terminal.test.ts | 63 ++++ src/cli/handlers/terminal.ts | 3 +- src/cli/help.ts | 7 +- src/cli/specs/core.ts | 14 +- src/cli/terminal-format.ts | 3 + src/main/ipc/session.ts | 6 + src/main/persistence.test.ts | 53 ++++ src/main/persistence.ts | 2 +- src/main/runtime/orca-runtime.test.ts | 104 +++++++ src/main/runtime/orca-runtime.ts | 118 +++++--- src/main/runtime/rpc/errors.test.ts | 10 + src/main/runtime/rpc/errors.ts | 3 + src/main/runtime/rpc/methods/terminal.ts | 7 + src/main/runtime/runtime-rpc.ts | 1 + .../window/attach-main-window-services.ts | 2 + .../terminal-tab-close-request-relay.test.ts | 71 +++++ .../terminal-tab-close-request-relay.ts | 43 +++ src/preload/api-types.ts | 7 + src/preload/index.ts | 12 + .../terminal/terminal-tab-actions.test.ts | 21 ++ .../terminal/terminal-tab-actions.ts | 7 + src/renderer/src/hooks/useIpcEvents.test.ts | 81 ++++- src/renderer/src/hooks/useIpcEvents.ts | 35 +++ .../src/lib/session-write-subscriber.test.ts | 26 ++ ...workspace-session-host-persistence.test.ts | 69 ++++- .../lib/workspace-session-host-persistence.ts | 22 ++ src/renderer/src/web/web-preload-api.ts | 4 + src/shared/runtime-types.ts | 2 + src/shared/terminal-tab-close.ts | 9 + ...rkspace-session-terminal-tab-close.test.ts | 231 +++++++++++++++ .../workspace-session-terminal-tab-close.ts | 276 ++++++++++++++++++ ...inal-tab-close-restart-persistence.spec.ts | 112 +++++++ 34 files changed, 1384 insertions(+), 57 deletions(-) create mode 100644 src/cli/handlers/terminal.test.ts create mode 100644 src/main/window/terminal-tab-close-request-relay.test.ts create mode 100644 src/main/window/terminal-tab-close-request-relay.ts create mode 100644 src/shared/terminal-tab-close.ts create mode 100644 src/shared/workspace-session-terminal-tab-close.test.ts create mode 100644 src/shared/workspace-session-terminal-tab-close.ts create mode 100644 tests/e2e/terminal-tab-close-restart-persistence.spec.ts diff --git a/docs/reference/terminal-session-lifecycle.md b/docs/reference/terminal-session-lifecycle.md index 3f86c687245e..0b11fd15557b 100644 --- a/docs/reference/terminal-session-lifecycle.md +++ b/docs/reference/terminal-session-lifecycle.md @@ -46,6 +46,22 @@ persisted physical-to-owner pane-key alias for detach, plus a live Electron parked-close gate. Focused tests cover local, SSH, split, parked, shared, paired-host, ordinary runtime, pane-transfer, and late-spawn paths. +`orca terminal close --tab` extends that same ownership boundary to automation. +With an attached renderer, main sends a request/reply close, the renderer runs +the canonical retirement path, persists a fresh host-partitioned session, and +forces the main store to disk before acknowledging success. Without a renderer, +the runtime applies the equivalent immutable projection to the persisted +aggregate tab, removes every split binding and resume record, safely selects a +surviving browser/editor/terminal surface, stops exact live or durable SSH PTYs, +and flushes before returning. The ordinary `terminal close` pane/session +contract is unchanged. + +This durability boundary does not use replay fences or tombstones. Renderer +session writes are ordered through the same IPC sender, and a pending debounced +write rebuilds from the latest store state, so the forced fresh snapshot is not +followed by a captured pre-close topology patch. Headless closes update the +single main-owned session projection directly. + Origin-aware defensive expiry remains a follow-up. Immediate deletion on explicit close fixes the incident without applying a wall-clock policy that could invalidate intentional long-lived `worktree-sleep` checkpoints. diff --git a/src/cli/args.ts b/src/cli/args.ts index a3dc0df5349c..4392d64314c9 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -58,6 +58,7 @@ export const BOOLEAN_FLAGS = new Set([ 'run-hooks', 'show-profile', 'staged', + 'tab', 'tasks', 'text-stdin', 'unread', diff --git a/src/cli/handlers/terminal.test.ts b/src/cli/handlers/terminal.test.ts new file mode 100644 index 000000000000..7dadf41c2d33 --- /dev/null +++ b/src/cli/handlers/terminal.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeClient } from '../runtime-client' +import { parseArgs } from '../args' +import { printHelp } from '../help' +import { COMMAND_SPECS } from '../specs' +import { TERMINAL_HANDLERS } from './terminal' + +describe('terminal close CLI', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('keeps the default close RPC unchanged', async () => { + const call = vi.fn().mockResolvedValue({ + result: { close: { handle: 'term-1', tabId: 'tab-1', ptyKilled: true } } + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await TERMINAL_HANDLERS['terminal close']({ + flags: new Map([['terminal', 'term-1']]), + client: { call } as unknown as RuntimeClient, + cwd: '/tmp/worktree', + json: true + }) + + expect(call).toHaveBeenCalledWith('terminal.close', { terminal: 'term-1' }) + }) + + it('routes --tab to the durable whole-tab RPC', async () => { + const parsed = parseArgs(['terminal', 'close', '--terminal', 'term-1', '--tab']) + const call = vi.fn().mockResolvedValue({ + result: { + close: { + handle: 'term-1', + tabId: 'tab-1', + closeMode: 'tab', + ptyKilled: false + } + } + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await TERMINAL_HANDLERS['terminal close']({ + flags: parsed.flags, + client: { call } as unknown as RuntimeClient, + cwd: '/tmp/worktree', + json: true + }) + + expect(parsed.flags.get('tab')).toBe(true) + expect(call).toHaveBeenCalledWith('terminal.closeTab', { terminal: 'term-1' }) + }) + + it('documents that --tab waits for durable persistence', () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + + printHelp(COMMAND_SPECS, ['terminal', 'close']) + + const help = String(log.mock.calls[0]?.[0]) + expect(help).toContain('orca terminal close [--terminal ] [--tab] [--json]') + expect(help).toContain('durable persistence') + }) +}) diff --git a/src/cli/handlers/terminal.ts b/src/cli/handlers/terminal.ts index fb2eefc3748e..1359f5cce138 100644 --- a/src/cli/handlers/terminal.ts +++ b/src/cli/handlers/terminal.ts @@ -150,7 +150,8 @@ export const TERMINAL_HANDLERS: Record = { // `focus` resolves to this canonical path via CommandSpec.aliases before dispatch. 'terminal switch': terminalFocusHandler, 'terminal close': async ({ flags, client, cwd, json }) => { - const result = await client.call<{ close: RuntimeTerminalClose }>('terminal.close', { + const method = flags.get('tab') === true ? 'terminal.closeTab' : 'terminal.close' + const result = await client.call<{ close: RuntimeTerminalClose }>(method, { terminal: await getTerminalHandle(flags, cwd, client) }) printResult(result, json, formatTerminalClose) diff --git a/src/cli/help.ts b/src/cli/help.ts index 379bfbce1e02..11d9fd2dc201 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -82,7 +82,7 @@ Terminals: terminal split Split an existing terminal pane terminal switch Bring a terminal tab to the foreground terminal focus Alias for terminal switch - terminal close Close a terminal pane (or tab if last pane) + terminal close Close a terminal pane/session, or its whole tab with --tab Orchestration: orchestration send Send an inter-agent message @@ -224,7 +224,7 @@ Common Commands: orca terminal create [--worktree ] [--title ] [--command ] [--focus] [--json] orca terminal split [--terminal ] [--direction horizontal|vertical] [--json] orca terminal switch [--terminal ] [--json] - orca terminal close [--terminal ] [--json] + orca terminal close [--terminal ] [--tab] [--json] orca project list [--json] orca project setups [--project ] [--host ] [--json] orca project setup-existing-folder --project --host --path [--kind git|folder] [--display-name ] [--json] @@ -401,6 +401,9 @@ export function formatGroupHelp(specs: CommandSpec[], group: string): string { function formatCommandFlagHelp(flag: string, commandPath: string[]): string { const command = commandPath.join(' ') + if (command === 'terminal close' && flag === 'tab') { + return '--tab Close the whole tab and wait for durable persistence' + } if (command === 'linear issue' && flag === 'id') { return '--id Linear issue key, id, or URL' } diff --git a/src/cli/specs/core.ts b/src/cli/specs/core.ts index 3c89c5ff3a51..41d001daf582 100644 --- a/src/cli/specs/core.ts +++ b/src/cli/specs/core.ts @@ -254,10 +254,16 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [ }, { path: ['terminal', 'close'], - summary: 'Close a terminal tab (kills PTY if running)', - usage: 'orca terminal close [--terminal ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'terminal'], - examples: ['orca terminal close --terminal term_abc123'] + summary: 'Close a terminal pane/session, or its whole tab with --tab', + usage: 'orca terminal close [--terminal ] [--tab] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'terminal', 'tab'], + notes: [ + 'Without --tab, preserves the existing pane/session close behavior. With --tab, waits until the whole tab is durably removed.' + ], + examples: [ + 'orca terminal close --terminal term_abc123', + 'orca terminal close --terminal term_abc123 --tab --json' + ] }, { path: ['terminal', 'rename'], diff --git a/src/cli/terminal-format.ts b/src/cli/terminal-format.ts index a0791d6e038c..a6fbd6a95146 100644 --- a/src/cli/terminal-format.ts +++ b/src/cli/terminal-format.ts @@ -168,6 +168,9 @@ export function formatTerminalFocus(result: { focus: RuntimeTerminalFocus }): st } export function formatTerminalClose(result: { close: RuntimeTerminalClose }): string { + if (result.close.closeMode === 'tab') { + return `Closed terminal tab ${result.close.tabId} (${result.close.handle}).` + } const ptyNote = result.close.ptyKilled ? ' PTY killed.' : '' return `Closed terminal ${result.close.handle}.${ptyNote}` } diff --git a/src/main/ipc/session.ts b/src/main/ipc/session.ts index 87f2f8b63d4d..98015aa64a7c 100644 --- a/src/main/ipc/session.ts +++ b/src/main/ipc/session.ts @@ -18,6 +18,12 @@ export function registerSessionHandlers(store: Store): void { store.patchWorkspaceSession(args, hostId) }) + ipcMain.handle('session:flush', () => { + // Why: durable lifecycle RPCs must propagate disk failures instead of + // returning success through Store.flush(), which intentionally only logs. + store.flushOrThrow() + }) + // Synchronous variant for the renderer's beforeunload handler. // sendSync blocks the renderer until this returns, guaranteeing the // data (including terminal scrollback buffers) is persisted to disk diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 3564b00b82b2..57153e31fef0 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -41,6 +41,7 @@ import { toRuntimeExecutionHostId, toSshExecutionHostId } from '../shared/execut import { SshConnectionStore } from './ssh/ssh-connection-store' import { setSourceControlActionDefault } from '../shared/source-control-ai-actions' import { LEGACY_DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../shared/ssh-types' +import { closeTerminalTabInWorkspaceSession } from '../shared/workspace-session-terminal-tab-close' // Shared mutable state so the electron mock can reference a per-test directory const testState = { dir: '' } @@ -337,6 +338,58 @@ describe('Store', () => { expect(store.getRepos()).toEqual([]) }, 15_000) + it('does not restore a terminal tab after its durable close flush returns', async () => { + const store = await createStore() + const worktreeId = 'repo-1::/tmp/worktree-1' + const tabId = 'terminal-1' + const session: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + activeWorktreeId: worktreeId, + activeTabId: tabId, + tabsByWorktree: { + [worktreeId]: [ + { + id: tabId, + ptyId: 'pty-1', + worktreeId, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + [tabId]: { + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-1' } + } + }, + activeTabIdByWorktree: { [worktreeId]: tabId }, + defaultTerminalTabsAppliedByWorktreeId: { [worktreeId]: true } + } + store.setWorkspaceSession(session) + store.flushOrThrow() + + const closed = closeTerminalTabInWorkspaceSession( + store.getWorkspaceSession(), + worktreeId, + tabId + ) + store.setWorkspaceSession(closed.session) + store.flushOrThrow() + + const reloaded = await createStore() + expect(reloaded.getWorkspaceSession().tabsByWorktree[worktreeId]).toEqual([]) + expect(reloaded.getWorkspaceSession().terminalLayoutsByTabId[tabId]).toBeUndefined() + expect( + reloaded.getWorkspaceSession().defaultTerminalTabsAppliedByWorktreeId?.[worktreeId] + ).toBe(true) + }) + it('loads state from an explicit profile data file path', async () => { const profileDataDirectory = join(testState.dir, 'profiles', 'local-default') const profileDataFile = join(profileDataDirectory, 'orca-data.json') diff --git a/src/main/persistence.ts b/src/main/persistence.ts index d407b9362d3b..0a2d7821f91c 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -3806,7 +3806,7 @@ export class Store { } } - private flushOrThrow(): void { + flushOrThrow(): void { if (this.writeTimer) { clearTimeout(this.writeTimer) this.writeTimer = null diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index f64a163cfe71..50f196f26693 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -18438,6 +18438,110 @@ describe('OrcaRuntimeService', () => { expect(kill).toHaveBeenCalledWith('laptop-created-pty') }) + it('waits for renderer acknowledgement before returning a whole-tab close receipt', async () => { + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal() + ) + const acknowledged = makeDeferred() + const closeTerminalTab = vi.fn(() => acknowledged.promise) + const runtime = new OrcaRuntimeService(runtimeStore as never) + runtime.setNotifier({ closeTerminal: vi.fn(), closeTerminalTab } as never) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [] + }) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'host-tab', + worktreeId: TEST_WORKTREE_ID, + title: 'Durable', + activeLeafId: HEADLESS_LEAF_ID, + layout: null + } + ], + leaves: [ + { + tabId: 'host-tab', + worktreeId: TEST_WORKTREE_ID, + leafId: HEADLESS_LEAF_ID, + paneRuntimeId: 1, + ptyId: 'persisted-pty' + } + ] + }) + const [terminal] = (await runtime.listTerminals()).terminals + const pending = runtime.closeTerminalTab(terminal.handle) + let settled = false + void pending.finally(() => { + settled = true + }) + + await vi.waitFor(() => expect(closeTerminalTab).toHaveBeenCalledWith('host-tab')) + expect(settled).toBe(false) + + acknowledged.resolve() + await expect(pending).resolves.toEqual({ + handle: terminal.handle, + tabId: 'host-tab', + closeMode: 'tab', + ptyKilled: false + }) + }) + + it('durably closes every split leaf without a renderer', async () => { + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + { + id: 'durable-tab', + ptyId: null, + worktreeId: TEST_WORKTREE_ID, + title: 'Durable', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + 'durable-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: undefined }) + } + }) + ) + const flushOrThrow = vi.fn() + const spawn = vi + .fn() + .mockResolvedValueOnce({ id: 'headless-left' }) + .mockResolvedValueOnce({ id: 'headless-right' }) + const kill = vi.fn(() => true) + const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow } as never) + runtime.setPtyController({ + spawn, + write: () => true, + kill, + getForegroundProcess: async () => null + }) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + const terminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'durable-tab', + leafId: HEADLESS_LEAF_ID + }) + await runtime.splitTerminal(terminal.handle, { direction: 'vertical' }) + + await runtime.closeTerminalTab(terminal.handle) + + expect(kill).toHaveBeenCalledWith('headless-left') + expect(kill).toHaveBeenCalledWith('headless-right') + expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toEqual([]) + expect(getSession().terminalLayoutsByTabId['durable-tab']).toBeUndefined() + expect(flushOrThrow).toHaveBeenCalledTimes(1) + }) + it('lists PTY-backed mobile session terminals without a renderer graph', async () => { const spawn = vi.fn().mockResolvedValue({ id: 'laptop-created-pty' }) const runtime = new OrcaRuntimeService(store) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 3e7ba7e2791e..da1bbfbe603c 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -153,6 +153,7 @@ import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resum import type { RuntimeClientEvent } from '../../shared/runtime-client-events' import { toRuntimeActivateWorktreeEvent } from '../../shared/runtime-client-events' import type { SshConnectionState } from '../../shared/ssh-types' +import { closeTerminalTabInWorkspaceSession } from '../../shared/workspace-session-terminal-tab-close' import type { LinearCurrentIssueContextHints, LinearAttachResult, @@ -850,6 +851,7 @@ type RuntimeStore = { getGitHubCache: Store['getGitHubCache'] getWorkspaceSession?: Store['getWorkspaceSession'] setWorkspaceSession?: Store['setWorkspaceSession'] + flushOrThrow?: Store['flushOrThrow'] persistPtyBinding?: Store['persistPtyBinding'] getUI?: Store['getUI'] updateUI?: Store['updateUI'] @@ -1406,6 +1408,7 @@ type RuntimeNotifier = { content: string ): Promise closeTerminal(tabId: string, paneRuntimeId?: number): void + closeTerminalTab?(tabId: string): Promise sleepWorktree(worktreeId: string): void // Why: a phone opening a worktree wakes its slept agents by asking the host // renderer to run its own navigation-free wake (experimental agent sleep); @@ -4310,33 +4313,20 @@ export class OrcaRuntimeService { } } - private removePersistedHeadlessTerminalTab(worktreeId: string, parentTabId: string): void { + private removePersistedHeadlessTerminalTab(worktreeId: string, parentTabId: string): string[] { const session = this.store?.getWorkspaceSession?.() if (!session || !this.store?.setWorkspaceSession) { - return + throw new Error('workspace_session_unavailable') } - const tabs = session.tabsByWorktree[worktreeId] ?? [] - const nextTabs = tabs.filter((tab) => tab.id !== parentTabId) - const nextTabsByWorktree = { - ...session.tabsByWorktree, - [worktreeId]: nextTabs - } - const nextLayouts = { ...session.terminalLayoutsByTabId } - delete nextLayouts[parentTabId] - const nextActiveTabId = - session.activeTabIdByWorktree?.[worktreeId] === parentTabId - ? (nextTabs[0]?.id ?? null) - : (session.activeTabIdByWorktree?.[worktreeId] ?? null) - this.store.setWorkspaceSession({ - ...session, - activeTabId: session.activeTabId === parentTabId ? nextActiveTabId : session.activeTabId, - tabsByWorktree: nextTabsByWorktree, - terminalLayoutsByTabId: nextLayouts, - activeTabIdByWorktree: { - ...session.activeTabIdByWorktree, - [worktreeId]: nextActiveTabId - } - }) + const result = closeTerminalTabInWorkspaceSession(session, worktreeId, parentTabId) + if (result.pinned) { + throw new Error('terminal_tab_pinned') + } + if (!result.closed) { + throw new Error('tab_not_found') + } + this.store.setWorkspaceSession(result.session) + return result.ptyIdsToKill } private persistHeadlessTerminalTabOrder(worktreeId: string, tabOrder: readonly string[]): void { @@ -4683,8 +4673,19 @@ export class OrcaRuntimeService { throw new Error('tab_not_found') } if (tab.type === 'terminal') { + const parentLeafCount = snapshot!.tabs.filter( + (candidate) => candidate.type === 'terminal' && candidate.parentTabId === tab.parentTabId + ).length + const closingWholeParent = tab.id !== tabId || parentLeafCount <= 1 + if (closingWholeParent && this.notifier?.closeTerminalTab) { + // Why: whole-tab close is a lifecycle transaction. The renderer reply + // arrives only after canonical retirement and a forced session flush. + await this.notifier.closeTerminalTab(tab.parentTabId) + return { closed: true } + } if (!this.notifier?.closeTerminal) { this.closeHeadlessMobileTerminalTab(worktreeId, snapshot!, tab) + this.store?.flushOrThrow?.() return { closed: true } } // Why: a runtime-owned headless tab whose whole parent is being closed must @@ -4693,13 +4694,10 @@ export class OrcaRuntimeService { // keeps republishing the "closed" tab with a live PTY. Best-effort notify the // renderer too so any adopted pane closes (no dead pane). A single split leaf // (exact id, multi-leaf parent) keeps the per-leaf path so siblings survive. - const parentLeafCount = snapshot!.tabs.filter( - (candidate) => candidate.type === 'terminal' && candidate.parentTabId === tab.parentTabId - ).length - const closingWholeParent = tab.id !== tabId || parentLeafCount <= 1 if (closingWholeParent && this.isRuntimeOwnedHeadlessMobileTab(worktreeId, tab)) { this.closeHeadlessMobileTerminalTab(worktreeId, snapshot!, tab) this.notifier?.closeTerminal(tab.parentTabId) + this.store?.flushOrThrow?.() return { closed: true } } if (tab.id === tabId) { @@ -4819,24 +4817,39 @@ export class OrcaRuntimeService { tab: RuntimeMobileSessionTerminalTab ): void { const closedParentTabId = tab.parentTabId + const projectedPtyIds = this.removePersistedHeadlessTerminalTab(worktreeId, closedParentTabId) + // Why: local provider ids can be reused after restart, so a dormant + // persisted id is not kill authority. SSH relay ids remain durable exact + // identities even before pane metadata reconnects. + const ptyIdsToKill = new Set(projectedPtyIds.filter((ptyId) => parseAppSshPtyId(ptyId))) + for (const candidate of snapshot.tabs) { + if (candidate.type !== 'terminal' || candidate.parentTabId !== closedParentTabId) { + continue + } + const livePty = this.findPtyForMobileTerminalTab(worktreeId, candidate) + const ptyId = livePty?.ptyId ?? candidate.ptyId + const hasOtherOwner = snapshot.tabs.some( + (other) => + other.type === 'terminal' && + other.parentTabId !== closedParentTabId && + other.ptyId === ptyId + ) + if (ptyId && !hasOtherOwner && (livePty || parseAppSshPtyId(ptyId))) { + // Why: a live serve leaf can exist before its debounced binding reaches + // persistence. Include it from the authoritative snapshot so split + // close cannot leave a provider process behind. + ptyIdsToKill.add(ptyId) + } + } + for (const ptyId of ptyIdsToKill) { + this.ptyController?.kill(ptyId) + } const nextTabs = snapshot.tabs.filter((candidate) => { if (candidate.type !== 'terminal' || candidate.parentTabId !== closedParentTabId) { return true } - const pty = this.findPtyForMobileTerminalTab(worktreeId, candidate) - if (pty?.connected) { - this.ptyController?.kill(pty.ptyId) - } else { - const persistedSshPtyId = this.getPersistedSshPtyIdForMobileTerminalTab(candidate) - if (persistedSshPtyId) { - // Why: close is an explicit deletion. Hydrated SSH PTYs can be known - // only by durable id before reconnect repopulates pane metadata. - this.ptyController?.kill(persistedSshPtyId) - } - } return false }) - this.removePersistedHeadlessTerminalTab(worktreeId, closedParentTabId) const active = nextTabs.find((candidate) => candidate.isActive) ?? nextTabs[0] ?? null const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { ...snapshot, @@ -19397,6 +19410,24 @@ export class OrcaRuntimeService { return { handle, tabId: leaf.tabId, ptyKilled } } + async closeTerminalTab(handle: string): Promise { + const pty = this.getLivePtyForHandle(handle) + if (pty) { + const tabId = pty.pty.tabId + if (!tabId) { + throw new Error('terminal_tab_not_found') + } + await this.closeMobileSessionTab(`id:${pty.pty.worktreeId}`, tabId) + this.claudeAgentTeams.removeTeamForLeaderHandle(handle) + return { handle, tabId, closeMode: 'tab', ptyKilled: false } + } + this.assertGraphReady() + const { leaf } = this.getLiveLeafForHandle(handle) + await this.closeMobileSessionTab(`id:${leaf.worktreeId}`, leaf.tabId) + this.claudeAgentTeams.removeTeamForLeaderHandle(handle) + return { handle, tabId: leaf.tabId, closeMode: 'tab', ptyKilled: false } + } + async splitTerminal( handle: string, opts: { @@ -21907,13 +21938,6 @@ export class OrcaRuntimeService { return null } - private getPersistedSshPtyIdForMobileTerminalTab( - tab: RuntimeMobileSessionTerminalTab - ): string | null { - const ptyId = tab.ptyId ?? tab.parentLayout?.ptyIdsByLeafId?.[tab.leafId] ?? null - return ptyId && parseAppSshPtyId(ptyId) ? ptyId : null - } - private getMobileTerminalPaneKey(tab: RuntimeMobileSessionTerminalTab): string { if (isTerminalLeafId(tab.leafId)) { return makePaneKey(tab.parentTabId, tab.leafId) diff --git a/src/main/runtime/rpc/errors.test.ts b/src/main/runtime/rpc/errors.test.ts index a14dc809c543..b1670590a9c6 100644 --- a/src/main/runtime/rpc/errors.test.ts +++ b/src/main/runtime/rpc/errors.test.ts @@ -9,6 +9,16 @@ class LineageError extends Error { } describe('mapRuntimeError', () => { + it.each(['terminal_tab_close_timeout', 'terminal_tab_not_found', 'terminal_tab_pinned'])( + 'preserves the durable terminal tab close failure %s', + (code) => { + expect(mapRuntimeError('req_1', { runtimeId: 'runtime-1' }, new Error(code))).toMatchObject({ + ok: false, + error: { code, message: code } + }) + } + ) + it.each([ ['window_not_focused', 'keyboard input requires focus', 'restore-window'], ['permission_denied', 'missing DBUS_SESSION_BUS_ADDRESS', 'permissions'], diff --git a/src/main/runtime/rpc/errors.ts b/src/main/runtime/rpc/errors.ts index bb800b54617d..15c592499f67 100644 --- a/src/main/runtime/rpc/errors.ts +++ b/src/main/runtime/rpc/errors.ts @@ -43,6 +43,9 @@ const RUNTIME_PASSTHROUGH_CODES: ReadonlySet = new Set([ 'terminal_not_writable', 'terminal_exited', 'terminal_gone', + 'terminal_tab_close_timeout', + 'terminal_tab_not_found', + 'terminal_tab_pinned', 'no_active_terminal', 'repo_not_found', 'timeout', diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 445a1f204e29..ec85e873b9f7 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -1409,6 +1409,13 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ close: await runtime.closeTerminal(params.terminal) }) }), + defineMethod({ + name: 'terminal.closeTab', + params: TerminalHandle, + handler: async (params, { runtime }) => ({ + close: await runtime.closeTerminalTab(params.terminal) + }) + }), defineMethod({ name: 'agentTeams.tmuxCompat', params: AgentTeamsTmuxCompat, diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 62e1af606df7..6dd4856b738c 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -376,6 +376,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'agentTeams.tmuxCompat', 'terminal.clearBuffer', 'terminal.close', + 'terminal.closeTab', 'terminal.create', 'terminal.focus', 'terminal.agentStatus', diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 0dd54c2c5983..21325c3e0366 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -38,6 +38,7 @@ import type { import type { RuntimeMobileSessionTabMove } from '../../shared/runtime-types' import { isNativeFileDropPayload, type NativeFileDropPayload } from '../../shared/native-file-drop' import { requestMobileMarkdownFromRenderer } from './mobile-markdown-request-relay' +import { requestTerminalTabCloseFromRenderer } from './terminal-tab-close-request-relay' import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' import type { ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection' import { runWorktreeChangeInvalidators } from '../ipc/worktree-change-invalidators' @@ -389,6 +390,7 @@ function registerRuntimeWindowLifecycle( content }) as Promise, closeTerminal: (tabId, paneRuntimeId) => send('ui:closeTerminal', { tabId, paneRuntimeId }), + closeTerminalTab: (tabId) => requestTerminalTabCloseFromRenderer(mainWindow, tabId), sleepWorktree: (worktreeId) => send('ui:sleepWorktree', { worktreeId }), resumeSleepingAgents: (worktreeId) => send('ui:resumeSleepingAgents', { worktreeId }), terminalFitOverrideChanged: (ptyId, mode, cols, rows) => diff --git a/src/main/window/terminal-tab-close-request-relay.test.ts b/src/main/window/terminal-tab-close-request-relay.test.ts new file mode 100644 index 000000000000..49eee1ce2995 --- /dev/null +++ b/src/main/window/terminal-tab-close-request-relay.test.ts @@ -0,0 +1,71 @@ +import { EventEmitter } from 'node:events' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const ipcEmitter = new EventEmitter() +const ipcMainMock = { + on: vi.fn((channel: string, listener: (...args: unknown[]) => void) => { + ipcEmitter.on(channel, listener) + }), + removeListener: vi.fn((channel: string, listener: (...args: unknown[]) => void) => { + ipcEmitter.removeListener(channel, listener) + }) +} + +vi.mock('electron', () => ({ ipcMain: ipcMainMock })) + +describe('requestTerminalTabCloseFromRenderer', () => { + beforeEach(() => { + ipcEmitter.removeAllListeners() + ipcMainMock.on.mockClear() + ipcMainMock.removeListener.mockClear() + }) + + it('waits for the targeted renderer durability acknowledgement', async () => { + const { requestTerminalTabCloseFromRenderer } = + await import('./terminal-tab-close-request-relay') + const webContents = { isDestroyed: () => false, send: vi.fn() } + const otherWebContents = {} + const mainWindow = { isDestroyed: () => false, webContents } + const pending = requestTerminalTabCloseFromRenderer(mainWindow as never, 'tab-1') + const request = webContents.send.mock.calls[0]?.[1] as { requestId: string; tabId: string } + + expect(request.tabId).toBe('tab-1') + ipcEmitter.emit( + 'ui:terminalTabCloseResponse', + { sender: otherWebContents }, + { requestId: request.requestId } + ) + let settled = false + void pending.finally(() => { + settled = true + }) + await Promise.resolve() + expect(settled).toBe(false) + + ipcEmitter.emit( + 'ui:terminalTabCloseResponse', + { sender: webContents }, + { requestId: request.requestId } + ) + await expect(pending).resolves.toBeUndefined() + }) + + it('propagates renderer cancellation instead of reporting success', async () => { + const { requestTerminalTabCloseFromRenderer } = + await import('./terminal-tab-close-request-relay') + const webContents = { isDestroyed: () => false, send: vi.fn() } + const pending = requestTerminalTabCloseFromRenderer( + { isDestroyed: () => false, webContents } as never, + 'tab-pinned' + ) + const request = webContents.send.mock.calls[0]?.[1] as { requestId: string } + + ipcEmitter.emit( + 'ui:terminalTabCloseResponse', + { sender: webContents }, + { requestId: request.requestId, error: 'terminal_tab_pinned' } + ) + + await expect(pending).rejects.toThrow('terminal_tab_pinned') + }) +}) diff --git a/src/main/window/terminal-tab-close-request-relay.ts b/src/main/window/terminal-tab-close-request-relay.ts new file mode 100644 index 000000000000..c788f79a95b4 --- /dev/null +++ b/src/main/window/terminal-tab-close-request-relay.ts @@ -0,0 +1,43 @@ +import { randomUUID } from 'node:crypto' + +import { ipcMain } from 'electron' +import type { BrowserWindow } from 'electron' +import type { + TerminalTabCloseRequest, + TerminalTabCloseResponse +} from '../../shared/terminal-tab-close' + +const TERMINAL_TAB_CLOSE_TIMEOUT_MS = 20_000 + +export async function requestTerminalTabCloseFromRenderer( + mainWindow: BrowserWindow, + tabId: string +): Promise { + if (mainWindow.isDestroyed() || mainWindow.webContents.isDestroyed()) { + throw new Error('renderer_unavailable') + } + const requestId = randomUUID() + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + ipcMain.removeListener('ui:terminalTabCloseResponse', onResponse) + reject(new Error('terminal_tab_close_timeout')) + }, TERMINAL_TAB_CLOSE_TIMEOUT_MS) + const onResponse = (event: Electron.IpcMainEvent, response: TerminalTabCloseResponse): void => { + // Why: request IDs are visible to renderer code; only the selected main + // window may commit or reject its lifecycle transaction. + if (event.sender !== mainWindow.webContents || response.requestId !== requestId) { + return + } + clearTimeout(timeout) + ipcMain.removeListener('ui:terminalTabCloseResponse', onResponse) + if (response.error) { + reject(new Error(response.error)) + } else { + resolve() + } + } + ipcMain.on('ui:terminalTabCloseResponse', onResponse) + const request: TerminalTabCloseRequest = { requestId, tabId } + mainWindow.webContents.send('ui:terminalTabCloseRequest', request) + }) +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 9d5e0b1fd90f..695e037379aa 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -9,6 +9,10 @@ import type { HostedReviewProvider } from '../shared/hosted-review' import type { NativeFileDropPayload } from '../shared/native-file-drop' +import type { + TerminalTabCloseRequest, + TerminalTabCloseResponse +} from '../shared/terminal-tab-close' import type { LocalLogTailChangedPayload, LocalLogTailReadArgs, @@ -2361,6 +2365,7 @@ export type PreloadApi = { get: (hostId?: ExecutionHostId) => Promise set: (args: WorkspaceSessionState, hostId?: ExecutionHostId) => Promise patch: (args: WorkspaceSessionPatch, hostId?: ExecutionHostId) => Promise + flush: () => Promise readTerminalScrollback: (args: { ref: string }) => string | null setSync: (args: WorkspaceSessionState, hostId?: ExecutionHostId) => void } @@ -2915,6 +2920,8 @@ export type PreloadApi = { onCloseTerminal: ( callback: (data: { tabId: string; paneRuntimeId?: number }) => void ) => () => void + onTerminalTabCloseRequest: (callback: (request: TerminalTabCloseRequest) => void) => () => void + respondTerminalTabClose: (response: TerminalTabCloseResponse) => void onSleepWorktree: (callback: (data: { worktreeId: string }) => void) => () => void onResumeSleepingAgents: (callback: (data: { worktreeId: string }) => void) => () => void onTerminalZoom: (callback: (direction: 'in' | 'out' | 'reset') => void) => () => void diff --git a/src/preload/index.ts b/src/preload/index.ts index 48e05ac67f10..64276047cea7 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2711,6 +2711,7 @@ const api = { get: (hostId) => ipcRenderer.invoke('session:get', hostId), set: (args, hostId) => ipcRenderer.invoke('session:set', args, hostId), patch: (args, hostId) => ipcRenderer.invoke('session:patch', args, hostId), + flush: () => ipcRenderer.invoke('session:flush'), readTerminalScrollback: (args) => ipcRenderer.sendSync('session:read-terminal-scrollback-sync', args), /** Synchronous session save for beforeunload — blocks until flushed to disk. */ @@ -3692,6 +3693,17 @@ const api = { ipcRenderer.on('ui:closeTerminal', listener) return () => ipcRenderer.removeListener('ui:closeTerminal', listener) }, + onTerminalTabCloseRequest: (callback) => { + const listener = ( + _event: Electron.IpcRendererEvent, + request: Parameters[0] + ) => callback(request) + ipcRenderer.on('ui:terminalTabCloseRequest', listener) + return () => ipcRenderer.removeListener('ui:terminalTabCloseRequest', listener) + }, + respondTerminalTabClose: (response) => { + ipcRenderer.send('ui:terminalTabCloseResponse', response) + }, onSleepWorktree: (callback: (data: { worktreeId: string }) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, data: { worktreeId: string }) => callback(data) diff --git a/src/renderer/src/components/terminal/terminal-tab-actions.test.ts b/src/renderer/src/components/terminal/terminal-tab-actions.test.ts index ce310190eef4..7c7bb511efc0 100644 --- a/src/renderer/src/components/terminal/terminal-tab-actions.test.ts +++ b/src/renderer/src/components/terminal/terminal-tab-actions.test.ts @@ -442,6 +442,27 @@ describe('closeTerminalTab', () => { expect(onCancel).toHaveBeenCalledTimes(1) }) + it('rejects a pinned background lifecycle close without opening a confirmation modal', () => { + const requestPinnedTabCloseConfirm = vi.fn() + const closeUnifiedTab = vi.fn() + const onClosed = vi.fn() + const onCancel = vi.fn() + getStateMock.mockReturnValue( + makePinnedTabState({ + confirmClosePinnedTab: true, + requestPinnedTabCloseConfirm, + closeUnifiedTab + }) + ) + + closeTerminalTab('pinned-entity-1', { rejectPinned: true, onClosed, onCancel }) + + expect(requestPinnedTabCloseConfirm).not.toHaveBeenCalled() + expect(closeUnifiedTab).not.toHaveBeenCalled() + expect(onClosed).not.toHaveBeenCalled() + expect(onCancel).toHaveBeenCalledTimes(1) + }) + it('guards a pinned tab closed by its unified id (workspace overlay path)', () => { const requestPinnedTabCloseConfirm = vi.fn() const closeUnifiedTab = vi.fn() diff --git a/src/renderer/src/components/terminal/terminal-tab-actions.ts b/src/renderer/src/components/terminal/terminal-tab-actions.ts index 3f41e8315bfc..ebd861e6952e 100644 --- a/src/renderer/src/components/terminal/terminal-tab-actions.ts +++ b/src/renderer/src/components/terminal/terminal-tab-actions.ts @@ -49,6 +49,7 @@ export function closeTerminalTab( tabId: string, options?: { force?: boolean + rejectPinned?: boolean reason?: TerminalTabCloseReason captureRecentlyClosed?: boolean localPtyTeardownOwnedExternally?: boolean @@ -78,6 +79,12 @@ export function closeTerminalTab( !options?.force && isPinnedVisibleTab(state, owningWorktreeId, terminalTabId) ) { + // Why: background lifecycle callers cannot safely wait on a modal whose + // owner may be unattended; reject pinned tabs without bypassing the guard. + if (options?.rejectPinned) { + options.onCancel?.() + return + } guardPinnedTabClose({ isPinned: true, tabLabel: resolvePinnedTabLabel(state, owningWorktreeId, terminalTabId), diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 9e188a808052..86791576c1cb 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -2688,6 +2688,7 @@ describe('useIpcEvents browser tab close routing', () => { type CloseActiveTabListener = () => void type CloseTerminalListener = (data: { tabId: string; paneRuntimeId?: number | null }) => void type CloseSessionTabListener = (data: { tabId: string; worktreeId: string }) => void + type TerminalTabCloseRequestListener = (data: { requestId: string; tabId: string }) => void async function useIpcEventsForCloseRouting({ closeActiveTabListenerRef, @@ -2695,7 +2696,10 @@ describe('useIpcEvents browser tab close routing', () => { closeTerminalListenerRef, getState, requestTabCloseListenerRef, - replyTabClose = vi.fn() + replyTabClose = vi.fn(), + terminalTabCloseRequestListenerRef, + respondTerminalTabClose = vi.fn(), + persistWorkspaceSession = vi.fn().mockResolvedValue(undefined) }: { closeActiveTabListenerRef?: { current: CloseActiveTabListener | null } closeSessionTabListenerRef?: { current: CloseSessionTabListener | null } @@ -2703,6 +2707,9 @@ describe('useIpcEvents browser tab close routing', () => { getState: () => Record requestTabCloseListenerRef?: { current: RequestTabCloseListener | null } replyTabClose?: ReturnType + terminalTabCloseRequestListenerRef?: { current: TerminalTabCloseRequestListener | null } + respondTerminalTabClose?: ReturnType + persistWorkspaceSession?: ReturnType }): Promise { vi.doMock('react', async () => { const actual = await vi.importActual('react') @@ -2783,6 +2790,12 @@ describe('useIpcEvents browser tab close routing', () => { vi.doMock('@/lib/zoom-events', () => ({ dispatchZoomLevelChanged: vi.fn() })) + vi.doMock('@/lib/workspace-session-host-persistence', () => ({ + persistWorkspaceSessionByHost: persistWorkspaceSession + })) + vi.doMock('@/lib/workspace-session', () => ({ + buildWorkspaceSessionPayload: vi.fn(() => ({})) + })) vi.stubGlobal('window', { dispatchEvent: vi.fn(), @@ -2832,6 +2845,13 @@ describe('useIpcEvents browser tab close routing', () => { } return () => {} }, + onTerminalTabCloseRequest: (listener: TerminalTabCloseRequestListener) => { + if (terminalTabCloseRequestListenerRef) { + terminalTabCloseRequestListenerRef.current = listener + } + return () => {} + }, + respondTerminalTabClose, onSleepWorktree: () => () => {}, onResumeSleepingAgents: () => () => {}, onNewBrowserTab: () => () => {}, @@ -2979,6 +2999,65 @@ describe('useIpcEvents browser tab close routing', () => { expect(closeTerminalTabMock).toHaveBeenCalledWith('terminal-1') }) + it('acknowledges whole-tab close only after the fresh session is durably persisted', async () => { + const listenerRef: { current: TerminalTabCloseRequestListener | null } = { current: null } + let finishPersist!: () => void + const persistWorkspaceSession = vi.fn( + () => + new Promise((resolve) => { + finishPersist = resolve + }) + ) + const respondTerminalTabClose = vi.fn() + closeTerminalTabMock.mockImplementation((_tabId: string, options: { onClosed?: () => void }) => + options.onClosed?.() + ) + await useIpcEventsForCloseRouting({ + getState: () => ({}), + terminalTabCloseRequestListenerRef: listenerRef, + respondTerminalTabClose, + persistWorkspaceSession + }) + + listenerRef.current?.({ requestId: 'close-1', tabId: 'terminal-1' }) + await Promise.resolve() + + expect(closeTerminalTabMock).toHaveBeenCalledWith( + 'terminal-1', + expect.objectContaining({ rejectPinned: true }) + ) + expect(persistWorkspaceSession).toHaveBeenCalledTimes(1) + expect(respondTerminalTabClose).not.toHaveBeenCalled() + + finishPersist() + await vi.waitFor(() => + expect(respondTerminalTabClose).toHaveBeenCalledWith({ requestId: 'close-1' }) + ) + }) + + it('rejects a pinned whole-tab close without persisting or reporting success', async () => { + const listenerRef: { current: TerminalTabCloseRequestListener | null } = { current: null } + const persistWorkspaceSession = vi.fn().mockResolvedValue(undefined) + const respondTerminalTabClose = vi.fn() + closeTerminalTabMock.mockImplementation((_tabId: string, options: { onCancel?: () => void }) => + options.onCancel?.() + ) + await useIpcEventsForCloseRouting({ + getState: () => ({}), + terminalTabCloseRequestListenerRef: listenerRef, + respondTerminalTabClose, + persistWorkspaceSession + }) + + listenerRef.current?.({ requestId: 'close-pinned', tabId: 'terminal-pinned' }) + + expect(persistWorkspaceSession).not.toHaveBeenCalled() + expect(respondTerminalTabClose).toHaveBeenCalledWith({ + requestId: 'close-pinned', + error: 'terminal_tab_pinned' + }) + }) + it('confirms before closing a pinned active browser tab from the native close event', async () => { const closeActiveTabListenerRef: { current: CloseActiveTabListener | null } = { current: null } const closeBrowserTab = vi.fn() diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 661fcca0b425..e84df727e4d6 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -98,6 +98,7 @@ import { collectLeafIdsInOrder } from '@/components/terminal-pane/layout-seriali import { track } from '@/lib/telemetry' import { singlePaneLayoutSnapshot } from '@/store/slices/terminal-helpers' import { buildWorkspaceSessionPayload } from '@/lib/workspace-session' +import { persistWorkspaceSessionByHost } from '@/lib/workspace-session-host-persistence' import { getLinearIssueWorkspaceName } from '../../../shared/workspace-name' import type { RuntimeClientEvent } from '../../../shared/runtime-client-events' import type { AppState } from '../store/types' @@ -2009,6 +2010,40 @@ export function useIpcEvents(): void { }) ) + // Why: during an in-place renderer reload, an older preload can briefly + // remain installed. Keep the new request listener additive at that seam. + if (window.api.ui.onTerminalTabCloseRequest) { + unsubs.push( + window.api.ui.onTerminalTabCloseRequest(({ requestId, tabId }) => { + let responded = false + const respond = (error?: string): void => { + if (responded) { + return + } + responded = true + window.api.ui.respondTerminalTabClose({ requestId, ...(error ? { error } : {}) }) + } + closeTerminalTab(tabId, { + rejectPinned: true, + onCancel: () => respond('terminal_tab_pinned'), + onClosed: () => { + void (async () => { + const state = useAppStore.getState() + await persistWorkspaceSessionByHost( + window.api.session, + buildWorkspaceSessionPayload(state), + state + ) + respond() + })().catch((error: unknown) => { + respond(error instanceof Error ? error.message : 'terminal_tab_close_failed') + }) + } + }) + }) + ) + } + unsubs.push( window.api.ui.onSleepWorktree(({ worktreeId }) => { void runSleepWorktree(worktreeId) diff --git a/src/renderer/src/lib/session-write-subscriber.test.ts b/src/renderer/src/lib/session-write-subscriber.test.ts index 703c54058348..3c31296095a1 100644 --- a/src/renderer/src/lib/session-write-subscriber.test.ts +++ b/src/renderer/src/lib/session-write-subscriber.test.ts @@ -601,6 +601,32 @@ describe('createSessionWriteSubscriber', () => { cleanup() }) + it('rebuilds a pending tab patch after close so it cannot resurrect the tab', () => { + const persist = vi.fn<(payload: WorkspaceSessionWrite) => void>() + const cleanup = createSessionWriteSubscriber({ store: useAppStore, persist }) + + useAppStore.setState({ + workspaceSessionReady: true, + hydrationSucceeded: true, + ...makeTerminalSessionState('shell') + }) + vi.advanceTimersByTime(50) + useAppStore.setState({ + tabsByWorktree: { 'wt-1': [] }, + unifiedTabsByWorktree: { 'wt-1': [] }, + groupsByWorktree: { 'wt-1': [] }, + layoutByWorktree: {}, + activeGroupIdByWorktree: {}, + activeTabId: null + }) + vi.advanceTimersByTime(200) + + expect(persist).toHaveBeenCalledTimes(1) + expect(persist.mock.calls[0][0].patch.tabsByWorktree?.['wt-1']).toEqual([]) + expect(persist.mock.calls[0][0].patch.unifiedTabs?.['wt-1']).toBeUndefined() + cleanup() + }) + it('cleanup unsubscribes and cancels a pending timer', () => { const persist = vi.fn<(payload: WorkspaceSessionWrite) => void>() const cleanup = createSessionWriteSubscriber({ store: useAppStore, persist }) diff --git a/src/renderer/src/lib/workspace-session-host-persistence.test.ts b/src/renderer/src/lib/workspace-session-host-persistence.test.ts index d2bf7336a9ef..6c6db4933412 100644 --- a/src/renderer/src/lib/workspace-session-host-persistence.test.ts +++ b/src/renderer/src/lib/workspace-session-host-persistence.test.ts @@ -6,7 +6,8 @@ import { buildHostIdByWorktreeId, fetchWorkspaceSessionFromHosts, fetchWorkspaceSessionWithRuntimeHostOwners, - patchWorkspaceSessionByHost + patchWorkspaceSessionByHost, + persistWorkspaceSessionByHost } from './workspace-session-host-persistence' describe('fetchWorkspaceSessionFromHosts', () => { @@ -384,3 +385,69 @@ describe('fetchWorkspaceSessionFromHosts', () => { expect(owner(worktreeWorkspaceKey(worktreeId))).toBe('runtime:env-1') }) }) + +describe('persistWorkspaceSessionByHost', () => { + it('awaits every host write before crossing the durable flush boundary', async () => { + const localWorktreeId = 'local-repo::/src/local' + const remoteWorktreeId = 'remote-repo::/srv/remote' + const payload: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [localWorktreeId]: [ + { + id: 'local-tab', + ptyId: null, + worktreeId: localWorktreeId, + title: 'Local', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ], + [remoteWorktreeId]: [ + { + id: 'remote-tab', + ptyId: null, + worktreeId: remoteWorktreeId, + title: 'Remote', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + } + const set = vi.fn().mockResolvedValue(undefined) + const flush = vi.fn().mockResolvedValue(undefined) + + await persistWorkspaceSessionByHost( + { get: vi.fn(), set, patch: vi.fn(), flush, setSync: vi.fn() }, + payload, + { + repos: [ + { id: 'local-repo', connectionId: null, executionHostId: 'local' }, + { id: 'remote-repo', connectionId: null, executionHostId: 'runtime:env-1' } + ], + worktreesByRepo: { + 'local-repo': [{ id: localWorktreeId, repoId: 'local-repo' }], + 'remote-repo': [{ id: remoteWorktreeId, repoId: 'remote-repo', hostId: 'runtime:env-1' }] + } + } + ) + + expect(set).toHaveBeenCalledTimes(2) + expect(set).toHaveBeenCalledWith( + expect.objectContaining({ tabsByWorktree: { [localWorktreeId]: expect.any(Array) } }) + ) + expect(set).toHaveBeenCalledWith( + expect.objectContaining({ + tabsByWorktree: { [remoteWorktreeId]: expect.any(Array) } + }), + 'runtime:env-1' + ) + expect(flush).toHaveBeenCalledTimes(1) + expect(set.mock.invocationCallOrder.at(-1)).toBeLessThan(flush.mock.invocationCallOrder[0]!) + }) +}) diff --git a/src/renderer/src/lib/workspace-session-host-persistence.ts b/src/renderer/src/lib/workspace-session-host-persistence.ts index f59a827b7295..ab7b04c2fed4 100644 --- a/src/renderer/src/lib/workspace-session-host-persistence.ts +++ b/src/renderer/src/lib/workspace-session-host-persistence.ts @@ -33,6 +33,11 @@ type SessionApi = { setSync: (args: WorkspaceSessionState, hostId?: ExecutionHostId) => void } +type DurableSessionApi = SessionApi & { + set: (args: WorkspaceSessionState, hostId?: ExecutionHostId) => Promise + flush: () => Promise +} + export type WorkspaceSessionHostRead = { session: WorkspaceSessionState runtimeHostIdByWorkspaceSessionKey: Record @@ -223,6 +228,23 @@ export function patchWorkspaceSessionByHost( return localWrite } +/** Persist a fresh full snapshot to every owning host partition, then force the + * main store to disk. Used by request/reply lifecycle operations whose success + * receipt is a durability boundary rather than a debounced UI update. */ +export async function persistWorkspaceSessionByHost( + api: DurableSessionApi, + payload: WorkspaceSessionState, + state: HostPersistenceState +): Promise { + const slices = splitWorkspaceSessionByHost(payload, buildHostIdByWorktreeId(state)) + const writes: Promise[] = [api.set(slices[LOCAL_EXECUTION_HOST_ID] ?? payload)] + for (const [hostId, slice] of nonLocalEntries(slices)) { + writes.push(api.set(slice, hostId)) + } + await Promise.all(writes) + await api.flush() +} + /** Synchronous full-session split for the beforeunload / quit paths. */ export function persistWorkspaceSessionByHostSync( api: SessionApi, diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 5fa4365a3449..61c0cb560666 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -650,6 +650,8 @@ function createWebPreloadApi(): Partial { }) ) }, + // localStorage writes synchronously, so there is no deferred web flush. + flush: async () => {}, readTerminalScrollback: () => null, setSync: (session, hostId) => { writeJson(sessionStorageKeyForHost(hostId), sanitizeWebRuntimeWorkspaceSession(session)) @@ -2437,6 +2439,8 @@ function createWebUiApi(): NonNullable['ui']> { onMobileMarkdownRequest: () => noopUnsubscribe, respondMobileMarkdownRequest: () => {}, onCloseTerminal: () => noopUnsubscribe, + onTerminalTabCloseRequest: () => noopUnsubscribe, + respondTerminalTabClose: () => {}, onSleepWorktree: () => noopUnsubscribe, // Why: paired web is a full renderer that wakes on activation; mobile wake is // desktop-host-scoped, so the web client never receives this signal. diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 41a800eb3dac..774114b6d751 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -547,6 +547,8 @@ export type RuntimeTerminalFocus = { export type RuntimeTerminalClose = { handle: string tabId: string + /** Present for the durable whole-tab lifecycle without changing legacy receipts. */ + closeMode?: 'tab' ptyKilled: boolean } diff --git a/src/shared/terminal-tab-close.ts b/src/shared/terminal-tab-close.ts new file mode 100644 index 000000000000..55186d8046c8 --- /dev/null +++ b/src/shared/terminal-tab-close.ts @@ -0,0 +1,9 @@ +export type TerminalTabCloseRequest = { + requestId: string + tabId: string +} + +export type TerminalTabCloseResponse = { + requestId: string + error?: string +} diff --git a/src/shared/workspace-session-terminal-tab-close.test.ts b/src/shared/workspace-session-terminal-tab-close.test.ts new file mode 100644 index 000000000000..66bb3d5dd63a --- /dev/null +++ b/src/shared/workspace-session-terminal-tab-close.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, it } from 'vitest' +import { getDefaultWorkspaceSession } from './constants' +import type { Tab, TerminalTab, WorkspaceSessionState } from './types' +import { closeTerminalTabInWorkspaceSession } from './workspace-session-terminal-tab-close' + +const WORKTREE_ID = 'worktree-1' + +function terminalTab(id: string, ptyId: string | null, isPinned = false): TerminalTab { + return { + id, + ptyId, + worktreeId: WORKTREE_ID, + title: id, + customTitle: null, + color: null, + isPinned, + sortOrder: 0, + createdAt: 1 + } +} + +function unifiedTab( + id: string, + entityId: string, + contentType: Tab['contentType'], + groupId = 'group-1', + isPinned = false +): Tab { + return { + id, + entityId, + groupId, + worktreeId: WORKTREE_ID, + contentType, + label: id, + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1, + isPinned + } +} + +function session(overrides: Partial = {}): WorkspaceSessionState { + return { + ...getDefaultWorkspaceSession(), + activeWorktreeId: WORKTREE_ID, + activeTabId: 'terminal-1', + tabsByWorktree: { + [WORKTREE_ID]: [terminalTab('terminal-1', 'pty-left')] + }, + terminalLayoutsByTabId: { + 'terminal-1': { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: 'leaf-left' }, + second: { type: 'leaf', leafId: 'leaf-right' } + }, + activeLeafId: 'leaf-left', + expandedLeafId: null, + ptyIdsByLeafId: { 'leaf-left': 'pty-left', 'leaf-right': 'pty-right' } + } + }, + activeTabIdByWorktree: { [WORKTREE_ID]: 'terminal-1' }, + unifiedTabs: { + [WORKTREE_ID]: [unifiedTab('terminal-1', 'terminal-1', 'terminal')] + }, + tabGroups: { + [WORKTREE_ID]: [ + { + id: 'group-1', + worktreeId: WORKTREE_ID, + activeTabId: 'terminal-1', + tabOrder: ['terminal-1'], + recentTabIds: ['terminal-1'] + } + ] + }, + tabGroupLayouts: { [WORKTREE_ID]: { type: 'leaf', groupId: 'group-1' } }, + activeGroupIdByWorktree: { [WORKTREE_ID]: 'group-1' }, + defaultTerminalTabsAppliedByWorktreeId: { [WORKTREE_ID]: true }, + ...overrides + } +} + +describe('closeTerminalTabInWorkspaceSession', () => { + it('atomically removes a dormant split tab and returns every exact PTY', () => { + const result = closeTerminalTabInWorkspaceSession( + session({ + remoteSessionIdsByTabId: { 'terminal-1': 'pty-remote' }, + sleepingAgentSessionsByPaneKey: { + 'terminal-1:leaf-left': { + paneKey: 'terminal-1:leaf-left', + tabId: 'terminal-1', + worktreeId: WORKTREE_ID, + agent: 'codex', + providerSession: { key: 'session_id', id: 'session-1' }, + prompt: 'continue', + state: 'working', + capturedAt: 1, + updatedAt: 1 + } + } + }), + WORKTREE_ID, + 'terminal-1' + ) + + expect(result).toMatchObject({ closed: true, pinned: false }) + expect(result.ptyIdsToKill.sort()).toEqual(['pty-left', 'pty-remote', 'pty-right']) + expect(result.session.tabsByWorktree[WORKTREE_ID]).toEqual([]) + expect(result.session.terminalLayoutsByTabId['terminal-1']).toBeUndefined() + expect(result.session.remoteSessionIdsByTabId?.['terminal-1']).toBeUndefined() + expect(result.session.sleepingAgentSessionsByPaneKey).toEqual({}) + expect(result.session.defaultTerminalTabsAppliedByWorktreeId?.[WORKTREE_ID]).toBe(true) + }) + + it('does not kill a PTY still owned by another terminal tab', () => { + const current = session({ + tabsByWorktree: { + [WORKTREE_ID]: [ + terminalTab('terminal-1', 'shared-pty'), + terminalTab('terminal-2', 'shared-pty') + ] + }, + terminalLayoutsByTabId: { + 'terminal-1': { + root: { type: 'leaf', leafId: 'leaf-1' }, + activeLeafId: 'leaf-1', + expandedLeafId: null, + ptyIdsByLeafId: { 'leaf-1': 'shared-pty' } + }, + 'terminal-2': { + root: { type: 'leaf', leafId: 'leaf-2' }, + activeLeafId: 'leaf-2', + expandedLeafId: null, + ptyIdsByLeafId: { 'leaf-2': 'shared-pty' } + } + } + }) + + const result = closeTerminalTabInWorkspaceSession(current, WORKTREE_ID, 'terminal-1') + + expect(result.ptyIdsToKill).toEqual([]) + expect(result.session.tabsByWorktree[WORKTREE_ID]?.map((tab) => tab.id)).toEqual(['terminal-2']) + }) + + it('lands on the active browser survivor instead of an empty terminal group', () => { + const current = session({ + browserTabsByWorktree: { + [WORKTREE_ID]: [ + { + id: 'browser-1', + worktreeId: WORKTREE_ID, + url: 'https://example.com', + title: 'Docs', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 1 + } + ] + }, + activeBrowserTabIdByWorktree: { [WORKTREE_ID]: 'browser-1' }, + unifiedTabs: { + [WORKTREE_ID]: [ + unifiedTab('terminal-1', 'terminal-1', 'terminal'), + unifiedTab('browser-1', 'browser-1', 'browser') + ] + }, + tabGroups: { + [WORKTREE_ID]: [ + { + id: 'group-1', + worktreeId: WORKTREE_ID, + activeTabId: 'terminal-1', + tabOrder: ['terminal-1', 'browser-1'], + recentTabIds: ['browser-1', 'terminal-1'] + } + ] + } + }) + + const result = closeTerminalTabInWorkspaceSession(current, WORKTREE_ID, 'terminal-1') + + expect(result.session.tabGroups?.[WORKTREE_ID]?.[0]?.activeTabId).toBe('browser-1') + expect(result.session.activeTabTypeByWorktree?.[WORKTREE_ID]).toBe('browser') + expect(result.session.activeBrowserTabIdByWorktree?.[WORKTREE_ID]).toBe('browser-1') + expect(result.session.activeWorktreeId).toBe(WORKTREE_ID) + }) + + it('rejects pinned tabs without mutating the session', () => { + const current = session({ + tabsByWorktree: { [WORKTREE_ID]: [terminalTab('terminal-1', 'pty-left', true)] } + }) + + const result = closeTerminalTabInWorkspaceSession(current, WORKTREE_ID, 'terminal-1') + + expect(result).toEqual({ session: current, ptyIdsToKill: [], closed: false, pinned: true }) + }) + + it('has no bounded replay window after more than 32 closes', () => { + let current = getDefaultWorkspaceSession() + for (let index = 0; index < 40; index += 1) { + const id = `terminal-${index}` + current = { + ...current, + tabsByWorktree: { + ...current.tabsByWorktree, + [WORKTREE_ID]: [terminalTab(id, `pty-${index}`)] + }, + terminalLayoutsByTabId: { + ...current.terminalLayoutsByTabId, + [id]: { + root: { type: 'leaf', leafId: `leaf-${index}` }, + activeLeafId: `leaf-${index}`, + expandedLeafId: null, + ptyIdsByLeafId: { [`leaf-${index}`]: `pty-${index}` } + } + } + } + current = closeTerminalTabInWorkspaceSession(current, WORKTREE_ID, id).session + } + + expect(current.tabsByWorktree[WORKTREE_ID]).toEqual([]) + expect(current.terminalLayoutsByTabId).toEqual({}) + }) +}) diff --git a/src/shared/workspace-session-terminal-tab-close.ts b/src/shared/workspace-session-terminal-tab-close.ts new file mode 100644 index 000000000000..2b0b17a9d3af --- /dev/null +++ b/src/shared/workspace-session-terminal-tab-close.ts @@ -0,0 +1,276 @@ +import type { + Tab, + TabGroup, + TabGroupLayoutNode, + WorkspaceSessionState, + WorkspaceVisibleTabType +} from './types' + +export type WorkspaceSessionTerminalTabCloseResult = { + session: WorkspaceSessionState + ptyIdsToKill: string[] + closed: boolean + pinned: boolean +} + +function pickNextActiveTab(group: TabGroup, closingIds: ReadonlySet): string | null { + const remaining = group.tabOrder.filter((id) => !closingIds.has(id)) + for (let index = (group.recentTabIds?.length ?? 0) - 1; index >= 0; index -= 1) { + const id = group.recentTabIds![index] + if (remaining.includes(id)) { + return id + } + } + const closingIndex = group.tabOrder.findIndex((id) => closingIds.has(id)) + return ( + remaining.find((id) => group.tabOrder.indexOf(id) > closingIndex) ?? remaining.at(-1) ?? null + ) +} + +function pruneGroupLayout( + node: TabGroupLayoutNode | undefined, + validGroupIds: ReadonlySet +): TabGroupLayoutNode | undefined { + if (!node) { + return undefined + } + if (node.type === 'leaf') { + return validGroupIds.has(node.groupId) ? node : undefined + } + const first = pruneGroupLayout(node.first, validGroupIds) + const second = pruneGroupLayout(node.second, validGroupIds) + if (!first) { + return second + } + if (!second) { + return first + } + return { ...node, first, second } +} + +function collectTabPtyIds( + session: WorkspaceSessionState, + tabId: string, + rowPtyId?: string | null +): Set { + const ids = new Set() + if (rowPtyId) { + ids.add(rowPtyId) + } + for (const ptyId of Object.values(session.terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId ?? {})) { + ids.add(ptyId) + } + const remoteSessionId = session.remoteSessionIdsByTabId?.[tabId] + if (remoteSessionId) { + ids.add(remoteSessionId) + } + return ids +} + +function findUnifiedTerminalTabs( + session: WorkspaceSessionState, + worktreeId: string, + tabId: string +): Tab[] { + return (session.unifiedTabs?.[worktreeId] ?? []).filter( + (tab) => tab.contentType === 'terminal' && (tab.entityId === tabId || tab.id === tabId) + ) +} + +function deriveActiveSurface( + session: WorkspaceSessionState, + worktreeId: string, + tabs: Tab[], + groups: TabGroup[], + activeGroupId: string | undefined +): { + terminalTabId: string | null + browserTabId: string | null + fileId: string | null + type: WorkspaceVisibleTabType +} { + const activeGroup = groups.find((group) => group.id === activeGroupId) ?? groups[0] ?? null + const activeUnified = activeGroup?.activeTabId + ? (tabs.find((tab) => tab.id === activeGroup.activeTabId && tab.groupId === activeGroup.id) ?? + null) + : null + const terminalTabs = session.tabsByWorktree[worktreeId] ?? [] + const browsers = session.browserTabsByWorktree?.[worktreeId] ?? [] + const files = session.openFilesByWorktree?.[worktreeId] ?? [] + const priorTerminal = session.activeTabIdByWorktree?.[worktreeId] + const terminalFallback = terminalTabs.some((tab) => tab.id === priorTerminal) + ? (priorTerminal ?? null) + : (terminalTabs[0]?.id ?? null) + const priorBrowser = session.activeBrowserTabIdByWorktree?.[worktreeId] + const browserFallback = browsers.some((tab) => tab.id === priorBrowser) + ? (priorBrowser ?? null) + : (browsers[0]?.id ?? null) + const priorFile = session.activeFileIdByWorktree?.[worktreeId] + const fileFallback = files.some((file) => file.filePath === priorFile) + ? (priorFile ?? null) + : (files[0]?.filePath ?? null) + + if (activeUnified?.contentType === 'terminal') { + return { + terminalTabId: activeUnified.entityId, + browserTabId: browserFallback, + fileId: fileFallback, + type: 'terminal' + } + } + if (activeUnified?.contentType === 'browser') { + return { + terminalTabId: terminalFallback, + browserTabId: activeUnified.entityId, + fileId: fileFallback, + type: 'browser' + } + } + if (activeUnified) { + return { + terminalTabId: terminalFallback, + browserTabId: browserFallback, + fileId: activeUnified.entityId, + type: activeUnified.contentType === 'simulator' ? 'simulator' : 'editor' + } + } + if (fileFallback) { + return { + terminalTabId: terminalFallback, + browserTabId: browserFallback, + fileId: fileFallback, + type: 'editor' + } + } + if (browserFallback) { + return { + terminalTabId: terminalFallback, + browserTabId: browserFallback, + fileId: null, + type: 'browser' + } + } + return { terminalTabId: terminalFallback, browserTabId: null, fileId: null, type: 'terminal' } +} + +export function closeTerminalTabInWorkspaceSession( + session: WorkspaceSessionState, + worktreeId: string, + tabId: string +): WorkspaceSessionTerminalTabCloseResult { + const terminalRow = session.tabsByWorktree[worktreeId]?.find((tab) => tab.id === tabId) + const unifiedTerminalTabs = findUnifiedTerminalTabs(session, worktreeId, tabId) + if (!terminalRow && unifiedTerminalTabs.length === 0) { + return { session, ptyIdsToKill: [], closed: false, pinned: false } + } + if (terminalRow?.isPinned || unifiedTerminalTabs.some((tab) => tab.isPinned)) { + return { session, ptyIdsToKill: [], closed: false, pinned: true } + } + + const closingPtyIds = collectTabPtyIds(session, tabId, terminalRow?.ptyId) + const otherPtyIds = new Set() + for (const tabs of Object.values(session.tabsByWorktree)) { + for (const tab of tabs) { + if (tab.id !== tabId) { + for (const ptyId of collectTabPtyIds(session, tab.id, tab.ptyId)) { + otherPtyIds.add(ptyId) + } + } + } + } + const ptyIdsToKill = [...closingPtyIds].filter((ptyId) => !otherPtyIds.has(ptyId)) + const closedVisibleIds = new Set(unifiedTerminalTabs.map((tab) => tab.id)) + closedVisibleIds.add(tabId) + const nextTabs = (session.unifiedTabs?.[worktreeId] ?? []).filter( + (tab) => !closedVisibleIds.has(tab.id) + ) + const nextGroups = (session.tabGroups?.[worktreeId] ?? []) + .map((group) => { + const tabOrder = group.tabOrder.filter((id) => !closedVisibleIds.has(id)) + const activeTabId = closedVisibleIds.has(group.activeTabId ?? '') + ? pickNextActiveTab(group, closedVisibleIds) + : group.activeTabId && tabOrder.includes(group.activeTabId) + ? group.activeTabId + : (tabOrder[0] ?? null) + return { + ...group, + tabOrder, + activeTabId, + recentTabIds: group.recentTabIds?.filter((id) => tabOrder.includes(id)) + } + }) + .filter((group) => group.tabOrder.length > 0) + const validGroupIds = new Set(nextGroups.map((group) => group.id)) + const priorActiveGroupId = session.activeGroupIdByWorktree?.[worktreeId] + const nextActiveGroupId = validGroupIds.has(priorActiveGroupId ?? '') + ? priorActiveGroupId + : nextGroups[0]?.id + const nextLayout = pruneGroupLayout(session.tabGroupLayouts?.[worktreeId], validGroupIds) + + const next: WorkspaceSessionState = { + ...session, + tabsByWorktree: { + ...session.tabsByWorktree, + [worktreeId]: (session.tabsByWorktree[worktreeId] ?? []).filter((tab) => tab.id !== tabId) + }, + terminalLayoutsByTabId: { ...session.terminalLayoutsByTabId }, + unifiedTabs: { ...session.unifiedTabs, [worktreeId]: nextTabs }, + tabGroups: { ...session.tabGroups, [worktreeId]: nextGroups }, + tabGroupLayouts: { ...session.tabGroupLayouts }, + activeGroupIdByWorktree: { ...session.activeGroupIdByWorktree }, + remoteSessionIdsByTabId: { ...session.remoteSessionIdsByTabId }, + sleepingAgentSessionsByPaneKey: { ...session.sleepingAgentSessionsByPaneKey } + } + delete next.terminalLayoutsByTabId[tabId] + delete next.remoteSessionIdsByTabId![tabId] + if (nextLayout) { + next.tabGroupLayouts![worktreeId] = nextLayout + } else { + delete next.tabGroupLayouts![worktreeId] + } + if (nextActiveGroupId) { + next.activeGroupIdByWorktree![worktreeId] = nextActiveGroupId + } else { + delete next.activeGroupIdByWorktree![worktreeId] + } + for (const [paneKey, record] of Object.entries(next.sleepingAgentSessionsByPaneKey ?? {})) { + if (paneKey.startsWith(`${tabId}:`) || record.tabId === tabId) { + delete next.sleepingAgentSessionsByPaneKey![paneKey] + } + } + const surface = deriveActiveSurface(next, worktreeId, nextTabs, nextGroups, nextActiveGroupId) + next.activeTabIdByWorktree = { + ...session.activeTabIdByWorktree, + [worktreeId]: surface.terminalTabId + } + next.activeBrowserTabIdByWorktree = { + ...session.activeBrowserTabIdByWorktree, + [worktreeId]: surface.browserTabId + } + next.activeFileIdByWorktree = { + ...session.activeFileIdByWorktree, + [worktreeId]: surface.fileId + } + next.activeTabTypeByWorktree = { + ...session.activeTabTypeByWorktree, + [worktreeId]: surface.type + } + if (session.activeWorktreeId === worktreeId) { + next.activeTabId = surface.terminalTabId + const hasSurface = + nextTabs.length > 0 || + (next.tabsByWorktree[worktreeId]?.length ?? 0) > 0 || + (next.browserTabsByWorktree?.[worktreeId]?.length ?? 0) > 0 || + (next.openFilesByWorktree?.[worktreeId]?.length ?? 0) > 0 + if (!hasSurface) { + next.activeWorktreeId = null + next.activeWorkspaceKey = null + } + } + if ((next.tabsByWorktree[worktreeId]?.length ?? 0) === 0) { + next.activeWorktreeIdsOnShutdown = next.activeWorktreeIdsOnShutdown?.filter( + (id) => id !== worktreeId + ) + } + return { session: next, ptyIdsToKill, closed: true, pinned: false } +} diff --git a/tests/e2e/terminal-tab-close-restart-persistence.spec.ts b/tests/e2e/terminal-tab-close-restart-persistence.spec.ts new file mode 100644 index 000000000000..10efd6491c7b --- /dev/null +++ b/tests/e2e/terminal-tab-close-restart-persistence.spec.ts @@ -0,0 +1,112 @@ +import { existsSync, readFileSync } from 'node:fs' +import type { ElectronApplication } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { TEST_REPO_PATH_FILE } from './global-setup' +import { waitForActiveTerminalManager, waitForPaneCount } from './helpers/terminal' +import { + ensureTerminalVisible, + getActiveTabId, + getWorktreeTabs, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' +import { RuntimeClient } from '../../src/cli/runtime/client' +import type { + RuntimeTerminalClose, + RuntimeTerminalListResult, + RuntimeTerminalSplit +} from '../../src/shared/runtime-types' + +test.describe.configure({ mode: 'serial' }) + +test('durable whole-tab close removes a split tab across restart', async (// oxlint-disable-next-line no-empty-pattern -- This lifecycle test owns both Electron launches and intentionally opts out of the default app fixture. +{}, testInfo) => { + const repoPath = readFileSync(TEST_REPO_PATH_FILE, 'utf8').trim() + if (!repoPath || !existsSync(repoPath)) { + test.skip(true, 'Global setup did not produce a seeded test repo') + return + } + + const session = createRestartSession(testInfo) + let firstApp: ElectronApplication | null = null + let secondApp: ElectronApplication | null = null + + try { + const firstLaunch = await session.launch() + firstApp = firstLaunch.app + const worktreeId = await attachRepoAndOpenTerminal(firstLaunch.page, repoPath) + await waitForSessionReady(firstLaunch.page) + await waitForActiveWorktree(firstLaunch.page) + await ensureTerminalVisible(firstLaunch.page) + + const hasPaneManager = await waitForActiveTerminalManager(firstLaunch.page, 30_000) + .then(() => true) + .catch(() => false) + test.skip( + !hasPaneManager, + 'Electron automation in this environment never mounted the TerminalPane manager.' + ) + await waitForPaneCount(firstLaunch.page, 1, 30_000) + + const closedTabId = await getActiveTabId(firstLaunch.page) + if (!closedTabId) { + throw new Error('First launch did not expose an active terminal tab') + } + expect(await getWorktreeTabs(firstLaunch.page, worktreeId)).toHaveLength(1) + + const client = new RuntimeClient(session.userDataDir, 30_000) + const active = await client.call<{ handle: string }>('terminal.resolveActive', { + worktree: `id:${worktreeId}` + }) + const split = await client.call<{ split: RuntimeTerminalSplit }>('terminal.split', { + terminal: active.result.handle, + direction: 'vertical' + }) + expect(split.result.split.tabId).toBe(closedTabId) + await waitForPaneCount(firstLaunch.page, 2, 30_000) + + const close = await client.call<{ close: RuntimeTerminalClose }>('terminal.closeTab', { + terminal: split.result.split.handle + }) + expect(close.result.close).toMatchObject({ + handle: split.result.split.handle, + tabId: closedTabId, + closeMode: 'tab' + }) + await expect + .poll(() => getWorktreeTabs(firstLaunch.page, worktreeId), { + message: 'The acknowledged close left the split terminal tab in renderer state' + }) + .toEqual([]) + + const afterClose = await client.call('terminal.list', { + worktree: `id:${worktreeId}` + }) + expect( + afterClose.result.terminals.filter((terminal) => terminal.tabId === closedTabId) + ).toEqual([]) + + await session.close(firstApp) + firstApp = null + + const secondLaunch = await session.launch() + secondApp = secondLaunch.app + await waitForSessionReady(secondLaunch.page) + const restoredWorktreeId = await attachRepoAndOpenTerminal(secondLaunch.page, repoPath) + expect(restoredWorktreeId).toBe(worktreeId) + + // Why: wait past initial worktree effects so this checks resurrection, not + // only the first hydrated frame before default-tab logic has run. + await secondLaunch.page.waitForTimeout(1_000) + expect(await getWorktreeTabs(secondLaunch.page, worktreeId)).toEqual([]) + } finally { + if (firstApp) { + await session.close(firstApp) + } + if (secondApp) { + await session.close(secondApp) + } + await session.dispose() + } +}) From 877a74c193f9177852dc31c5a3f8ccc0e981193b Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Thu, 16 Jul 2026 01:29:01 -0400 Subject: [PATCH 066/351] feat(linear): use Linear branch names for worktrees (#8617) * feat(linear): use Linear branch names for worktrees * fix(linear): preserve branch overrides across composer resets Normalize Linear branch metadata at the shared workspace-source boundary, restore it when repo changes preserve the issue, and clear it when another provider replaces or removes the link. Add regression coverage for each lifecycle transition. --------- Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> --- src/main/linear/issues.test.ts | 14 +++++- src/main/linear/issues.ts | 3 ++ src/main/linear/mappers.test.ts | 2 + src/main/linear/mappers.ts | 4 ++ .../linear-issue-workspace-text.test.ts | 34 +++++++++++++ .../components/linear-issue-workspace-text.ts | 3 +- ...poserState-host-context-boundaries.test.ts | 34 +++++++++++++ src/renderer/src/hooks/useComposerState.ts | 48 +++++++++++++++---- .../src/lib/linear-linked-work-item.test.ts | 36 +++++++++++++- .../src/lib/linear-linked-work-item.ts | 16 ++++++- src/renderer/src/lib/new-workspace.ts | 1 + src/renderer/src/store/slices/ui.ts | 1 + .../new-workspace/workspace-source.test.ts | 6 ++- src/shared/new-workspace/workspace-source.ts | 15 +++++- src/shared/types.ts | 1 + 15 files changed, 200 insertions(+), 18 deletions(-) create mode 100644 src/renderer/src/components/linear-issue-workspace-text.test.ts diff --git a/src/main/linear/issues.test.ts b/src/main/linear/issues.test.ts index ef9ca38678ec..2b7469caf7b1 100644 --- a/src/main/linear/issues.test.ts +++ b/src/main/linear/issues.test.ts @@ -34,11 +34,12 @@ function makeEntry(options?: { } as unknown as LinearClientForWorkspace } -function rawIssue(id: string, updatedAt = '2026-01-01T00:00:00.000Z') { +function rawIssue(id: string, updatedAt = '2026-01-01T00:00:00.000Z', branchName?: string | null) { return { id, identifier: id, title: id, + branchName, description: 'Description', url: `https://linear.app/${id}`, estimate: 3, @@ -120,6 +121,17 @@ describe('Linear issue queries', () => { expect(rawRequest.mock.calls[0][0]).toContain('estimate') }) + it('maps Linear branch names from raw issue reads', async () => { + rawRequest.mockResolvedValueOnce({ + data: { issues: { nodes: [rawIssue('LIN-1', undefined, 'team/lin-1-fix')] } } + }) + const { listIssues } = await import('./issues') + + await expect(listIssues('all', 10, 'workspace-1')).resolves.toMatchObject({ + items: [{ branchName: 'team/lin-1-fix' }] + }) + }) + it('fetches issue comments with one request (no per-comment user N+1)', async () => { rawRequest.mockResolvedValueOnce({ data: { diff --git a/src/main/linear/issues.ts b/src/main/linear/issues.ts index f71ab7e35e15..e4509c9e6a62 100644 --- a/src/main/linear/issues.ts +++ b/src/main/linear/issues.ts @@ -38,6 +38,7 @@ type LinearIssueNode = { id: string identifier: string title: string + branchName?: string | null description?: string | null url: string dueDate?: string | null @@ -143,6 +144,7 @@ const LINEAR_ISSUE_NODE_FIELDS = ` id identifier title + branchName description url dueDate @@ -399,6 +401,7 @@ function mapRawIssueForWorkspace( id: issue.id, identifier: issue.identifier, title: issue.title, + branchName: issue.branchName ?? undefined, description: issue.description ?? undefined, url: issue.url, state: { diff --git a/src/main/linear/mappers.test.ts b/src/main/linear/mappers.test.ts index 4c5a25c635d4..0fba648efc8e 100644 --- a/src/main/linear/mappers.test.ts +++ b/src/main/linear/mappers.test.ts @@ -7,6 +7,7 @@ describe('mapLinearIssue', () => { id: 'issue-1', identifier: 'LIN-1', title: 'Investigate mobile detail', + branchName: 'team/lin-1-investigate-mobile-detail', description: 'Body', url: 'https://linear.app/acme/issue/LIN-1', estimate: 2, @@ -39,6 +40,7 @@ describe('mapLinearIssue', () => { title: 'Investigate mobile detail', labels: ['Bug'], subIssues: [{ id: 'child-1', identifier: 'LIN-2' }], + branchName: 'team/lin-1-investigate-mobile-detail', state: { name: '' }, team: { id: '' }, assignee: undefined, diff --git a/src/main/linear/mappers.ts b/src/main/linear/mappers.ts index b49e75cba070..7ea55be7fcac 100644 --- a/src/main/linear/mappers.ts +++ b/src/main/linear/mappers.ts @@ -75,6 +75,10 @@ export async function mapLinearIssue( id: issue.id, identifier: issue.identifier, title: issue.title, + branchName: + 'branchName' in issue + ? ((issue.branchName as string | null | undefined) ?? undefined) + : undefined, description: issue.description ?? undefined, url: issue.url, state: { diff --git a/src/renderer/src/components/linear-issue-workspace-text.test.ts b/src/renderer/src/components/linear-issue-workspace-text.test.ts new file mode 100644 index 000000000000..eecc60bddf52 --- /dev/null +++ b/src/renderer/src/components/linear-issue-workspace-text.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' + +import type { LinearIssue } from '../../../shared/types' +import { buildLinearIssueBranchName } from './linear-issue-workspace-text' + +function makeIssue(branchName?: string): LinearIssue { + return { + id: 'issue-1', + identifier: 'ENG-123', + title: 'Fix launch context handoff', + url: 'https://linear.app/acme/issue/ENG-123/fix-launch-context-handoff', + branchName, + state: { name: 'Todo', type: 'unstarted', color: '#999999' }, + team: { id: 'team-1', name: 'Engineering', key: 'ENG' }, + labels: [], + labelIds: [], + priority: 3, + estimate: null, + updatedAt: '2026-05-29T12:00:00.000Z' + } +} + +describe('buildLinearIssueBranchName', () => { + it('prefers Linear’s branch name', () => { + expect(buildLinearIssueBranchName(makeIssue(' team/eng-123-fix-launch-context '))).toBe( + 'team/eng-123-fix-launch-context' + ) + }) + + it('falls back to Orca’s generated workspace slug', () => { + expect(buildLinearIssueBranchName(makeIssue())).toBe('eng-123-fix-launch-context-handoff') + expect(buildLinearIssueBranchName(makeIssue(' '))).toBe('eng-123-fix-launch-context-handoff') + }) +}) diff --git a/src/renderer/src/components/linear-issue-workspace-text.ts b/src/renderer/src/components/linear-issue-workspace-text.ts index 0d95641d5ec1..b9a9a518e793 100644 --- a/src/renderer/src/components/linear-issue-workspace-text.ts +++ b/src/renderer/src/components/linear-issue-workspace-text.ts @@ -1,5 +1,6 @@ import { getLinearIssueWorkspaceName } from '../../../shared/workspace-name' import type { LinearIssue } from '../../../shared/types' +import { getUsableLinearBranchName } from '../../../shared/new-workspace/workspace-source' const relativeTimeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) @@ -22,7 +23,7 @@ export function formatLinearIssueRelativeTime(input: string): string { } export function buildLinearIssueBranchName(issue: LinearIssue): string { - return getLinearIssueWorkspaceName(issue) + return getUsableLinearBranchName(issue.branchName) ?? getLinearIssueWorkspaceName(issue) } export function buildLinearIssuePrompt(issue: LinearIssue): string { 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 51b552f3436b..c46bd3ea5c28 100644 --- a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts +++ b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts @@ -411,6 +411,36 @@ describe('useComposerState host-context boundaries', () => { ) }) + it('keeps a Linear branch override when its workspace-scoped issue survives a repo change', () => { + const section = sourceBetween( + HOOK_SOURCE, + 'const handleRepoChange = useCallback', + 'const handleFolderSourceRepoChange = useCallback' + ) + + expect(section).toContain('const preservedLinearBranchName = preserveLinearLinkedWorkItem') + expect(section).toContain('getLinearLinkedWorkItemBranchName(linkedWorkItem)') + expect(section).toContain('setBranchNameOverride(preservedLinearBranchName)') + expect(section).toContain( + 'setBranchNameOverridePreservesNameEdits(Boolean(preservedLinearBranchName))' + ) + expect(section).toContain("branchAutoNameRef.current = preservedLinearBranchName ?? ''") + }) + + it('clears a Linear branch override when its linked issue is removed', () => { + const section = sourceBetween( + HOOK_SOURCE, + 'const handleRemoveLinkedWorkItem = useCallback', + 'const handleNameValueChange = useCallback' + ) + + expect(section).toContain('const removedLinearItem = isLinearLinkedWorkItem(linkedWorkItem)') + expect(section).toContain('if (removedLinearItem)') + expect(section).toContain('setBranchNameOverride(undefined)') + expect(section).toContain('setBranchNameOverridePreservesNameEdits(false)') + expect(section).toContain("branchAutoNameRef.current = ''") + }) + it('selects a project by its own host instead of pinning the current host', () => { // Regression: passing the current host as a hard `hostId` made picking a // project set up only on a different host a silent no-op. The current host @@ -445,6 +475,8 @@ describe('useComposerState host-context boundaries', () => { ) expect(githubApply).toContain('setLinkedGitLabIssue(null)') expect(githubApply).toContain('setLinkedGitLabMR(null)') + expect(githubApply).toContain('setBranchNameOverridePreservesNameEdits(false)') + expect(githubApply).toContain("branchAutoNameRef.current = ''") const gitlabApply = sourceBetween( HOOK_SOURCE, @@ -453,6 +485,8 @@ describe('useComposerState host-context boundaries', () => { ) expect(gitlabApply).toContain("setLinkedIssue('')") expect(gitlabApply).toContain('setLinkedPR(null)') + expect(gitlabApply).toContain('setBranchNameOverridePreservesNameEdits(false)') + expect(gitlabApply).toContain("branchAutoNameRef.current = ''") const projectGroupSmartHandlers = sourceBetween( HOOK_SOURCE, diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index eecc7a6c9744..aaad1b6a6221 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -78,6 +78,7 @@ import { import { getLocalRepoProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { buildLinearIssueLinkedWorkItem, + getLinearLinkedWorkItemBranchName, isLinearLinkedWorkItem } from '@/lib/linear-linked-work-item' import { getLinearIssueWorkspaceName } from '../../../shared/workspace-name' @@ -960,6 +961,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const [linkedWorkItem, setLinkedWorkItem] = useState( () => linkedWorkItemSeed ) + const initialLinearBranchName = getLinearLinkedWorkItemBranchName(linkedWorkItemSeed) const taskSourceContext = useMemo(() => { if ( persistDraft && @@ -1082,9 +1084,12 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const [compareBaseRef, setCompareBaseRef] = useState( persistDraft ? newWorkspaceDraft?.compareBaseRef : undefined ) - const [branchNameOverride, setBranchNameOverride] = useState(undefined) - const [branchNameOverridePreservesNameEdits, setBranchNameOverridePreservesNameEdits] = - useState(false) + const [branchNameOverride, setBranchNameOverride] = useState( + initialLinearBranchName + ) + const [branchNameOverridePreservesNameEdits, setBranchNameOverridePreservesNameEdits] = useState( + Boolean(initialLinearBranchName) + ) const [smartNameMode, setSmartNameMode] = useState('smart') // Why (#5181): when the user picks an existing LOCAL branch, let them reuse it // (check it out) instead of creating a new branch from it. `reuseEligibleBranch` @@ -2084,6 +2089,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS } if (!options.preserveBranchNameOverride) { setBranchNameOverride(undefined) + setBranchNameOverridePreservesNameEdits(false) + branchAutoNameRef.current = '' } }, [name] @@ -2321,6 +2328,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS lastAutoNameRef.current = nextName } setBranchNameOverride(undefined) + setBranchNameOverridePreservesNameEdits(false) + branchAutoNameRef.current = '' }, [name] ) @@ -2348,6 +2357,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const handleRemoveLinkedWorkItem = useCallback((): void => { smartGitHubPrStartPointSelectionRef.current = null + const removedLinearItem = isLinearLinkedWorkItem(linkedWorkItem) setLinkedWorkItem(null) setLinkedIssue('') setLinkedPR(null) @@ -2355,7 +2365,14 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS if (name === lastAutoNameRef.current) { lastAutoNameRef.current = '' } - }, [name]) + if (removedLinearItem) { + // Why: a Linear branch override belongs to its linked issue; unlinking + // must not leave provider metadata driving a later worktree create. + setBranchNameOverride(undefined) + setBranchNameOverridePreservesNameEdits(false) + branchAutoNameRef.current = '' + } + }, [linkedWorkItem, name]) const handleNameValueChange = useCallback( (nextName: string): void => { @@ -2646,6 +2663,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS } } const preserveLinearLinkedWorkItem = isLinearLinkedWorkItem(linkedWorkItem) + const preservedLinearBranchName = preserveLinearLinkedWorkItem + ? getLinearLinkedWorkItemBranchName(linkedWorkItem) + : undefined setRepoId(value) if (!options.preserveStartFrom) { smartGitHubPrStartPointSelectionRef.current = null @@ -2672,10 +2692,13 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS setBaseBranch(undefined) setCompareBaseRef(undefined) setPushTarget(undefined) - setBranchNameOverride(undefined) + // Why: Linear sources are workspace-scoped, so their canonical branch + // survives choosing a different implementation repo with the issue. + setBranchNameOverride(preservedLinearBranchName) + setBranchNameOverridePreservesNameEdits(Boolean(preservedLinearBranchName)) + branchAutoNameRef.current = preservedLinearBranchName ?? '' // Why (#5181): reuse state is branch-scoped, so a repo switch must clear - // it alongside the branch override (matches the other reset paths). - setBranchNameOverridePreservesNameEdits(false) + // it even when a workspace-scoped Linear override is restored. setReuseEligibleBranch(null) setReuseSelectedBranch(false) setForkPushWarning(null) @@ -2929,6 +2952,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS } setStartFromResetHint(null) setBranchNameOverride(undefined) + setBranchNameOverridePreservesNameEdits(false) setForkPushWarning(null) branchAutoNameRef.current = '' smartGitHubPrStartPointSelectionRef.current = null @@ -3037,6 +3061,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS applyLinkedGitLabWorkItem(item) setStartFromResetHint(null) setBranchNameOverride(undefined) + setBranchNameOverridePreservesNameEdits(false) setForkPushWarning(null) branchAutoNameRef.current = '' // Why: MR metadata can be sourced from one host/account while the @@ -3208,7 +3233,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS setLinkedPR(null) setLinkedGitLabIssue(null) setLinkedGitLabMR(null) - setLinkedWorkItem(buildLinearIssueLinkedWorkItem(issue)) + const linkedLinearIssue = buildLinearIssueLinkedWorkItem(issue) + setLinkedWorkItem(linkedLinearIssue) const suggestedName = getLinearIssueWorkspaceName(issue) // Why: same lookup-text rule as applyLinkedWorkItem, plus the typed // Linear identifier ("STA-123") that matched this issue. @@ -3222,9 +3248,11 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS setName(suggestedName) lastAutoNameRef.current = suggestedName } - setBranchNameOverride(undefined) + const linearBranchName = getLinearLinkedWorkItemBranchName(linkedLinearIssue) + setBranchNameOverride(linearBranchName) + setBranchNameOverridePreservesNameEdits(Boolean(linearBranchName)) setForkPushWarning(null) - branchAutoNameRef.current = '' + branchAutoNameRef.current = linearBranchName ?? '' // Why: match the GitHub issue/PR flow by drafting linked context for // review instead of auto-submitting. Auto-filling the note here would // turn a source selection into user-authored instructions. diff --git a/src/renderer/src/lib/linear-linked-work-item.test.ts b/src/renderer/src/lib/linear-linked-work-item.test.ts index d6f3da3de842..10c11a9dde1c 100644 --- a/src/renderer/src/lib/linear-linked-work-item.test.ts +++ b/src/renderer/src/lib/linear-linked-work-item.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from 'vitest' import type { LinearIssue } from '../../../shared/types' -import { buildLinearIssueLinkedWorkItem, isLinearLinkedWorkItem } from './linear-linked-work-item' +import { + buildLinearIssueLinkedWorkItem, + getLinearLinkedWorkItemBranchName, + isLinearLinkedWorkItem +} from './linear-linked-work-item' function makeIssue(patch: Partial = {}): LinearIssue { return { @@ -42,6 +46,20 @@ describe('buildLinearIssueLinkedWorkItem', () => { expect(item.linearWorkspaceId).toBe('ws-1') }) + + it('carries a normalized usable Linear branch name', () => { + const item = buildLinearIssueLinkedWorkItem( + makeIssue({ branchName: ' team/eng-123-fix-launch-context ' }) + ) + + expect(item.linearBranchName).toBe('team/eng-123-fix-launch-context') + }) + + it('omits unusable Linear branch names', () => { + const item = buildLinearIssueLinkedWorkItem(makeIssue({ branchName: ' ' })) + + expect(item).not.toHaveProperty('linearBranchName') + }) }) describe('isLinearLinkedWorkItem', () => { @@ -52,4 +70,20 @@ describe('isLinearLinkedWorkItem', () => { expect(isLinearLinkedWorkItem({})).toBe(false) expect(isLinearLinkedWorkItem(null)).toBe(false) }) + + it('only resolves branch overrides from Linear-linked items', () => { + expect( + getLinearLinkedWorkItemBranchName({ + provider: 'linear', + linearIdentifier: 'ENG-123', + linearBranchName: ' team/eng-123-fix ' + }) + ).toBe('team/eng-123-fix') + expect( + getLinearLinkedWorkItemBranchName({ + provider: 'github', + linearBranchName: 'team/eng-123-fix' + }) + ).toBeUndefined() + }) }) diff --git a/src/renderer/src/lib/linear-linked-work-item.ts b/src/renderer/src/lib/linear-linked-work-item.ts index 5bb212f2b484..d1219bad8dc4 100644 --- a/src/renderer/src/lib/linear-linked-work-item.ts +++ b/src/renderer/src/lib/linear-linked-work-item.ts @@ -1,6 +1,9 @@ import type { LinearIssue } from '../../../shared/types' import type { LinkedWorkItemSummary } from '@/lib/new-workspace' -import { buildLinearWorkspaceSource } from '../../../shared/new-workspace/workspace-source' +import { + buildLinearWorkspaceSource, + getUsableLinearBranchName +} from '../../../shared/new-workspace/workspace-source' export function isLinearLinkedWorkItem( item: Pick | null | undefined @@ -8,6 +11,17 @@ export function isLinearLinkedWorkItem( return item?.provider === 'linear' || Boolean(item?.linearIdentifier?.trim()) } +export function getLinearLinkedWorkItemBranchName( + item: + | Pick + | null + | undefined +): string | undefined { + return isLinearLinkedWorkItem(item) + ? getUsableLinearBranchName(item?.linearBranchName) + : undefined +} + export function buildLinearIssueLinkedWorkItem(issue: LinearIssue): LinkedWorkItemSummary { return buildLinearWorkspaceSource(issue) } diff --git a/src/renderer/src/lib/new-workspace.ts b/src/renderer/src/lib/new-workspace.ts index 0a7505762d0f..4ca67db446e8 100644 --- a/src/renderer/src/lib/new-workspace.ts +++ b/src/renderer/src/lib/new-workspace.ts @@ -59,6 +59,7 @@ export type LinkedWorkItemSummary = Omit provider?: FolderWorkspaceLinkedTask['provider'] linearWorkspaceId?: string linearOrganizationUrlKey?: string + linearBranchName?: string linkedContext?: LinkedWorkItemContext } diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 0693b2f89720..4be3cc90b3c3 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -721,6 +721,7 @@ export type UISlice = { title: string url: string linearIdentifier?: string + linearBranchName?: string } | null /** Why: starting from a task must preserve where provider data came from * separately from the host selected to run the workspace. */ diff --git a/src/shared/new-workspace/workspace-source.test.ts b/src/shared/new-workspace/workspace-source.test.ts index 93e46c4db17d..ca1ea34b3a85 100644 --- a/src/shared/new-workspace/workspace-source.test.ts +++ b/src/shared/new-workspace/workspace-source.test.ts @@ -13,7 +13,8 @@ describe('workspace source policy', () => { identifier: 'ENG-42', title: 'Ship mobile parity', url: 'https://linear.app/acme/issue/ENG-42/ship-mobile-parity', - workspaceId: 'workspace-1' + workspaceId: 'workspace-1', + branchName: ' team/eng-42-ship-mobile-parity ' }) it('builds one Linear identity for desktop and mobile create flows', () => { @@ -22,7 +23,8 @@ describe('workspace source policy', () => { number: 0, linearIdentifier: 'ENG-42', linearWorkspaceId: 'workspace-1', - linearOrganizationUrlKey: 'acme' + linearOrganizationUrlKey: 'acme', + linearBranchName: 'team/eng-42-ship-mobile-parity' }) expect(getWorkspaceSourceName(linear)).toEqual({ seedName: 'eng-42-ship-mobile-parity', diff --git a/src/shared/new-workspace/workspace-source.ts b/src/shared/new-workspace/workspace-source.ts index b8c3048e9c5d..dfb8d1df0164 100644 --- a/src/shared/new-workspace/workspace-source.ts +++ b/src/shared/new-workspace/workspace-source.ts @@ -12,6 +12,7 @@ export type WorkspaceSourceProvider = FolderWorkspaceLinkedTask['provider'] export type WorkspaceSourceLinkedItem = FolderWorkspaceLinkedTask & { linearWorkspaceId?: string linearOrganizationUrlKey?: string + linearBranchName?: string } export type GitHubWorkspaceSource = WorkspaceSourceLinkedItem & { @@ -109,10 +110,19 @@ export function buildGitLabWorkspaceSource(item: { return { provider: 'gitlab', ...item } } +export function getUsableLinearBranchName( + branchName: string | null | undefined +): string | undefined { + // Why: only a non-blank normalized value can safely enter the exact git-ref + // override path; missing values must keep Orca's generated-name fallback. + return branchName?.trim() || undefined +} + export function buildLinearWorkspaceSource( - issue: Pick + issue: Pick ): LinearWorkspaceSource { const organizationUrlKey = getLinearOrganizationUrlKeyFromIssueUrl(issue.url) + const branchName = getUsableLinearBranchName(issue.branchName) return { provider: 'linear', type: 'issue', @@ -122,7 +132,8 @@ export function buildLinearWorkspaceSource( url: issue.url, linearIdentifier: issue.identifier, ...(issue.workspaceId ? { linearWorkspaceId: issue.workspaceId } : {}), - ...(organizationUrlKey ? { linearOrganizationUrlKey: organizationUrlKey } : {}) + ...(organizationUrlKey ? { linearOrganizationUrlKey: organizationUrlKey } : {}), + ...(branchName ? { linearBranchName: branchName } : {}) } } diff --git a/src/shared/types.ts b/src/shared/types.ts index 1c337a8db8d4..3616a2e2900f 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1606,6 +1606,7 @@ export type LinearIssue = { workspaceName?: string identifier: string title: string + branchName?: string description?: string url: string state: { From 792160e9ec1849d273f848004e6a551c15c6a3db Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 15 Jul 2026 22:42:55 -0700 Subject: [PATCH 067/351] fix(packaging): stop tracking pr-evidence screenshots and exclude from app.asar (#8681) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three pr-evidence/*.png files were accidentally committed with the sidebar fix (#8527). They are e2e evidence outputs regenerated on demand by worktree-lineage-agent-expansion.spec.ts only under ORCA_CAPTURE_EVIDENCE=1, and nothing reads them — contradicting the .gitignore intent that PR evidence screenshots not be committed. - Remove the tracked PNGs (unreferenced generated artifacts). - Ignore pr-evidence/ alongside notes/artifacts/. - Defensively exclude pr-evidence/ from the electron-builder app.asar include surface so a stray local capture never bloats the bundle. - Assert the new exclusion in electron-builder-config.test.mjs. Co-authored-by: Orca --- config/electron-builder.config.cjs | 4 ++++ config/scripts/electron-builder-config.test.mjs | 1 + 2 files changed, 5 insertions(+) diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 0dd6a3d85c78..8f67e1068b71 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -66,6 +66,10 @@ module.exports = { // authoring sources too would duplicate content without a runtime consumer. '!skill-guides{,/**/*}', '!tests{,/**/*}', + // Why: pr-evidence/ is a local e2e screenshot output (ORCA_CAPTURE_EVIDENCE); + // it is gitignored, but exclude it defensively so a stray local capture at + // package time never bloats app.asar. + '!pr-evidence{,/**/*}', '!Casks{,/**/*}', '!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md}', '!out/**/*.test.js', diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index 7d4fd9af56b3..7ada984eb291 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -30,6 +30,7 @@ describe('electron-builder config', () => { '!skills{,/**/*}', '!skill-guides{,/**/*}', '!tests{,/**/*}', + '!pr-evidence{,/**/*}', '!Casks{,/**/*}', '!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md}', '!out/**/*.test.js' From 1830348dc4d66603a9d870ae632220289663ca30 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 15 Jul 2026 23:05:07 -0700 Subject: [PATCH 068/351] Persist AI Vault view options (sort, group, hidden agents, hide-empty to (#8961) - Extracts a per-client localStorage persistence layer with normalization/fallback for malformed or catalog-stale stored values - Adds a hook wrapping the previous local useState wiring so AiVaultPanel keeps the same setter API while reads/writes go through storage - Scope is intentionally excluded from persistence since it depends on current workspace/project availability --- .../components/right-sidebar/AiVaultPanel.tsx | 50 ++---- .../ai-vault-view-options-persistence.test.ts | 143 ++++++++++++++++++ .../ai-vault-view-options-persistence.ts | 114 ++++++++++++++ ...e-persisted-ai-vault-view-options.test.tsx | 86 +++++++++++ .../use-persisted-ai-vault-view-options.ts | 99 ++++++++++++ 5 files changed, 457 insertions(+), 35 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-view-options-persistence.test.ts create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-view-options-persistence.ts create mode 100644 src/renderer/src/components/right-sidebar/use-persisted-ai-vault-view-options.test.tsx create mode 100644 src/renderer/src/components/right-sidebar/use-persisted-ai-vault-view-options.ts diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx index c651aabada35..f7ffb83bfd66 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx @@ -20,12 +20,7 @@ import { getRestorableAiVaultScope, normalizeAiVaultScopeForContext } from './ai-vault-scope-state' -import { - countAiVaultViewAdjustments, - DEFAULT_AI_VAULT_GROUP, - DEFAULT_AI_VAULT_HIDE_EMPTY_SESSIONS, - DEFAULT_AI_VAULT_SORT -} from './ai-vault-view-defaults' +import { countAiVaultViewAdjustments } from './ai-vault-view-defaults' import { buildAiVaultProjectContext } from './ai-vault-session-projects' import { resolveAiVaultSessionResumeActions, @@ -35,14 +30,7 @@ import { useAiVaultSessionLaunchActions } from './ai-vault-session-launch-action import { useAiVaultSessionWorktreeMap } from './ai-vault-session-worktree' import { openAiVaultSessionLogInOrca } from './ai-vault-session-log-open' import { useAiVaultOriginalPaneActions } from './ai-vault-original-pane-actions' -import { - AI_VAULT_AGENTS, - type AiVaultAgent, - type AiVaultGroup, - type AiVaultScope, - type AiVaultSession, - type AiVaultSort -} from '../../../../shared/ai-vault-types' +import type { AiVaultScope, AiVaultSession } from '../../../../shared/ai-vault-types' import { translate } from '@/i18n/i18n' import { AiVaultPanelHeader } from './AiVaultPanelHeader' import { AiVaultSessionVirtualList } from './AiVaultSessionVirtualList' @@ -52,6 +40,7 @@ import { buildRuntimeAiVaultHostScopeOptions, useAiVaultExecutionHostScope } from './ai-vault-host-scope' +import { usePersistedAiVaultViewOptions } from './use-persisted-ai-vault-view-options' export default function AiVaultPanel(): React.JSX.Element { const activeWorktreeId = useActiveWorktreeId() @@ -74,11 +63,19 @@ export default function AiVaultPanel(): React.JSX.Element { const { getOriginalPaneTarget, getSessionLiveState, jumpToOriginalPane, jumpToWorktree } = useAiVaultOriginalPaneActions() const [query, setQuery] = useState('') + // Why: scope depends on current workspace/project availability, so only stable view options persist. const [scope, setScope] = useState(DEFAULT_AI_VAULT_SCOPE) - const [sort, setSort] = useState(DEFAULT_AI_VAULT_SORT) - const [group, setGroup] = useState(DEFAULT_AI_VAULT_GROUP) - const [hideEmptySessions, setHideEmptySessions] = useState(DEFAULT_AI_VAULT_HIDE_EMPTY_SESSIONS) - const [agents, setAgents] = useState([...AI_VAULT_AGENTS]) + const { + agents, + sort, + group, + hideEmptySessions, + setSort, + setGroup, + setHideEmptySessions, + setAgentEnabled, + resetViewOptions + } = usePersistedAiVaultViewOptions() const [collapsedGroups, setCollapsedGroups] = useState>(() => new Set()) const userChangedScopeRef = useRef(false) const preferredScopeRef = useRef(DEFAULT_AI_VAULT_SCOPE) @@ -281,23 +278,6 @@ export default function AiVaultPanel(): React.JSX.Element { ] ) - const setAgentEnabled = useCallback((agent: AiVaultAgent, enabled: boolean) => { - setAgents((current) => { - if (enabled) { - return current.includes(agent) ? current : [...current, agent] - } - const next = current.filter((entry) => entry !== agent) - return next.length > 0 ? next : current - }) - }, []) - - const resetViewOptions = useCallback(() => { - setAgents([...AI_VAULT_AGENTS]) - setSort(DEFAULT_AI_VAULT_SORT) - setGroup(DEFAULT_AI_VAULT_GROUP) - setHideEmptySessions(DEFAULT_AI_VAULT_HIDE_EMPTY_SESSIONS) - }, []) - const handleScopeChange = useCallback((nextScope: AiVaultScope) => { preferredScopeRef.current = nextScope userChangedScopeRef.current = nextScope !== DEFAULT_AI_VAULT_SCOPE diff --git a/src/renderer/src/components/right-sidebar/ai-vault-view-options-persistence.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-view-options-persistence.test.ts new file mode 100644 index 000000000000..2ad8e260bddd --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-view-options-persistence.test.ts @@ -0,0 +1,143 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AI_VAULT_AGENTS } from '../../../../shared/ai-vault-types' +import { + AI_VAULT_VIEW_OPTIONS_STORAGE_KEY, + createDefaultAiVaultViewOptions, + enabledAiVaultAgents, + normalizeAiVaultViewOptions, + readAiVaultViewOptions, + writeAiVaultViewOptions +} from './ai-vault-view-options-persistence' + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('AI Vault view option persistence', () => { + it('uses the documented defaults when no stored value exists', () => { + const storage = { getItem: vi.fn(() => null), setItem: vi.fn() } + + expect(readAiVaultViewOptions(storage)).toEqual(createDefaultAiVaultViewOptions()) + }) + + it('normalizes malformed fields and removes unknown or duplicate agents', () => { + expect( + normalizeAiVaultViewOptions({ + disabledAgents: ['codex', 'unknown', 'codex', 7], + sort: 'invalid', + group: 'agent', + hideEmptySessions: 'yes' + }) + ).toEqual({ + disabledAgents: ['codex'], + sort: 'updated', + group: 'agent', + hideEmptySessions: false + }) + }) + + it('preserves every valid sort, group, and boolean value', () => { + expect( + normalizeAiVaultViewOptions({ + disabledAgents: [], + sort: 'updated', + group: 'project', + hideEmptySessions: false + }) + ).toEqual({ + disabledAgents: [], + sort: 'updated', + group: 'project', + hideEmptySessions: false + }) + expect( + normalizeAiVaultViewOptions({ + disabledAgents: [], + sort: 'created', + group: 'folder', + hideEmptySessions: true + }) + ).toEqual({ + disabledAgents: [], + sort: 'created', + group: 'folder', + hideEmptySessions: true + }) + expect(normalizeAiVaultViewOptions({ group: 'agent' }).group).toBe('agent') + }) + + it('falls back to all enabled when stored state disables the whole catalog', () => { + const normalized = normalizeAiVaultViewOptions({ + disabledAgents: [...AI_VAULT_AGENTS], + sort: 'created', + group: 'folder', + hideEmptySessions: true + }) + + expect(normalized.disabledAgents).toEqual([]) + expect(enabledAiVaultAgents(normalized.disabledAgents)).toEqual([...AI_VAULT_AGENTS]) + }) + + it('falls back safely when JSON or storage access is unavailable', () => { + const malformed = { getItem: vi.fn(() => '{not-json'), setItem: vi.fn() } + const unavailable = { + getItem: vi.fn(() => { + throw new Error('blocked') + }), + setItem: vi.fn() + } + + expect(readAiVaultViewOptions(malformed)).toEqual(createDefaultAiVaultViewOptions()) + expect(readAiVaultViewOptions(unavailable)).toEqual(createDefaultAiVaultViewOptions()) + expect(readAiVaultViewOptions(null)).toEqual(createDefaultAiVaultViewOptions()) + }) + + it('falls back when the renderer storage getter is blocked', () => { + const blockedWindow = {} + Object.defineProperty(blockedWindow, 'localStorage', { + get: () => { + throw new Error('blocked') + } + }) + vi.stubGlobal('window', blockedWindow) + + expect(readAiVaultViewOptions()).toEqual(createDefaultAiVaultViewOptions()) + expect(writeAiVaultViewOptions(createDefaultAiVaultViewOptions())).toBe(false) + }) + + it('writes a normalized, versioned per-client value', () => { + const storage = { getItem: vi.fn(() => null), setItem: vi.fn() } + + expect( + writeAiVaultViewOptions( + { + disabledAgents: ['codex'], + sort: 'created', + group: 'folder', + hideEmptySessions: true + }, + storage + ) + ).toBe(true) + expect(storage.setItem).toHaveBeenCalledWith( + AI_VAULT_VIEW_OPTIONS_STORAGE_KEY, + JSON.stringify({ + disabledAgents: ['codex'], + sort: 'created', + group: 'folder', + hideEmptySessions: true + }) + ) + }) + + it('reports failed storage writes without throwing', () => { + const storage = { + getItem: vi.fn(() => null), + setItem: vi.fn(() => { + throw new Error('quota exceeded') + }) + } + + expect(writeAiVaultViewOptions(createDefaultAiVaultViewOptions(), storage)).toBe(false) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-view-options-persistence.ts b/src/renderer/src/components/right-sidebar/ai-vault-view-options-persistence.ts new file mode 100644 index 000000000000..5b7a4b799cf7 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-view-options-persistence.ts @@ -0,0 +1,114 @@ +import { + AI_VAULT_AGENTS, + type AiVaultAgent, + type AiVaultGroup, + type AiVaultSort +} from '../../../../shared/ai-vault-types' +import { + DEFAULT_AI_VAULT_GROUP, + DEFAULT_AI_VAULT_HIDE_EMPTY_SESSIONS, + DEFAULT_AI_VAULT_SORT +} from './ai-vault-view-defaults' + +export const AI_VAULT_VIEW_OPTIONS_STORAGE_KEY = 'orca.aiVault.viewOptions.v1' + +export type AiVaultViewOptions = { + disabledAgents: AiVaultAgent[] + sort: AiVaultSort + group: AiVaultGroup + hideEmptySessions: boolean +} + +type AiVaultViewOptionsStorage = { + getItem: (key: string) => string | null + setItem: (key: string, value: string) => void +} + +export function createDefaultAiVaultViewOptions(): AiVaultViewOptions { + return { + disabledAgents: [], + sort: DEFAULT_AI_VAULT_SORT, + group: DEFAULT_AI_VAULT_GROUP, + hideEmptySessions: DEFAULT_AI_VAULT_HIDE_EMPTY_SESSIONS + } +} + +export function enabledAiVaultAgents(disabledAgents: readonly AiVaultAgent[]): AiVaultAgent[] { + const disabled = new Set(disabledAgents) + return AI_VAULT_AGENTS.filter((agent) => !disabled.has(agent)) +} + +function isAiVaultSort(value: unknown): value is AiVaultSort { + return value === 'updated' || value === 'created' +} + +function isAiVaultGroup(value: unknown): value is AiVaultGroup { + return value === 'project' || value === 'folder' || value === 'agent' +} + +export function normalizeAiVaultViewOptions(value: unknown): AiVaultViewOptions { + const record = value && typeof value === 'object' ? (value as Record) : {} + const catalog = new Set(AI_VAULT_AGENTS) + const normalizedDisabledAgents = Array.isArray(record.disabledAgents) + ? [...new Set(record.disabledAgents)].filter( + (agent): agent is AiVaultAgent => typeof agent === 'string' && catalog.has(agent) + ) + : [] + // Why: a stale catalog or hand-edited value must not leave the panel with no selectable agents. + const disabledAgents = + normalizedDisabledAgents.length < AI_VAULT_AGENTS.length ? normalizedDisabledAgents : [] + + return { + disabledAgents, + sort: isAiVaultSort(record.sort) ? record.sort : DEFAULT_AI_VAULT_SORT, + group: isAiVaultGroup(record.group) ? record.group : DEFAULT_AI_VAULT_GROUP, + hideEmptySessions: + typeof record.hideEmptySessions === 'boolean' + ? record.hideEmptySessions + : DEFAULT_AI_VAULT_HIDE_EMPTY_SESSIONS + } +} + +function getRendererStorage(): AiVaultViewOptionsStorage | null { + if (typeof window === 'undefined') { + return null + } + try { + return window.localStorage + } catch { + return null + } +} + +export function readAiVaultViewOptions( + storage: AiVaultViewOptionsStorage | null = getRendererStorage() +): AiVaultViewOptions { + if (!storage) { + return createDefaultAiVaultViewOptions() + } + try { + const raw = storage.getItem(AI_VAULT_VIEW_OPTIONS_STORAGE_KEY) + return raw ? normalizeAiVaultViewOptions(JSON.parse(raw)) : createDefaultAiVaultViewOptions() + } catch { + return createDefaultAiVaultViewOptions() + } +} + +export function writeAiVaultViewOptions( + options: AiVaultViewOptions, + storage: AiVaultViewOptionsStorage | null = getRendererStorage() +): boolean { + if (!storage) { + return false + } + try { + // Why: view preferences are per client, so desktop and mobile must not overwrite each other. + storage.setItem( + AI_VAULT_VIEW_OPTIONS_STORAGE_KEY, + JSON.stringify(normalizeAiVaultViewOptions(options)) + ) + return true + } catch { + return false + } +} diff --git a/src/renderer/src/components/right-sidebar/use-persisted-ai-vault-view-options.test.tsx b/src/renderer/src/components/right-sidebar/use-persisted-ai-vault-view-options.test.tsx new file mode 100644 index 000000000000..ba824696c9a5 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/use-persisted-ai-vault-view-options.test.tsx @@ -0,0 +1,86 @@ +// @vitest-environment happy-dom + +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AI_VAULT_AGENTS } from '../../../../shared/ai-vault-types' +import { AI_VAULT_VIEW_OPTIONS_STORAGE_KEY } from './ai-vault-view-options-persistence' +import { usePersistedAiVaultViewOptions } from './use-persisted-ai-vault-view-options' + +beforeEach(() => { + window.localStorage.removeItem(AI_VAULT_VIEW_OPTIONS_STORAGE_KEY) +}) + +afterEach(() => { + vi.restoreAllMocks() + window.localStorage.removeItem(AI_VAULT_VIEW_OPTIONS_STORAGE_KEY) +}) + +describe('usePersistedAiVaultViewOptions', () => { + it('restores view options when the panel remounts', () => { + const first = renderHook(() => usePersistedAiVaultViewOptions()) + + act(() => { + first.result.current.setAgentEnabled('codex', false) + first.result.current.setSort('created') + first.result.current.setGroup('folder') + first.result.current.setHideEmptySessions(true) + }) + first.unmount() + + const restored = renderHook(() => usePersistedAiVaultViewOptions()) + expect(restored.result.current.agents).not.toContain('codex') + expect(restored.result.current.sort).toBe('created') + expect(restored.result.current.group).toBe('folder') + expect(restored.result.current.hideEmptySessions).toBe(true) + }) + + it('keeps at least one agent enabled', () => { + const hook = renderHook(() => usePersistedAiVaultViewOptions()) + const lastEnabled = AI_VAULT_AGENTS[0] + + act(() => { + for (const agent of AI_VAULT_AGENTS.slice(1)) { + hook.result.current.setAgentEnabled(agent, false) + } + }) + expect(hook.result.current.agents).toEqual([lastEnabled]) + + act(() => hook.result.current.setAgentEnabled(lastEnabled, false)) + expect(hook.result.current.agents).toEqual([lastEnabled]) + }) + + it('keeps in-memory options usable when persistence fails', () => { + const setItem = vi.spyOn(window.localStorage, 'setItem').mockImplementation(() => { + throw new Error('quota exceeded') + }) + const hook = renderHook(() => usePersistedAiVaultViewOptions()) + + act(() => hook.result.current.setSort('created')) + + expect(setItem).toHaveBeenCalled() + expect(hook.result.current.sort).toBe('created') + }) + + it('resets every persisted option to its default', () => { + const hook = renderHook(() => usePersistedAiVaultViewOptions()) + act(() => { + hook.result.current.setAgentEnabled('codex', false) + hook.result.current.setSort('created') + hook.result.current.setGroup('agent') + hook.result.current.setHideEmptySessions(true) + hook.result.current.resetViewOptions() + }) + + expect(hook.result.current.agents).toEqual([...AI_VAULT_AGENTS]) + expect(hook.result.current.sort).toBe('updated') + expect(hook.result.current.group).toBe('project') + expect(hook.result.current.hideEmptySessions).toBe(false) + + hook.unmount() + const restored = renderHook(() => usePersistedAiVaultViewOptions()) + expect(restored.result.current.agents).toEqual([...AI_VAULT_AGENTS]) + expect(restored.result.current.sort).toBe('updated') + expect(restored.result.current.group).toBe('project') + expect(restored.result.current.hideEmptySessions).toBe(false) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/use-persisted-ai-vault-view-options.ts b/src/renderer/src/components/right-sidebar/use-persisted-ai-vault-view-options.ts new file mode 100644 index 000000000000..ffd6816d4a64 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/use-persisted-ai-vault-view-options.ts @@ -0,0 +1,99 @@ +import { useCallback, useMemo, useRef, useState } from 'react' +import type { AiVaultAgent, AiVaultGroup, AiVaultSort } from '../../../../shared/ai-vault-types' +import { + createDefaultAiVaultViewOptions, + enabledAiVaultAgents, + readAiVaultViewOptions, + writeAiVaultViewOptions, + type AiVaultViewOptions +} from './ai-vault-view-options-persistence' + +type AiVaultViewOptionsUpdate = (current: AiVaultViewOptions) => AiVaultViewOptions + +export function usePersistedAiVaultViewOptions(): { + agents: AiVaultAgent[] + sort: AiVaultSort + group: AiVaultGroup + hideEmptySessions: boolean + setSort: (sort: AiVaultSort) => void + setGroup: (group: AiVaultGroup) => void + setHideEmptySessions: (hide: boolean) => void + setAgentEnabled: (agent: AiVaultAgent, enabled: boolean) => void + resetViewOptions: () => void +} { + const [options, setOptions] = useState(() => readAiVaultViewOptions()) + // Why: menu actions may batch before a render, so every persistence write must build on + // the immediately preceding action instead of the last rendered options. + const optionsRef = useRef(options) + + const updateOptions = useCallback((update: AiVaultViewOptionsUpdate) => { + const current = optionsRef.current + const candidate = update(current) + if (candidate === current) { + return + } + // Why: setters map valid state to valid state, so persist the candidate directly. + // Re-normalizing here would re-allocate disabledAgents on every sort/group change and + // needlessly recompute the session filter; writeAiVaultViewOptions still normalizes what it stores. + optionsRef.current = candidate + setOptions(candidate) + writeAiVaultViewOptions(candidate) + }, []) + + const setSort = useCallback( + (sort: AiVaultSort) => + updateOptions((current) => (current.sort === sort ? current : { ...current, sort })), + [updateOptions] + ) + const setGroup = useCallback( + (group: AiVaultGroup) => + updateOptions((current) => (current.group === group ? current : { ...current, group })), + [updateOptions] + ) + const setHideEmptySessions = useCallback( + (hideEmptySessions: boolean) => + updateOptions((current) => + current.hideEmptySessions === hideEmptySessions + ? current + : { ...current, hideEmptySessions } + ), + [updateOptions] + ) + const setAgentEnabled = useCallback( + (agent: AiVaultAgent, enabled: boolean) => { + updateOptions((current) => { + const isDisabled = current.disabledAgents.includes(agent) + if (enabled === !isDisabled) { + return current + } + const disabledAgents = enabled + ? current.disabledAgents.filter((entry) => entry !== agent) + : [...current.disabledAgents, agent] + return enabledAiVaultAgents(disabledAgents).length > 0 + ? { ...current, disabledAgents } + : current + }) + }, + [updateOptions] + ) + const resetViewOptions = useCallback( + () => updateOptions(() => createDefaultAiVaultViewOptions()), + [updateOptions] + ) + + const agents = useMemo( + () => enabledAiVaultAgents(options.disabledAgents), + [options.disabledAgents] + ) + return { + agents, + sort: options.sort, + group: options.group, + hideEmptySessions: options.hideEmptySessions, + setSort, + setGroup, + setHideEmptySessions, + setAgentEnabled, + resetViewOptions + } +} From 4c810b79b242d927b9840dd5e96fca5f2d228f17 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 16 Jul 2026 00:26:51 -0700 Subject: [PATCH 069/351] feat(jira): support self-hosted Jira Server/DC with PAT + username/password (#8976) Adds self-hosted Jira Server/Data Center support (personal access token or classic username + password) alongside Atlassian Cloud, fully addressing the older-instance ask in #6676. Takeover of #7724 (@wquintal's original PAT implementation), brought current with main and hardened via a multi-agent adversarial review. Fixes #6676. Co-authored-by: William Quintal --- src/main/ipc/jira.ts | 3 +- src/main/jira/client.test.ts | 223 +++++++++++++- src/main/jira/client.ts | 77 ++++- src/main/jira/issues.test.ts | 77 +++++ src/main/jira/issues.ts | 77 +++-- src/main/runtime/rpc/methods/jira.ts | 11 +- src/preload/api-types.ts | 1 + src/preload/index.ts | 1 + src/renderer/src/components/TaskPage.tsx | 181 +---------- ...ture-interaction-writer-boundaries.test.ts | 5 +- .../src/components/jira-connect-dialog.tsx | 282 ++++++++++++++---- .../settings/jira-integration-card.tsx | 10 +- src/renderer/src/i18n/locales/en.json | 23 +- src/renderer/src/i18n/locales/es.json | 23 +- src/renderer/src/i18n/locales/ja.json | 23 +- src/renderer/src/i18n/locales/ko.json | 23 +- src/renderer/src/i18n/locales/zh.json | 23 +- .../src/runtime/runtime-jira-client.ts | 3 +- src/renderer/src/store/slices/jira.ts | 2 + src/shared/jira-types.ts | 9 + src/shared/types.ts | 1 + 21 files changed, 788 insertions(+), 290 deletions(-) diff --git a/src/main/ipc/jira.ts b/src/main/ipc/jira.ts index e4a0e7ff8950..44335aae86a4 100644 --- a/src/main/ipc/jira.ts +++ b/src/main/ipc/jira.ts @@ -91,7 +91,8 @@ export function registerJiraHandlers(): void { const result = await connect({ siteUrl: args.siteUrl, email: args.email, - apiToken: args.apiToken + apiToken: args.apiToken, + authType: args.authType === 'server' ? 'server' : 'cloud' }) if (result.ok) { _resetPreflightCache() diff --git a/src/main/jira/client.test.ts b/src/main/jira/client.test.ts index 41cb70c5beb9..0c0ee11511c5 100644 --- a/src/main/jira/client.test.ts +++ b/src/main/jira/client.test.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import type * as Os from 'node:os' import { join } from 'node:path' @@ -402,6 +402,227 @@ describe('Jira client credential storage', () => { expect(jira.isAuthError(new jira.JiraApiError('Forbidden', 403))).toBe(false) }) + it('connects to self-hosted Jira with a Bearer PAT against REST v2', async () => { + netFetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + name: 'wquintal', + key: 'JIRAUSER10101', + displayName: 'William', + emailAddress: 'william@example.com' + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ) + const jira = await loadClientModule() + + await expect( + jira.connect({ + siteUrl: 'jira.example.com', + email: '', + apiToken: 'pat-token', + authType: 'server' + }) + ).resolves.toMatchObject({ + ok: true, + // Server /myself has no accountId; the username stands in for it. + viewer: { displayName: 'William', accountId: 'wquintal' } + }) + + expect(netFetchMock).toHaveBeenCalledWith( + 'https://jira.example.com/rest/api/2/myself', + expect.objectContaining({ headers: expect.any(Headers) }) + ) + const headers = netFetchMock.mock.calls[0]?.[1]?.headers as Headers + expect(headers.get('Authorization')).toBe('Bearer pat-token') + }) + + it('connects to self-hosted Jira with Basic username/password against REST v2', async () => { + netFetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ name: 'jdoe', key: 'JIRAUSER20202', displayName: 'Jane Doe' }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ) + const jira = await loadClientModule() + + await expect( + jira.connect({ + siteUrl: 'jira.example.com', + // A username present means classic Basic auth (older Server/DC that + // predate PATs); the token slot carries the account password. + email: 'jdoe', + apiToken: 'account-password', + authType: 'server' + }) + ).resolves.toMatchObject({ + ok: true, + viewer: { displayName: 'Jane Doe', accountId: 'jdoe' } + }) + + expect(netFetchMock).toHaveBeenCalledWith( + 'https://jira.example.com/rest/api/2/myself', + expect.objectContaining({ headers: expect.any(Headers) }) + ) + const headers = netFetchMock.mock.calls[0]?.[1]?.headers as Headers + expect(headers.get('Authorization')).toBe( + `Basic ${Buffer.from('jdoe:account-password').toString('base64')}` + ) + }) + + it('uses Basic auth for stored self-hosted sites that carry a username', async () => { + const siteId = 'site-server-basic' + const orcaDir = join(tempHome, '.orca') + mkdirSync(join(orcaDir, 'jira-tokens'), { recursive: true }) + writeFileSync( + join(orcaDir, 'jira-sites.json'), + JSON.stringify({ + version: 1, + activeSiteId: siteId, + selectedSiteId: siteId, + sites: [ + { + id: siteId, + siteUrl: 'https://jira.example.com', + email: 'jdoe', + displayName: 'Jane Doe', + accountId: 'jdoe', + authType: 'server' + } + ] + }), + { encoding: 'utf-8' } + ) + writeFileSync(tokenPathForSite(siteId), 'account-password') + netFetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ name: 'jdoe', displayName: 'Jane Doe' }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + ) + const jira = await loadClientModule() + + await expect(jira.testConnection(siteId)).resolves.toMatchObject({ ok: true }) + + const headers = netFetchMock.mock.calls[0]?.[1]?.headers as Headers + expect(headers.get('Authorization')).toBe( + `Basic ${Buffer.from('jdoe:account-password').toString('base64')}` + ) + }) + + it('requires a token but not an email for self-hosted connections', async () => { + const jira = await loadClientModule() + + await expect( + jira.connect({ + siteUrl: 'jira.example.com', + email: '', + apiToken: '', + authType: 'server' + }) + ).resolves.toEqual({ ok: false, error: 'Personal access token is required.' }) + expect(netFetchMock).not.toHaveBeenCalled() + }) + + it('requires a password when a username is present on self-hosted', async () => { + const jira = await loadClientModule() + + await expect( + jira.connect({ + siteUrl: 'jira.example.com', + email: 'jdoe', + apiToken: '', + authType: 'server' + }) + ).resolves.toEqual({ ok: false, error: 'Password is required.' }) + expect(netFetchMock).not.toHaveBeenCalled() + }) + + it('keeps distinct self-hosted PAT accounts on one host as separate sites', async () => { + netFetchMock + .mockResolvedValueOnce( + new Response(JSON.stringify({ name: 'alice', displayName: 'Alice' }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ name: 'bot', displayName: 'Bot' }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + ) + const jira = await loadClientModule() + + await jira.connect({ + siteUrl: 'jira.example.com', + email: '', + apiToken: 'alice-pat', + authType: 'server' + }) + await jira.connect({ + siteUrl: 'jira.example.com', + email: '', + apiToken: 'bot-pat', + authType: 'server' + }) + + // Two PATs (both with empty email) to the same host must not collide onto + // one id and silently overwrite each other — the viewer identity keys them. + const stored = JSON.parse( + readFileSync(join(tempHome, '.orca', 'jira-sites.json'), 'utf-8') + ) as { + sites: { accountId: string }[] + } + expect(stored.sites).toHaveLength(2) + expect(stored.sites.map((site) => site.accountId).sort()).toEqual(['alice', 'bot']) + }) + + it('uses Bearer auth and REST v2 for stored self-hosted sites', async () => { + const siteId = 'site-server' + const orcaDir = join(tempHome, '.orca') + mkdirSync(join(orcaDir, 'jira-tokens'), { recursive: true }) + writeFileSync( + join(orcaDir, 'jira-sites.json'), + JSON.stringify({ + version: 1, + activeSiteId: siteId, + selectedSiteId: siteId, + sites: [ + { + id: siteId, + siteUrl: 'https://jira.example.com', + email: '', + displayName: 'William', + accountId: 'wquintal', + authType: 'server' + } + ] + }), + { encoding: 'utf-8' } + ) + writeFileSync(tokenPathForSite(siteId), 'pat-token') + netFetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ name: 'wquintal', displayName: 'William' }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + ) + const jira = await loadClientModule() + + await expect(jira.testConnection(siteId)).resolves.toMatchObject({ + ok: true, + viewer: { displayName: 'William' } + }) + + expect(netFetchMock).toHaveBeenCalledWith( + 'https://jira.example.com/rest/api/2/myself', + expect.objectContaining({ headers: expect.any(Headers) }) + ) + const headers = netFetchMock.mock.calls[0]?.[1]?.headers as Headers + expect(headers.get('Authorization')).toBe('Bearer pat-token') + }) + it('bridges proxy environment settings before Jira connect requests', async () => { netFetchMock.mockResolvedValueOnce( new Response( diff --git a/src/main/jira/client.ts b/src/main/jira/client.ts index cce8ee85b67c..4c182ee2ad55 100644 --- a/src/main/jira/client.ts +++ b/src/main/jira/client.ts @@ -14,6 +14,7 @@ import { import { ensureElectronProxyFromEnvironment } from '../network/proxy-settings' import { withSpan } from '../observability/tracer' import type { + JiraAuthType, JiraConnectArgs, JiraConnectionStatus, JiraSite, @@ -65,6 +66,13 @@ export type JiraClientForSite = { authorization: string } +// Self-hosted Jira Server/Data Center only exposes REST v2; Cloud endpoints +// in this codebase are written against v3. Callers build paths with this +// prefix so one code path serves both deployments. +export function apiBasePath(site: JiraSite): string { + return site.authType === 'server' ? '/rest/api/2' : '/rest/api/3' +} + export class JiraApiError extends Error { status: number | null @@ -143,7 +151,9 @@ function normalizeSite(input: unknown): JiraSite | null { siteUrl: record.siteUrl, email: record.email, displayName: record.displayName, - accountId: record.accountId + accountId: record.accountId, + // Sites saved before self-hosted support have no authType; they are Cloud. + authType: record.authType === 'server' ? 'server' : 'cloud' } } @@ -284,8 +294,17 @@ function getSiteId(siteUrl: string, email: string): string { function toViewer(data: Record, fallbackEmail: string): JiraViewer { const avatarUrls = data.avatarUrls as Record | undefined + // Server/DC /myself has no accountId; its stable identifiers are name/key. + const accountId = + typeof data.accountId === 'string' + ? data.accountId + : typeof data.name === 'string' + ? data.name + : typeof data.key === 'string' + ? data.key + : '' return { - accountId: typeof data.accountId === 'string' ? data.accountId : '', + accountId, displayName: typeof data.displayName === 'string' ? data.displayName : fallbackEmail, email: typeof data.emailAddress === 'string' ? data.emailAddress : fallbackEmail, avatarUrl: @@ -308,7 +327,14 @@ function siteToViewer(site: JiraSite | null): JiraViewer | null { } } -function authHeader(email: string, apiToken: string): string { +function authHeader(email: string, apiToken: string, authType?: JiraAuthType): string { + // Self-hosted with no username = a personal access token (Bearer); Basic auth + // with a PAT in the password slot is what produces the 401s users report. + // Self-hosted WITH a username is classic username+password Basic auth, which + // older Server/DC instances (predating PATs) require. Cloud is always Basic. + if (authType === 'server' && !email) { + return `Bearer ${apiToken}` + } return `Basic ${Buffer.from(`${email}:${apiToken}`).toString('base64')}` } @@ -366,13 +392,14 @@ async function requestWithCredentials( email: string, apiToken: string, path: string, - init?: RequestInit + init?: RequestInit, + authType?: JiraAuthType ): Promise { const headers = new Headers(init?.headers) headers.set('Accept', 'application/json') headers.set('Content-Type', 'application/json') headers.set('User-Agent', JIRA_API_USER_AGENT) - headers.set('Authorization', authHeader(email, apiToken)) + headers.set('Authorization', authHeader(email, apiToken, authType)) const response = await jiraFetch(`${siteUrl}${path}`, { ...init, headers @@ -453,7 +480,7 @@ export function getClients(selection?: JiraSiteSelection | null): JiraClientForS } throw error } - return token ? [{ site, authorization: authHeader(site.email, token) }] : [] + return token ? [{ site, authorization: authHeader(site.email, token, site.authType) }] : [] }) } @@ -484,28 +511,48 @@ export async function connect( return { ok: false, error: 'Enter a valid Jira site URL.' } } + const authType: JiraAuthType = args.authType === 'server' ? 'server' : 'cloud' const email = args.email.trim() const apiToken = args.apiToken.trim() - if (!email || !apiToken) { + if (authType === 'server') { + if (!apiToken) { + // A username present means classic Basic auth (password); its absence + // means the credential is a personal access token sent as Bearer. + return { + ok: false, + error: email ? 'Password is required.' : 'Personal access token is required.' + } + } + } else if (!email || !apiToken) { return { ok: false, error: 'Email and API token are required.' } } await acquire() try { + const myselfPath = authType === 'server' ? '/rest/api/2/myself' : '/rest/api/3/myself' const viewer = toViewer( - (await requestWithCredentials(siteUrl, email, apiToken, '/rest/api/3/myself')) as Record< - string, - unknown - >, - email + (await requestWithCredentials( + siteUrl, + email, + apiToken, + myselfPath, + undefined, + authType + )) as Record, + email || siteUrl ) - const id = getSiteId(siteUrl, email) + // PAT sites have no email, so keying on it alone would collide every PAT + // connection to the same host into one id (silently overwriting a prior + // account + token). Fall back to the verified viewer identity so distinct + // accounts stay distinct. Cloud/Basic keep keying on their non-empty email. + const id = getSiteId(siteUrl, email || viewer.accountId) const site: JiraSite = { id, siteUrl, email, displayName: viewer.displayName, - accountId: viewer.accountId + accountId: viewer.accountId, + authType } saveToken(id, apiToken) const file = getSiteFile() @@ -565,7 +612,7 @@ export async function testConnection( await acquire() try { const viewer = toViewer( - (await jiraRequest(client, '/rest/api/3/myself')) as Record, + (await jiraRequest(client, `${apiBasePath(client.site)}/myself`)) as Record, client.site.email ) return { ok: true, viewer } diff --git a/src/main/jira/issues.test.ts b/src/main/jira/issues.test.ts index 1cf9ab312121..4b20b0c4a2f6 100644 --- a/src/main/jira/issues.test.ts +++ b/src/main/jira/issues.test.ts @@ -12,6 +12,8 @@ const { clearTokenMock, getClientsMock, isAuthErrorMock, jiraRequestMock } = vi. vi.mock('./client', () => ({ acquire: vi.fn().mockResolvedValue(undefined), release: vi.fn(), + apiBasePath: (site: { authType?: string }) => + site.authType === 'server' ? '/rest/api/2' : '/rest/api/3', clearToken: (...args: unknown[]) => clearTokenMock(...args), getClients: (...args: unknown[]) => getClientsMock(...args), isAuthError: (...args: unknown[]) => isAuthErrorMock(...args), @@ -31,6 +33,20 @@ function makeEntry(id = 'site-1'): JiraClientForSite { } } +function makeServerEntry(id = 'server-1'): JiraClientForSite { + return { + site: { + id, + siteUrl: 'https://jira.example.com', + email: '', + displayName: 'Self-hosted Jira', + accountId: 'wquintal', + authType: 'server' + }, + authorization: 'Bearer pat-token' + } +} + describe('Jira issue operations', () => { beforeEach(() => { vi.clearAllMocks() @@ -60,6 +76,67 @@ describe('Jira issue operations', () => { ).rejects.toThrow(error.message) }) + it('uses classic /search and REST v2 for self-hosted sites', async () => { + getClientsMock.mockReturnValue([makeServerEntry()]) + jiraRequestMock.mockResolvedValueOnce({ issues: [] }) + const { searchIssues } = await import('./issues') + + await searchIssues('project = ALP', 20, 'server-1') + + expect(jiraRequestMock).toHaveBeenCalledWith( + expect.objectContaining({ site: expect.objectContaining({ authType: 'server' }) }), + '/rest/api/2/search', + expect.objectContaining({ method: 'POST' }) + ) + }) + + it('sends plain-text bodies and v2 paths for self-hosted issue creation', async () => { + getClientsMock.mockReturnValue([makeServerEntry()]) + jiraRequestMock.mockResolvedValueOnce({ id: '1', key: 'ALP-1', self: '' }) + const { createIssue } = await import('./issues') + + await createIssue({ + siteId: 'server-1', + projectId: '10000', + issueTypeId: '10001', + title: 'Fix auth', + description: 'Body text' + }) + + const [, path, init] = jiraRequestMock.mock.calls[0] + expect(path).toBe('/rest/api/2/issue') + const body = JSON.parse((init as { body: string }).body) as { + fields: { description: unknown } + } + // REST v2 rejects ADF documents; the description must stay a plain string. + expect(body.fields.description).toBe('Body text') + }) + + it('assigns by username on self-hosted sites', async () => { + getClientsMock.mockReturnValue([makeServerEntry()]) + jiraRequestMock.mockResolvedValue(null) + const { updateIssue } = await import('./issues') + + await updateIssue('ALP-1', { assigneeAccountId: 'wquintal' }, 'server-1') + + expect(jiraRequestMock).toHaveBeenCalledWith( + expect.anything(), + '/rest/api/2/issue/ALP-1/assignee', + expect.objectContaining({ body: JSON.stringify({ name: 'wquintal' }) }) + ) + }) + + it('lists self-hosted projects from the unpaged /project resource', async () => { + getClientsMock.mockReturnValue([makeServerEntry()]) + jiraRequestMock.mockResolvedValueOnce([{ id: '1', key: 'ALP', name: 'Alpha' }]) + const { listProjects } = await import('./issues') + + const projects = await listProjects('server-1') + + expect(jiraRequestMock).toHaveBeenCalledWith(expect.anything(), '/rest/api/2/project') + expect(projects).toMatchObject([{ key: 'ALP', name: 'Alpha' }]) + }) + it('rejects single-site search failures so the UI can surface them', async () => { getClientsMock.mockReturnValue([makeEntry('site-1')]) jiraRequestMock.mockRejectedValueOnce(new Error('Forbidden')) diff --git a/src/main/jira/issues.ts b/src/main/jira/issues.ts index 5a19b9897b37..2ce24a87420c 100644 --- a/src/main/jira/issues.ts +++ b/src/main/jira/issues.ts @@ -23,6 +23,7 @@ import type { } from '../../shared/types' import { acquire, + apiBasePath, clearToken, getClients, isAuthError, @@ -191,7 +192,8 @@ function avatarUrl(value: unknown): string | undefined { function mapUser(value: unknown): JiraUser | undefined { const user = asRecord(value) - const accountId = asString(user.accountId) + // Server/DC users have no accountId; name (login) and key are its stable ids. + const accountId = asString(user.accountId) || asString(user.name) || asString(user.key) if (!accountId) { return undefined } @@ -307,6 +309,11 @@ function issueUrl(site: JiraSite, key: string): string { return `${site.siteUrl}/browse/${encodeURIComponent(key)}` } +// REST v2 (Server/DC) bodies are plain text; v3 (Cloud) requires ADF documents. +function toBodyText(site: JiraSite, text: string): unknown { + return site.authType === 'server' ? text : textToAdf(text) +} + export function mapJiraIssue(site: JiraSite, raw: JiraRecord): JiraIssue { const fields = asRecord(raw.fields) const key = asString(raw.key) @@ -354,7 +361,12 @@ async function searchIssuesForClient( jql: string, limit: number ): Promise { - const result = await jiraRequest(entry, '/rest/api/3/search/jql', { + // Server/DC only has the classic /search resource; /search/jql is Cloud-only. + const searchPath = + entry.site.authType === 'server' + ? `${apiBasePath(entry.site)}/search` + : '/rest/api/3/search/jql' + const result = await jiraRequest(entry, searchPath, { method: 'POST', body: JSON.stringify({ jql, @@ -429,7 +441,7 @@ export async function getIssue( try { const issue = await jiraRequest( entry, - `/rest/api/3/issue/${encodeURIComponent(key)}?fields=${encodeURIComponent( + `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}?fields=${encodeURIComponent( ISSUE_FIELDS.join(',') )}` ) @@ -468,7 +480,7 @@ export async function createIssue(args: JiraCreateIssueArgs): Promise( entry, - '/rest/api/3/issue', + `${apiBasePath(entry.site)}/issue`, { method: 'POST', body: JSON.stringify({ fields }) @@ -517,20 +529,27 @@ export async function updateIssue( if (updates.priorityId !== undefined) { fields.priority = updates.priorityId ? { id: updates.priorityId } : null } + const issueBase = `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}` if (Object.keys(fields).length > 0) { - await jiraRequest(entry, `/rest/api/3/issue/${encodeURIComponent(key)}`, { + await jiraRequest(entry, issueBase, { method: 'PUT', body: JSON.stringify({ fields }) }) } if (updates.assigneeAccountId !== undefined) { - await jiraRequest(entry, `/rest/api/3/issue/${encodeURIComponent(key)}/assignee`, { + // Server/DC identifies assignees by username (`name`), not accountId; + // mapUser stores the Server username in the accountId slot. + const assigneeBody = + entry.site.authType === 'server' + ? { name: updates.assigneeAccountId } + : { accountId: updates.assigneeAccountId } + await jiraRequest(entry, `${issueBase}/assignee`, { method: 'PUT', - body: JSON.stringify({ accountId: updates.assigneeAccountId }) + body: JSON.stringify(assigneeBody) }) } if (updates.transitionId) { - await jiraRequest(entry, `/rest/api/3/issue/${encodeURIComponent(key)}/transitions`, { + await jiraRequest(entry, `${issueBase}/transitions`, { method: 'POST', body: JSON.stringify({ transition: { id: updates.transitionId } }) }) @@ -560,10 +579,10 @@ export async function addIssueComment( try { const comment = await jiraRequest<{ id: string }>( entry, - `/rest/api/3/issue/${encodeURIComponent(key)}/comment`, + `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}/comment`, { method: 'POST', - body: JSON.stringify({ body: textToAdf(body) }) + body: JSON.stringify({ body: toBodyText(entry.site, body) }) } ) return { ok: true, id: comment.id } @@ -604,7 +623,7 @@ export async function getIssueComments( orderBy: 'created', startAt: String(startAt) }) - return `/rest/api/3/issue/${encodeURIComponent(key)}/comment?${params.toString()}` + return `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}/comment?${params.toString()}` }) return comments.map(mapComment) } catch (error) { @@ -628,13 +647,18 @@ export async function listProjects(siteId?: JiraSiteSelection | null): Promise { await acquire() try { - const projects = await fetchPagedRecords(entry, 'values', (startAt, maxResults) => { - const params = new URLSearchParams({ - maxResults: String(maxResults), - startAt: String(startAt) - }) - return `/rest/api/3/project/search?${params.toString()}` - }) + // Server/DC has no /project/search resource; /project returns the + // full list as a plain (unpaged) array. + const projects = + entry.site.authType === 'server' + ? await jiraRequest(entry, `${apiBasePath(entry.site)}/project`) + : await fetchPagedRecords(entry, 'values', (startAt, maxResults) => { + const params = new URLSearchParams({ + maxResults: String(maxResults), + startAt: String(startAt) + }) + return `/rest/api/3/project/search?${params.toString()}` + }) return projects.map((project) => mapProject(project, entry.site)) } catch (error) { if (isAuthError(error)) { @@ -669,7 +693,8 @@ export async function listIssueTypes( maxResults: String(maxResults), startAt: String(startAt) }) - return `/rest/api/3/issue/createmeta/${encodeURIComponent( + // Per-project createmeta paths exist on Server/DC from Jira 8.4 onward. + return `${apiBasePath(entry.site)}/issue/createmeta/${encodeURIComponent( projectIdOrKey )}/issuetypes?${params.toString()}` }) @@ -707,7 +732,7 @@ export async function listCreateFields( }) const response = await jiraRequest>( entry, - `/rest/api/3/issue/createmeta/${encodeURIComponent( + `${apiBasePath(entry.site)}/issue/createmeta/${encodeURIComponent( projectIdOrKey )}/issuetypes/${encodeURIComponent(issueTypeId)}?${params.toString()}` ) @@ -742,7 +767,7 @@ export async function listPriorities(siteId?: string | null): Promise(entry, '/rest/api/3/priority') + const response = await jiraRequest(entry, `${apiBasePath(entry.site)}/priority`) return response.map(mapPriority).filter((priority): priority is JiraPriority => !!priority) } catch (error) { if (isAuthError(error)) { @@ -765,15 +790,17 @@ export async function listAssignableUsers( if (!entry) { return [] } + const isServer = entry.site.authType === 'server' const params = new URLSearchParams({ issueKey: key, maxResults: '50' }) if (query?.trim()) { - params.set('query', query.trim()) + // Server/DC filters assignable users by `username`; `query` is Cloud-only. + params.set(isServer ? 'username' : 'query', query.trim()) } await acquire() try { const response = await jiraRequest( entry, - `/rest/api/3/user/assignable/search?${params.toString()}` + `${apiBasePath(entry.site)}/user/assignable/search?${params.toString()}` ) return response.map(mapUser).filter((user): user is JiraUser => !!user) } catch (error) { @@ -800,7 +827,7 @@ export async function listTransitions( try { const response = await jiraRequest<{ transitions?: JiraRecord[] }>( entry, - `/rest/api/3/issue/${encodeURIComponent(key)}/transitions` + `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}/transitions` ) return (response.transitions ?? []).map((transition) => ({ id: asString(transition.id), diff --git a/src/main/runtime/rpc/methods/jira.ts b/src/main/runtime/rpc/methods/jira.ts index 35d1d067ca1e..a18eb2d02d30 100644 --- a/src/main/runtime/rpc/methods/jira.ts +++ b/src/main/runtime/rpc/methods/jira.ts @@ -17,8 +17,10 @@ const SiteSelection = z const Connect = z.object({ siteUrl: requiredString('Site URL is required'), - email: requiredString('Email is required'), - apiToken: requiredString('API token is required') + // Self-hosted PAT auth needs no email; connect() enforces it for Cloud. + email: OptionalPlainString, + apiToken: requiredString('API token is required'), + authType: z.enum(['cloud', 'server']).optional() }) const SelectSite = z.object({ @@ -100,8 +102,9 @@ export const JIRA_METHODS: RpcMethod[] = [ handler: async (params, { runtime }) => runtime.jiraConnect({ siteUrl: params.siteUrl.trim(), - email: params.email.trim(), - apiToken: params.apiToken.trim() + email: params.email?.trim() ?? '', + apiToken: params.apiToken.trim(), + authType: params.authType }) }), defineMethod({ diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 695e037379aa..2ce823028c5f 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -2007,6 +2007,7 @@ export type PreloadApi = { siteUrl: string email: string apiToken: string + authType?: 'cloud' | 'server' }) => Promise<{ ok: true; viewer: JiraViewer } | { ok: false; error: string }> disconnect: (args?: { siteId?: string }) => Promise selectSite: (args: { siteId: JiraSiteSelection }) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 64276047cea7..912e1852cfa4 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1744,6 +1744,7 @@ const api = { siteUrl: string email: string apiToken: string + authType?: 'cloud' | 'server' }): Promise<{ ok: true; viewer: unknown } | { ok: false; error: string }> => ipcRenderer.invoke('jira:connect', args), diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 0ade42f2811d..af47d26736d1 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -27,7 +27,6 @@ import { GitPullRequestDraft, List, LoaderCircle, - Lock, Minus, Plus, RefreshCw, @@ -90,6 +89,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' import TaskProjectSourceCombobox from '@/components/task-project-source-combobox' +import { JiraConnectDialog } from '@/components/jira-connect-dialog' import { LinearApiKeyDialog } from '@/components/linear-api-key-dialog' import { LinearScopeSelector } from '@/components/linear-scope-selector' import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' @@ -3118,7 +3118,6 @@ export default function TaskPage(): React.JSX.Element { const jiraStatus = useAppStore((s) => s.jiraStatus) const jiraStatusChecked = useAppStore((s) => s.jiraStatusChecked) const jiraStatusContextKey = useAppStore((s) => s.jiraStatusContextKey) - const connectJira = useAppStore((s) => s.connectJira) const selectJiraSite = useAppStore((s) => s.selectJiraSite) const searchJiraIssues = useAppStore((s) => s.searchJiraIssues) const listJiraIssues = useAppStore((s) => s.listJiraIssues) @@ -5849,6 +5848,7 @@ export default function TaskPage(): React.JSX.Element { }, [newLinearStates.data, newLinearIssueStateId]) const [linearConnectOpen, setLinearConnectOpen] = useState(false) + const [jiraConnectOpen, setJiraConnectOpen] = useState(false) useContextualTour( 'tasks', !dialogWorkItem && @@ -5858,6 +5858,7 @@ export default function TaskPage(): React.JSX.Element { !newLinearProjectOpen && !newLinearIssueOpen && !linearConnectOpen && + !jiraConnectOpen && activeModal === 'none', 'tasks_open' ) @@ -5897,12 +5898,6 @@ export default function TaskPage(): React.JSX.Element { const [newJiraIssueCustomFieldValues, setNewJiraIssueCustomFieldValues] = useState< Record >({}) - const [jiraConnectOpen, setJiraConnectOpen] = useState(false) - const [jiraSiteUrlDraft, setJiraSiteUrlDraft] = useState('') - const [jiraEmailDraft, setJiraEmailDraft] = useState('') - const [jiraApiTokenDraft, setJiraApiTokenDraft] = useState('') - const [jiraConnectState, setJiraConnectState] = useState<'idle' | 'connecting' | 'error'>('idle') - const [jiraConnectError, setJiraConnectError] = useState(null) const includeJiraSiteNameInProjectLabel = selectedJiraSiteId === 'all' const previousProviderRuntimeContextKeyRef = useRef(providerRuntimeContextKey) @@ -8114,33 +8109,6 @@ export default function TaskPage(): React.JSX.Element { [openComposerForJiraItem] ) - const handleJiraConnect = useCallback(async (): Promise => { - const siteUrl = jiraSiteUrlDraft.trim() - const email = jiraEmailDraft.trim() - const apiToken = jiraApiTokenDraft.trim() - if (!siteUrl || !email || !apiToken) { - return - } - setJiraConnectState('connecting') - setJiraConnectError(null) - try { - const result = await connectJira({ siteUrl, email, apiToken }) - if (result.ok) { - setJiraSiteUrlDraft('') - setJiraEmailDraft('') - setJiraApiTokenDraft('') - setJiraConnectState('idle') - setJiraConnectOpen(false) - } else { - setJiraConnectState('error') - setJiraConnectError(result.error) - } - } catch (error) { - setJiraConnectState('error') - setJiraConnectError(error instanceof Error ? error.message : 'Connection failed') - } - }, [connectJira, jiraApiTokenDraft, jiraEmailDraft, jiraSiteUrlDraft]) - const taskPageListChromeHidden = shouldHideTaskPageListChrome({ taskSource, hasGitHubDetail: Boolean(dialogWorkItem), @@ -10085,16 +10053,7 @@ export default function TaskPage(): React.JSX.Element { )}

- - . -

-

- - {translate( - 'auto.components.TaskPage.2abe22ef76', - 'Your token is encrypted via the OS keychain and stored locally.' - )} -

-
- - - - - - +
) } diff --git a/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts b/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts index 79ea48d89290..ad7a11973232 100644 --- a/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts +++ b/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts @@ -205,8 +205,11 @@ describe('feature interaction writer boundaries', () => { const taskPageSource = componentSource('TaskPage.tsx') const jiraWriter = "recordFeatureInteraction('jira-tasks')" + // End boundary is the declaration after the handler: the Jira connect flow + // now lives in the shared JiraConnectDialog, so handleJiraConnect (the prior + // marker) no longer exists in TaskPage. expect( - sourceBetween(taskPageSource, 'const handleUseJiraItem', 'const handleJiraConnect') + sourceBetween(taskPageSource, 'const handleUseJiraItem', 'const taskPageListChromeHidden') ).toContain(jiraWriter) }) diff --git a/src/renderer/src/components/jira-connect-dialog.tsx b/src/renderer/src/components/jira-connect-dialog.tsx index eff9f0cde24f..0de9093185c2 100644 --- a/src/renderer/src/components/jira-connect-dialog.tsx +++ b/src/renderer/src/components/jira-connect-dialog.tsx @@ -1,4 +1,4 @@ -import { useId, useState } from 'react' +import { useId, useLayoutEffect, useState } from 'react' import { LoaderCircle, Lock } from 'lucide-react' import { useAppStore } from '@/store' import { useMountedRef } from '@/hooks/useMountedRef' @@ -13,6 +13,7 @@ import { } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' +import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' import { cn } from '@/lib/utils' import { hasRemoteProviderRuntime } from '@/lib/provider-runtime-context' import { translate } from '@/i18n/i18n' @@ -26,6 +27,10 @@ type JiraConnectDialogProps = { } type ConnectState = 'idle' | 'connecting' | 'error' +type JiraInstanceType = 'cloud' | 'server' +// Self-hosted Jira accepts either a personal access token (Bearer) or classic +// username + password (Basic); older Server/DC instances predate PATs. +type ServerAuthMethod = 'pat' | 'basic' // Why: mirrors the inline Jira connect dialog in TaskPage so the onboarding // "Connect integrations" step can reuse the same site URL + email + API token @@ -45,15 +50,39 @@ export function JiraConnectDialog({ const tokenId = useId() const errorId = useId() + const [instanceType, setInstanceType] = useState('cloud') + const [serverAuthMethod, setServerAuthMethod] = useState('pat') const [siteUrl, setSiteUrl] = useState('') const [email, setEmail] = useState('') const [apiToken, setApiToken] = useState('') const [connectState, setConnectState] = useState('idle') const [connectError, setConnectError] = useState(null) + // Start every open with a clean slate so a previously-typed secret, stale + // instance/auth-method selection, or old error can't linger across reopens. + // Runs before paint so a stale credential never renders for a frame. + useLayoutEffect(() => { + if (!open) { + return + } + setInstanceType('cloud') + setServerAuthMethod('pat') + setSiteUrl('') + setEmail('') + setApiToken('') + setConnectState('idle') + setConnectError(null) + }, [open]) + + const isServer = instanceType === 'server' + // `needsIdentity` folds "Cloud Atlassian email" and "self-hosted Basic + // username" — the identity slot that keys/labels the stored site. PAT auth + // uses no identity, so the email field is hidden and left empty. + const isServerBasic = isServer && serverAuthMethod === 'basic' + const needsIdentity = !isServer || isServerBasic const canSubmit = Boolean(siteUrl.trim()) && - Boolean(email.trim()) && + (!needsIdentity || Boolean(email.trim())) && Boolean(apiToken.trim()) && connectState !== 'connecting' const credentialStorageCopy = hasRemoteProviderRuntime(settings) @@ -67,6 +96,16 @@ export function JiraConnectDialog({ } } + // A Cloud email, a Server username, a PAT, and an account password are + // different secrets; drop the credential fields when the deployment or auth + // method changes so one can't be submitted as another (e.g. a password + // silently riding along as a Bearer PAT). + const clearCredentialsOnModeSwitch = (): void => { + setEmail('') + setApiToken('') + clearErrorOnEdit() + } + const handleOpenChange = (nextOpen: boolean): void => { if (connectState !== 'connecting') { onOpenChange(nextOpen) @@ -77,7 +116,12 @@ export function JiraConnectDialog({ const trimmedSite = siteUrl.trim() const trimmedEmail = email.trim() const trimmedToken = apiToken.trim() - if (!trimmedSite || !trimmedEmail || !trimmedToken || connectState === 'connecting') { + if ( + !trimmedSite || + (needsIdentity && !trimmedEmail) || + !trimmedToken || + connectState === 'connecting' + ) { return } setConnectState('connecting') @@ -85,8 +129,11 @@ export function JiraConnectDialog({ try { const result = await connectJira({ siteUrl: trimmedSite, - email: trimmedEmail, - apiToken: trimmedToken + // Cloud sends the Atlassian email; self-hosted Basic sends the username; + // PAT sends nothing, so a stale email can't key/label the stored site. + email: needsIdentity ? trimmedEmail : '', + apiToken: trimmedToken, + authType: instanceType }) if (!mountedRef.current) { return @@ -95,6 +142,8 @@ export function JiraConnectDialog({ setSiteUrl('') setEmail('') setApiToken('') + setInstanceType('cloud') + setServerAuthMethod('pat') setConnectState('idle') onOpenChange(false) onConnected?.() @@ -121,31 +170,110 @@ export function JiraConnectDialog({ {translate('auto.components.jira.connect.dialog.8388bdea2b', 'Connect Jira site')} - {translate( - 'auto.components.jira.connect.dialog.d785c42b8b', - 'Use a Jira Cloud site URL, Atlassian email, and API token to browse issues.' - )} + {!isServer + ? translate( + 'auto.components.jira.connect.dialog.d785c42b8b', + 'Use a Jira Cloud site URL, Atlassian email, and API token to browse issues.' + ) + : isServerBasic + ? translate( + 'auto.components.jira.connect.dialog.1d947a07ab', + 'Use a self-hosted Jira base URL, username, and password to browse issues.' + ) + : translate( + 'auto.components.jira.connect.dialog.2e2b69e48e', + 'Use a self-hosted Jira base URL and a personal access token to browse issues.' + )}
{ event.preventDefault() void handleConnect() }} >
+ { + if (!value || connectState === 'connecting') { + return + } + setInstanceType(value as JiraInstanceType) + clearCredentialsOnModeSwitch() + }} + aria-label={translate( + 'auto.components.jira.connect.dialog.b67e919bd5', + 'Jira instance type' + )} + > + + {translate('auto.components.jira.connect.dialog.17787d6e4b', 'Atlassian Cloud')} + + + {translate('auto.components.jira.connect.dialog.bc7a831773', 'Self-hosted')} + + + {isServer ? ( + { + if (!value || connectState === 'connecting') { + return + } + setServerAuthMethod(value as ServerAuthMethod) + clearCredentialsOnModeSwitch() + }} + aria-label={translate( + 'auto.components.jira.connect.dialog.f49708c369', + 'Jira authentication method' + )} + > + + {translate( + 'auto.components.jira.connect.dialog.730d973bae', + 'Personal access token' + )} + + + {translate( + 'auto.components.jira.connect.dialog.84a810dd0e', + 'Username & password' + )} + + + ) : null}
{ setSiteUrl(event.target.value) @@ -154,36 +282,66 @@ export function JiraConnectDialog({ disabled={connectState === 'connecting'} />
-
- - { - setEmail(event.target.value) - clearErrorOnEdit() - }} - disabled={connectState === 'connecting'} - /> -
+ {needsIdentity ? ( +
+ + { + setEmail(event.target.value) + clearErrorOnEdit() + }} + disabled={connectState === 'connecting'} + /> +
+ ) : null}
{ setApiToken(event.target.value) @@ -199,24 +357,40 @@ export function JiraConnectDialog({ {connectError}

) : null} -

- {translate('auto.components.jira.connect.dialog.8090504a3e', 'Create a token in')}{' '} - - . -

+

+ ) : isServer ? ( +

+ {translate( + 'auto.components.jira.connect.dialog.ccfb086d3e', + 'Create a personal access token in your Jira profile under Personal Access Tokens.' + )} +

+ ) : ( +

+ {translate('auto.components.jira.connect.dialog.8090504a3e', 'Create a token in')}{' '} + + . +

+ )}

{credentialStorageCopy} diff --git a/src/renderer/src/components/settings/jira-integration-card.tsx b/src/renderer/src/components/settings/jira-integration-card.tsx index 0501d05faf16..8427e6f4cf7f 100644 --- a/src/renderer/src/components/settings/jira-integration-card.tsx +++ b/src/renderer/src/components/settings/jira-integration-card.tsx @@ -38,8 +38,14 @@ export function JiraIntegrationCard(): React.JSX.Element { const siteCount = sites.length || (connected ? 1 : 0) const accountScope = getProviderAccountScope(settings) const credentialCopy = hasRemoteProviderRuntime(settings) - ? 'Connect a Jira Cloud site with your Atlassian email and an API token. Credentials are sent to the selected remote runtime and stored there with runtime-supported encryption.' - : 'Connect a Jira Cloud site with your Atlassian email and an API token. Credentials are stored locally and encrypted when local runtime storage supports it.' + ? translate( + 'auto.components.settings.task.tracker.integration.cards.2d60ec7921', + 'Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are sent to the selected remote runtime and stored there with runtime-supported encryption.' + ) + : translate( + 'auto.components.settings.task.tracker.integration.cards.977e360b71', + 'Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are stored locally and encrypted when local runtime storage supports it.' + ) const subordinateRowClass = useIntegrationSubordinateRowClass('flex items-center gap-3') const accountScopeRowClass = useIntegrationSubordinateRowClass('text-xs') diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 1abdf8130efc..5fa8b146a617 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -8678,7 +8678,9 @@ "fe9231215b": "Checking Linear access before showing setup actions.", "e1f5e6424c": "{{value0}} workspace{{value1}} connected", "disconnect_all": "Disconnect", - "account_scope_prefix": "Account scope" + "account_scope_prefix": "Account scope", + "2d60ec7921": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are sent to the selected remote runtime and stored there with runtime-supported encryption.", + "977e360b71": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are stored locally and encrypted when local runtime storage supports it." } } } @@ -12692,7 +12694,24 @@ "70fcd360c4": "https://example.atlassian.net", "e176f9d0c5": "Jira Cloud site URL", "d785c42b8b": "Use a Jira Cloud site URL, Atlassian email, and API token to browse issues.", - "8388bdea2b": "Connect Jira site" + "8388bdea2b": "Connect Jira site", + "2e2b69e48e": "Use a self-hosted Jira base URL and a personal access token to browse issues.", + "b67e919bd5": "Jira instance type", + "17787d6e4b": "Atlassian Cloud", + "bc7a831773": "Self-hosted", + "3489e186d6": "Jira site URL", + "cbc27fa599": "https://jira.example.com", + "730d973bae": "Personal access token", + "8b9c7b9e7b": "Jira personal access token", + "ccfb086d3e": "Create a personal access token in your Jira profile under Personal Access Tokens.", + "1d947a07ab": "Use a self-hosted Jira base URL, username, and password to browse issues.", + "f49708c369": "Jira authentication method", + "84a810dd0e": "Username & password", + "8d1223fa5c": "Username", + "be9eba0a1b": "username", + "70035652d7": "Password", + "c50abbf340": "Jira account password", + "d8737db691": "Use your Jira Server or Data Center account username and password." } } }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 6c4707b53491..45e1afec7a48 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -8670,7 +8670,9 @@ "fe9231215b": "Comprobando el acceso a Linear antes de mostrar las acciones de configuración.", "e1f5e6424c": "Conexión completada para {{value0}} workspace{{value1}}", "disconnect_all": "Desconectar todo", - "account_scope_prefix": "Alcance de la cuenta" + "account_scope_prefix": "Alcance de la cuenta", + "2d60ec7921": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are sent to the selected remote runtime and stored there with runtime-supported encryption.", + "977e360b71": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are stored locally and encrypted when local runtime storage supports it." } } } @@ -12692,7 +12694,24 @@ "70fcd360c4": "https://example.atlassian.net", "e176f9d0c5": "URL del sitio de Jira Cloud", "d785c42b8b": "Usa una URL de Jira Cloud, email de Atlassian y token API para explorar issues.", - "8388bdea2b": "Conectar sitio de Jira" + "8388bdea2b": "Conectar sitio de Jira", + "2e2b69e48e": "Use a self-hosted Jira base URL and a personal access token to browse issues.", + "b67e919bd5": "Jira instance type", + "17787d6e4b": "Atlassian Cloud", + "bc7a831773": "Self-hosted", + "3489e186d6": "Jira site URL", + "cbc27fa599": "https://jira.example.com", + "730d973bae": "Personal access token", + "8b9c7b9e7b": "Jira personal access token", + "ccfb086d3e": "Create a personal access token in your Jira profile under Personal Access Tokens.", + "1d947a07ab": "Use a self-hosted Jira base URL, username, and password to browse issues.", + "f49708c369": "Jira authentication method", + "84a810dd0e": "Username & password", + "8d1223fa5c": "Username", + "be9eba0a1b": "username", + "70035652d7": "Password", + "c50abbf340": "Jira account password", + "d8737db691": "Use your Jira Server or Data Center account username and password." } } }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index a9c01fdbe14f..c454ec9ce475 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -8670,7 +8670,9 @@ "fe9231215b": "Checking Linear access before showing setup actions.", "e1f5e6424c": "{{value0}} workspace{{value1}} connected", "disconnect_all": "切断", - "account_scope_prefix": "Account scope" + "account_scope_prefix": "Account scope", + "2d60ec7921": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are sent to the selected remote runtime and stored there with runtime-supported encryption.", + "977e360b71": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are stored locally and encrypted when local runtime storage supports it." } } } @@ -12692,7 +12694,24 @@ "70fcd360c4": "https://example.atlassian.net", "e176f9d0c5": "Jira Cloud site URL", "d785c42b8b": "Use a Jira Cloud site URL, Atlassian email, and API token to browse issues.", - "8388bdea2b": "Connect Jira site" + "8388bdea2b": "Connect Jira site", + "2e2b69e48e": "Use a self-hosted Jira base URL and a personal access token to browse issues.", + "b67e919bd5": "Jira instance type", + "17787d6e4b": "Atlassian Cloud", + "bc7a831773": "Self-hosted", + "3489e186d6": "Jira site URL", + "cbc27fa599": "https://jira.example.com", + "730d973bae": "Personal access token", + "8b9c7b9e7b": "Jira personal access token", + "ccfb086d3e": "Create a personal access token in your Jira profile under Personal Access Tokens.", + "1d947a07ab": "Use a self-hosted Jira base URL, username, and password to browse issues.", + "f49708c369": "Jira authentication method", + "84a810dd0e": "Username & password", + "8d1223fa5c": "Username", + "be9eba0a1b": "username", + "70035652d7": "Password", + "c50abbf340": "Jira account password", + "d8737db691": "Use your Jira Server or Data Center account username and password." } } }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index cb10041dfaae..b1f7cca34884 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -8670,7 +8670,9 @@ "fe9231215b": "설정 작업을 표시하기 전에 Linear 액세스를 확인하는 중입니다.", "e1f5e6424c": "{{value0}}개 워크스페이스{{value1}} 연결됨", "disconnect_all": "연결 해제", - "account_scope_prefix": "계정 범위" + "account_scope_prefix": "계정 범위", + "2d60ec7921": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are sent to the selected remote runtime and stored there with runtime-supported encryption.", + "977e360b71": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are stored locally and encrypted when local runtime storage supports it." } } } @@ -12692,7 +12694,24 @@ "70fcd360c4": "https://example.atlassian.net", "e176f9d0c5": "Jira Cloud 사이트 URL", "d785c42b8b": "Jira Cloud 사이트 URL, Atlassian 이메일, API 토큰을 사용해 이슈를 탐색합니다.", - "8388bdea2b": "Jira 사이트 연결" + "8388bdea2b": "Jira 사이트 연결", + "2e2b69e48e": "Use a self-hosted Jira base URL and a personal access token to browse issues.", + "b67e919bd5": "Jira instance type", + "17787d6e4b": "Atlassian Cloud", + "bc7a831773": "Self-hosted", + "3489e186d6": "Jira site URL", + "cbc27fa599": "https://jira.example.com", + "730d973bae": "Personal access token", + "8b9c7b9e7b": "Jira personal access token", + "ccfb086d3e": "Create a personal access token in your Jira profile under Personal Access Tokens.", + "1d947a07ab": "Use a self-hosted Jira base URL, username, and password to browse issues.", + "f49708c369": "Jira authentication method", + "84a810dd0e": "Username & password", + "8d1223fa5c": "Username", + "be9eba0a1b": "username", + "70035652d7": "Password", + "c50abbf340": "Jira account password", + "d8737db691": "Use your Jira Server or Data Center account username and password." } } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index f8a6e2204070..1e59503c12ce 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -8670,7 +8670,9 @@ "fe9231215b": "在显示设置操作前正在检查 Linear 访问权限。", "e1f5e6424c": "{{value0}} 个工作区{{value1}} 已连接", "disconnect_all": "断开连接", - "account_scope_prefix": "账户范围" + "account_scope_prefix": "账户范围", + "2d60ec7921": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are sent to the selected remote runtime and stored there with runtime-supported encryption.", + "977e360b71": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are stored locally and encrypted when local runtime storage supports it." } } } @@ -12692,7 +12694,24 @@ "70fcd360c4": "https://example.atlassian.net", "e176f9d0c5": "Jira Cloud 站点 URL", "d785c42b8b": "使用 Jira Cloud 站点 URL、Atlassian 邮箱和 API token 来浏览议题。", - "8388bdea2b": "连接 Jira 站点" + "8388bdea2b": "连接 Jira 站点", + "2e2b69e48e": "Use a self-hosted Jira base URL and a personal access token to browse issues.", + "b67e919bd5": "Jira instance type", + "17787d6e4b": "Atlassian Cloud", + "bc7a831773": "Self-hosted", + "3489e186d6": "Jira site URL", + "cbc27fa599": "https://jira.example.com", + "730d973bae": "Personal access token", + "8b9c7b9e7b": "Jira personal access token", + "ccfb086d3e": "Create a personal access token in your Jira profile under Personal Access Tokens.", + "1d947a07ab": "Use a self-hosted Jira base URL, username, and password to browse issues.", + "f49708c369": "Jira authentication method", + "84a810dd0e": "Username & password", + "8d1223fa5c": "Username", + "be9eba0a1b": "username", + "70035652d7": "Password", + "c50abbf340": "Jira account password", + "d8737db691": "Use your Jira Server or Data Center account username and password." } } }, diff --git a/src/renderer/src/runtime/runtime-jira-client.ts b/src/renderer/src/runtime/runtime-jira-client.ts index 812e28b82336..a6c058ac9f76 100644 --- a/src/renderer/src/runtime/runtime-jira-client.ts +++ b/src/renderer/src/runtime/runtime-jira-client.ts @@ -1,5 +1,6 @@ import type { GlobalSettings, + JiraAuthType, JiraComment, JiraConnectionStatus, JiraCreateField, @@ -57,7 +58,7 @@ export async function jiraStatus(settings: RuntimeJiraSettings): Promise { const target = getJiraRuntimeTarget(settings) return target.kind === 'environment' diff --git a/src/renderer/src/store/slices/jira.ts b/src/renderer/src/store/slices/jira.ts index fd084a3d9850..4f6957366f2f 100644 --- a/src/renderer/src/store/slices/jira.ts +++ b/src/renderer/src/store/slices/jira.ts @@ -4,6 +4,7 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' import type { + JiraAuthType, JiraConnectionStatus, JiraIssue, JiraIssueFilter, @@ -163,6 +164,7 @@ export type JiraSlice = { siteUrl: string email: string apiToken: string + authType?: JiraAuthType }) => Promise<{ ok: true; viewer: JiraViewer } | { ok: false; error: string }> testJiraConnection: ( siteId?: string | null diff --git a/src/shared/jira-types.ts b/src/shared/jira-types.ts index 7831bd18416a..ad8e84ecbad9 100644 --- a/src/shared/jira-types.ts +++ b/src/shared/jira-types.ts @@ -1,9 +1,15 @@ +// 'cloud' = Atlassian Cloud (email + API token, Basic auth, REST v3). +// 'server' = self-hosted Jira Server/Data Center (personal access token, +// Bearer auth, REST v2). Older stored sites omit the field and mean 'cloud'. +export type JiraAuthType = 'cloud' | 'server' + export type JiraSite = { id: string siteUrl: string email: string displayName: string accountId: string + authType?: JiraAuthType } export type JiraViewer = { @@ -130,8 +136,11 @@ export type JiraIssueFilter = 'assigned' | 'reported' | 'all' | 'done' export type JiraConnectArgs = { siteUrl: string + // Ignored for 'server' auth: self-hosted PATs authenticate via Bearer + // header alone, so the email field may be empty. email: string apiToken: string + authType?: JiraAuthType } export type JiraCreateIssueArgs = { diff --git a/src/shared/types.ts b/src/shared/types.ts index 3616a2e2900f..ab975939e253 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1863,6 +1863,7 @@ export type { } from './gitlab-types' export type { + JiraAuthType, JiraComment, JiraConnectArgs, JiraConnectionStatus, From e84bd666f1ea12e282e351909d65d041fb0a11fd Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 16 Jul 2026 00:30:23 -0700 Subject: [PATCH 070/351] Update wechat QR codes and add spacing between them in README - Refresh star-history chart and backup WeChat QR image assets - Add spacing between the primary and backup WeChat QR codes in both README.md and README.zh-CN.md --- README.md | 1 + docs/assets/star-history.png | Bin 129489 -> 97545 bytes docs/assets/wechat-qr-backup.jpg | Bin 211009 -> 403265 bytes docs/readme/README.zh-CN.md | 1 + 4 files changed, 2 insertions(+) diff --git a/README.md b/README.md index 81b9a5c5aea1..c3f65c069a18 100644 --- a/README.md +++ b/README.md @@ -241,6 +241,7 @@ Pair with your desktop app to monitor and steer your agents from your phone. - **WeChat:** Scan the QR code to join the community. If the first group is full, use the backup group. WeChat QR code for the Orca community +     Backup WeChat QR code for the Orca community - **Feedback & Ideas:** We ship fast. Missing something? [Request a new feature](https://github.com/stablyai/orca/issues). diff --git a/docs/assets/star-history.png b/docs/assets/star-history.png index 39d065095903549997c42eda88d45a47b282d4d4..e07ed23112f3da1968d0890547c26a108d770ab7 100644 GIT binary patch literal 97545 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 ABLDyZ literal 129489 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 c2px5E-wAw-o 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

aNLS;DzmZ%3!;_4s3z8WSCiayZ`i>c5vz95oe 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⁢}8m&Wy}$d2Znr*i^Vs?zAR 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


7aHp{YZ|&74JG79Tst(`{EqZQp_J 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�=3$i~o~_r0A{BNwP1(Y^ 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

&Od~q+6Sdb3t_Sk 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

CGkvk9%zrfyICB~ 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 literal 211009 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%0D~ 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

ebFMiFD!7(hiP=;!Pluw=$JhHEoq=WVhNHf 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 z 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

(EM#s`Y56NT~Z80;UMG?H35ny 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 +     Orca 社区备用微信群二维码 - **反馈与想法:** 我们发布很快。缺少什么功能?[提交功能请求](https://github.com/stablyai/orca/issues)。 From 8c1d13dc52a84b0b38213fe2e76e1a84780b206e Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 16 Jul 2026 00:32:59 -0700 Subject: [PATCH 071/351] cleaning up --- pty-exhaustion-agent-session-leak.md | 178 --------------------------- 1 file changed, 178 deletions(-) delete mode 100644 pty-exhaustion-agent-session-leak.md diff --git a/pty-exhaustion-agent-session-leak.md b/pty-exhaustion-agent-session-leak.md deleted file mode 100644 index 64cbb3d93ced..000000000000 --- a/pty-exhaustion-agent-session-leak.md +++ /dev/null @@ -1,178 +0,0 @@ -# Design Doc: PTY Exhaustion from Leaked Agent Sessions - -**Date:** 2026-07-13 -**Status:** Root cause identified; fixes proposed -**Severity:** P0 — machine-wide outage. Once the pty cap is hit, _nothing_ on the host can open a terminal (Orca, Ghostty, IDEs, `ssh`, agent spawns all fail with "cannot allocate pty"). -**Source refs:** file:line references below are against the main checkout at `~/orca/orca` as of 2026-07-13. - ---- - -## 1. Summary - -An Orca desktop instance exhausted the macOS pseudo-terminal limit (`kern.tty.ptmx_max = 511`). Investigation on the live machine found **526 allocated ptys**, of which **~476 belonged to three `Orca Helper` processes** — hundreds of idle `login → zsh → codex resume ` chains, some alive for almost 4 days, all at 0% CPU. Sessions the user had explicitly closed in the UI were still running, and closed sessions kept _coming back_ in bulk waves after restarts. - -The root cause is **two independent defects that compound into a self-perpetuating leak**: - -- **Bug A (teardown):** Closing a session tab never kills the underlying pty. Tab close is pure renderer-state cleanup; the actual `pty.kill` only happens as a side effect of a mounted `TerminalPane` unmounting. For "cold-parked" tabs in background worktrees — whose panes are unmounted by design — closing the tab silently orphans the live process chain. -- **Bug B (resume):** Closing a tab never removes the session's record from the persisted resume list (`sleepingAgentSessionsByPaneKey`), and the record staleness check is a no-op (it compares `capturedAt - updatedAt`, which is always ≈ 0, instead of `now - updatedAt`). Every workspace open / app restart therefore bulk-launches `codex resume` for the entire orphaned backlog — and a periodic capture timer re-persists all of them, so the backlog only grows. - -Every restart makes it worse: Bug B resurrects the dead-but-remembered sessions (each burning a fresh pty), Bug A guarantees closing them doesn't actually free anything, and the capture loop re-persists the ever-larger set. - ---- - -## 2. Observed incident (live-machine evidence) - -Measurements taken 2026-07-13 on the affected desktop: - -| Metric | Value | -| ------------------------------------------------- | ----------------------------------------------------------------------- | -| Allocated ptys (`/dev/ttys*`) | 526 (cap: `kern.tty.ptmx_max = 511`) | -| `login` + `zsh` session pairs | 506 | -| Sessions parented to installed `Orca.app` helpers | 476 (helper pids 53960: **377**, 31662: 58, 1510: 41) | -| Sessions from dev-build Orca instances | ~25 | -| `codex` agent processes | ~280 (~270 **unique** session IDs; a handful resumed 2–3× concurrently) | -| `claude` agent processes | ~38 | -| Processes using >1% CPU | 7 of 763 — everything else fully idle | - -Key observations: - -- **`Orca Helper` pid 53960 alone held 376 open ptmx master fds.** Every leaked chain was still fully parented: `Orca Helper → /usr/bin/login → zsh → node → codex`. Nothing was orphaned to launchd — the app-side master fd was simply never closed. -- **Working directories were overwhelmingly finished PR-review worktrees** (`review-p1-pr-7511-issue-7026` alone had 79 codex processes; `review-p0-pr-8370-issue-8212` had 76; `review-p0-pr-8279-issue-8260` had 67). -- **Start times cluster in same-second waves** — batches on Fri Jul 10 (~15:41–18:14) and large bursts on Sun Jul 12 (14:03, 14:27, 14:36–14:40, 15:09). These are bulk resumes on workspace open / app restart, not user-opened sessions. -- **The user had closed these tabs.** The tabs were gone from the UI; the processes survived (Bug A) and were then re-resumed in later waves (Bug B). -- Nearly all resumed with `codex --dangerously-bypass-approvals-and-sandbox resume ` — i.e., they idle at an interactive prompt and never exit on their own. - ---- - -## 3. Background: pty ownership architecture - -- Terminal sessions are spawned in an Electron utility/helper process which holds the **pty master** (hence the ptmx fds on `Orca Helper`). -- A **daemon** hosts sessions so they survive app restarts for warm reattach: it is forked `detached: true` + `unref()` and deliberately outlives the app (`daemon/daemon-init.ts:306-319`; comment at `:741` — "sessions stay alive for warm reattach"). -- Background (non-active) worktrees are **"cold-parked"**: their `TerminalPane` React components unmount, the transport calls `detach()` (`pty-transport.ts:929` — "keep the PTY exit observer alive"), and pane-less byte watchers observe the still-running pty. -- Agent sessions are additionally tracked in a persisted map, `sleepingAgentSessionsByPaneKey` (`src/renderer/src/store/slices/agent-status.ts:121`), so they can be resumed (`codex resume `) after sleep/quit. - -Keep-alive-on-detach is **intentional**. The defects are in what is supposed to end a session's life. - ---- - -## 4. Bug A: closing a tab never kills the pty - -### The close path contains no kill - -`closeTerminalTab()` (`terminal-tab-actions.ts:152`) → `closeLocalTerminalTabState` → `state.closeTab()` (`store/slices/terminals.ts:1192-1381`). `closeTab` scrubs ~20 per-tab maps — including `delete ptyIdsByTabId[tabId]` (`terminals.ts:1217-1218`) — but issues **no `pty.kill`, no shutdown, no IPC**. It just _forgets the pty id_. - -### The only kill is a React-unmount side effect - -The pty dies only when a _mounted_ `TerminalPane` unmounts with the tab gone: `shouldDetachPaneTransportOnUnmount()` (`use-terminal-pane-lifecycle.ts:456`) returns false → unmount cleanup (`use-terminal-pane-lifecycle.ts:1725-1826`, kill branch at `:1807`) → `transport.destroy()` (`pty-transport.ts:1031`) → `window.api.pty.kill(id)` → main `pty:kill` (`ipc/pty.ts:4953`) → `provider.shutdown(id, {immediate: true})`. - -This works for the **active** worktree. But for a **cold-parked** tab there is no mounted pane; closing it runs `syncParkedTerminalTabWatchers` → `disposeParkedTabWatchers` (`terminal-parked-watcher-registry.ts:53-63`), which stops the watchers and **never kills the pty**. The `login → zsh → codex` chain is orphaned with nothing pointing at it. **This is the zombie source.** - -### Nothing else ever reaps it - -Every existing reaper is scoped to an event _other than_ tab close: - -| Mechanism | Why it doesn't help | -| --------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Daemon `detach` handler | Logging no-op — "full implementation would track tokens" (`daemon/daemon-server.ts:508-512`). No idle timeout exists anywhere. | -| `reapSession` (`daemon/terminal-host.ts:215`) | Fires only on the child's natural exit or explicit kill; an idle interactive `codex` never exits. | -| App-quit `killAllPty()` (`ipc/pty.ts:5191`), reload `killOrphanedPtys` (`ipc/pty.ts:2851`) | Both guarded by `if (localProvider instanceof LocalPtyProvider)` — false for the daemon adapter, so daemon-hosted sessions are excluded _by design_ (warm reattach). | -| Worktree removal (`runtime/worktree-teardown.ts:39`, `killAllProcessesForWorktree`) | Works, but only on full worktree delete. | -| Sleep flow (`sidebar/sleep-worktree-flow.ts:148` → `shutdownWorktreeTerminals`, `terminals.ts:2331`, kill at `:2665`) | Works, but only when the user explicitly sleeps the worktree. | - -### The codebase already knows close ≠ kill - -`kill-all-terminal-surfaces.ts` performs daemon `killAll()`, then `closeTerminalTab({force: true})` per tab, **then an explicit `window.api.pty.kill(ptyId)` loop over `ptyIdsByTabId`** (`:140-147`, `:188-190`) — direct evidence that tab close alone is known not to kill. There is even a manual mop-up UI: **Manage Sessions → "Kill orphan terminals"** for sessions "that have no tab in this Orca instance" (`status-bar/ResourceUsageStatusSegment.tsx:~1114-1155`) — a hand-operated workaround for exactly this leak. - ---- - -## 5. Bug B: closed sessions are resurrected in bulk forever - -### The resume list never learns about tab close - -Agent sessions are recorded in `sleepingAgentSessionsByPaneKey` (`agent-status.ts:121`; record shape in `src/shared/agent-session-resume.ts:38-59`, with `state`, `capturedAt`, `updatedAt`, and `origin: 'worktree-sleep' | 'quit' | 'live'`). Records are written: - -- **Live:** on every non-done agent-status update, an `origin: 'live'` record is captured (`agent-status.ts:1492-1544`). -- **Quit + periodic:** `captureAllSleepingAgentSessions` writes an `origin: 'quit'` record for _every currently-live pane_ (`agent-status.ts:2312-2350`), called on quit (`App.tsx:1367`) **and on a periodic interval** (`App.tsx:1394`). - -`closeTab` (`terminals.ts:1192-1381`) calls `dropAgentStatusByTabPrefix` (`terminals.ts:1894-2010`), which removes the agent-_status_ entry but **never touches `sleepingAgentSessionsByPaneKey`**. There is no concept of "user closed this tab" in the resume store. The orphaned record persists to disk with the workspace session state (`workspace-session-sleeping-agents.ts` → `workspace-session.ts:393`). - -### The staleness gate is a no-op - -On workspace/worktree open, `resumeSleepingAgentSessionsForWorktree` (`resume-sleeping-agent-session.ts:143-218`) launches `codex resume ` (argv built in `agent-session-resume.ts:195-220`; full command in `tui-agent-startup.ts:189-238`; launched via `sleeping-agent-session-launch.ts:65-128`) for every record unless a filter rejects it: - -- `isPassiveCompletedHibernationEvidence` (`sleeping-agent-pane-ownership.ts:16-18`): only skips records that are `done` **and** not `live`/`quit` origin. Orphaned records are `live`/`quit`, so they pass. -- `isInvalidWorktreeActivationRecord` (`resume-sleeping-agent-session.ts:131-141`): rejects records where `state !== 'done' && (capturedAt - updatedAt) > 30 min` (`AGENT_STATUS_STALE_AFTER_MS`, `shared/agent-status-types.ts:256`). **But live records are captured with `capturedAt === updatedAt`** (`agent-status.ts:1501`) and quit records stamp `capturedAt = Date.now()` immediately after the last update — so the difference is always ≈ 0. The check should compare against **`now`**; as written, a record from three days ago is perpetually "fresh." -- `recordPaneIsOwnedByPreservedPane`: skips only if the pane still exists in the restored layout — but the user closed the tab, so it doesn't. - -Result: orphaned records are neither passive, nor stale, nor pane-owned → **all of them get `codex resume`'d**, dozens in the same second. Triggers: folder-workspace open (`worktree-activation.ts:229`), worktree open (`:363`), startup hydration of the active worktree (`Terminal.tsx:1062-1075`), and mobile wake (`wake-sleeping-agents-in-background.ts:165-212`, wired at `useIpcEvents.ts:836`). - -### The feedback loop - -1. Workspace opens → every orphaned record is resumed → N fresh ptys, N idle `codex` processes. -2. Resumed sessions idle at a prompt and never reach `done`, so done-cleanup (`agent-status.ts:1541-1544`) never runs. -3. The periodic/quit capture (`agent-status.ts:2312-2350`) re-persists a record for every one of them. -4. User closes the tabs → Bug A leaves the processes alive; Bug B leaves the records in place. -5. Next open/restart → goto 1, with a strictly larger backlog. - -Distinct historical session IDs produce distinct claim keys, so per-claim dedup doesn't collapse them — which is exactly why one worktree accumulated 79 live codex processes across ~270 unique session IDs machine-wide. - ---- - -## 6. Proposed fixes - -### Fix 1 (primary, Bug A): make tab close actually kill the pty - -In `closeTab` (`terminals.ts:1192`) / `closeTerminalTab` (`terminal-tab-actions.ts:152`), issue `window.api.pty.kill()` for the tab's pty ids **before** `delete ptyIdsByTabId[tabId]`, mirroring the explicit kill loop in `kill-all-terminal-surfaces.ts:140-147`. This closes the parked-pane gap directly: the kill no longer depends on a mounted `TerminalPane` unmounting. Cover the split-pane close paths (`use-terminal-pane-lifecycle.ts`, `TerminalPaneOverlayLayer.tsx`) as well. - -Design decision needed: if warm-reattach semantics should be preserved for _some_ tab closes, distinguish "close tab" (kill) from "sleep/detach" (keep alive) explicitly in the UI action — today the distinction exists in the code (Sleep uses `keepIdentifiers: true`, `sleep-worktree-flow.ts:148`) but plain tab close falls into an unintended third state: forgotten but alive. - -### Fix 2 (primary, Bug B): remove resume records on tab close - -Have `closeTab` (and the split-pane close paths) delete the closed panes' entries from `sleepingAgentSessionsByPaneKey`. A user closing a tab is expressing "I'm done with this session" — it must not remain resumable-by-default. - -### Fix 3 (secondary, Bug B): origin-aware wall-clock expiry - -Do not globally expire resume records by wall time: intentional `worktree-sleep` -checkpoints may remain valid for days. A defensive expiry can apply only to -unowned `live` or `quit` records after their origin and ownership are validated -at every wake entry point. Explicit tab close must delete its records -immediately and remains the primary fix. - -### Fix 4 (secondary, Bug A): daemon-side reaper - -Implement the daemon `detach` handler (`daemon-server.ts:508-512`) and an idle reaper for sessions that are detached with **no owning tab**. The daemon deliberately outlives the app and is excluded from every `instanceof LocalPtyProvider` sweep, so it needs its own lifecycle policy — otherwise any future client-side bookkeeping bug leaks forever again. - -### Defense in depth - -- Surface a pty-pressure warning (the data already exists for the "Kill orphan terminals" feature) when app-owned ptys exceed a threshold (e.g. 300), since exhaustion is a machine-wide outage. -- The `--dangerously-bypass-approvals-and-sandbox` resume flag amplifies blast radius: hundreds of unattended, sandbox-less agents idling for days. Consider whether bulk background resume should ever use it. - ---- - -## 7. Verification plan - -1. **Repro (pre-fix):** in a background worktree, open an agent session, switch to another worktree (parking the pane), close the tab → `ps` shows the `login/zsh/codex` chain still alive and the helper still holds its ptmx fd. Restart Orca → the session is resumed again. -2. **Post-fix:** same steps → chain dies on tab close (Fix 1); record removed so restart resumes nothing (Fix 2). Origin-aware defensive expiry is verified separately when implemented (Fix 3). -3. **Regression:** worktree Sleep → wake still resumes sessions; quit → relaunch still warm-reattaches sessions whose tabs were _not_ closed; daemon warm-reattach unaffected. -4. **Soak:** count `Orca Helper` ptmx fds (`lsof -p | grep -c ptmx`) across a day of open/close/restart cycles — should stay flat. - -## 8. Immediate mitigation (ops, no code) - -- Use **Manage Sessions → "Kill orphan terminals"** or exact-session termination - to release the leaked ptys. A normal Orca.app restart is insufficient because - the persistent terminal daemon deliberately survives for warm reattach. -- `sudo sysctl kern.tty.ptmx_max=999` raises the cap to its macOS hard max — buys headroom only, hours at current leak rate. -- Restart the terminal daemon only when exact-session cleanup is unavailable; - this terminates all daemon-backed sessions, including legitimate in-flight work. - ---- - -## Appendix: diagnostic commands used - -```sh -ls /dev/ttys* | wc -l && sysctl kern.tty.ptmx_max # allocation vs cap -ps -ax -o pid,tty,etime,comm | grep -E 'ttys[0-9]+' # who holds ptys -lsof -a -d cwd -c codex | awk '{print $NF}' | sort | uniq -c | sort -rn # per-worktree counts -lsof -p | grep -c ptmx # master fds per Orca Helper -ps -ax -o args | grep -oE '[0-9a-f-]{36}' | sort | uniq -c # duplicate session resumes -``` From 3874734dcad4131bfef4f7c21ad263a86f68992d Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 16 Jul 2026 00:41:34 -0700 Subject: [PATCH 072/351] Update README WeChat section: single group, new QR image - Replace outdated dual-QR (primary + backup) layout with one QR code, since the first group filled up and members should join the second group instead - Applies to both README.md and the zh-CN translation - Remove the now-unused backup/png QR image assets --- README.md | 6 ++---- docs/assets/wechat-qr-backup.jpg | Bin 403265 -> 0 bytes docs/assets/wechat-qr.png | Bin 443867 -> 0 bytes docs/readme/README.zh-CN.md | 6 ++---- 4 files changed, 4 insertions(+), 8 deletions(-) delete mode 100644 docs/assets/wechat-qr-backup.jpg delete mode 100644 docs/assets/wechat-qr.png diff --git a/README.md b/README.md index c3f65c069a18..d07692e1edd8 100644 --- a/README.md +++ b/README.md @@ -238,11 +238,9 @@ Pair with your desktop app to monitor and steer your agents from your phone. - **Discord:** Join the community on **[Discord](https://discord.gg/fzjDKHxv8Q)**. - **Twitter / X:** Follow **[@orca_build](https://x.com/orca_build)** for updates and announcements. -- **WeChat:** Scan the QR code to join the community. If the first group is full, use the backup group. +- **WeChat:** First WeChat group is full, now you can join the second one. - WeChat QR code for the Orca community -     - Backup WeChat QR code for the Orca community + WeChat QR code for the Orca community - **Feedback & Ideas:** We ship fast. Missing something? [Request a new feature](https://github.com/stablyai/orca/issues). - **Privacy:** See the [privacy & telemetry docs](https://www.onorca.dev/docs/telemetry) for what anonymous usage data Orca collects and how to opt out. diff --git a/docs/assets/wechat-qr-backup.jpg b/docs/assets/wechat-qr-backup.jpg deleted file mode 100644 index 0b4598e0f883781b50d8aee3533d7d4ac372f258..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 403265 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


7aHp{YZ|&74JG79Tst(`{EqZQp_J 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�=3$i~o~_r0A{BNwP1(Y^ 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

&Od~q+6Sdb3t_Sk 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

CGkvk9%zrfyICB~ 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 diff --git a/docs/assets/wechat-qr.png b/docs/assets/wechat-qr.png deleted file mode 100644 index bf270d857e800cc3f19c4375f2e89140e30efee7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 443867 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

mVSHw72pz-@?~w3_Cb0iSjjPlQZCiY*#AyV! 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 z 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%0Dbc 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 z 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

aNLS;DzmZ%3!;_4s3z8WSCiayZ`i>c5vz95oe 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⁢}8m&Wy}$d2Znr*i^Vs?zAR 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


7aHp{YZ|&74JG79Tst(`{EqZQp_J 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�=3$i~o~_r0A{BNwP1(Y^ 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

&Od~q+6Sdb3t_Sk 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

CGkvk9%zrfyICB~ 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 literal 0 HcmV?d00001 From 377082e142b4120208df75ee008761687a1789c4 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 16 Jul 2026 02:57:43 -0700 Subject: [PATCH 074/351] fix(ai-vault): discover Antigravity CLI sessions (#8971) * fix(ai-vault): discover Antigravity CLI sessions Co-authored-by: Orca * fix(ai-vault): address remote scanner review Co-authored-by: Orca --------- Co-authored-by: Orca --- .../remote-session-scanner-discovery.ts | 168 ++++++++++++ .../remote-session-scanner-sources.ts | 31 ++- .../ai-vault/remote-session-scanner-types.ts | 6 + .../ai-vault/remote-session-scanner.test.ts | 241 ++++++++++++++++++ src/main/ai-vault/remote-session-scanner.ts | 113 +------- .../ai-vault/session-scanner-agent-parser.ts | 3 + .../session-scanner-antigravity-fixtures.ts | 35 +++ .../session-scanner-antigravity-history.ts | 77 ++++++ ...session-scanner-antigravity-parser.test.ts | 114 +++++++++ .../session-scanner-antigravity-parser.ts | 120 +++++++++ .../session-scanner-antigravity-paths.ts | 42 +++ ...session-scanner-antigravity-source.test.ts | 167 ++++++++++++ .../session-scanner-antigravity-sources.ts | 34 +++ .../session-scanner-codex-workers.test.ts | 1 + .../ai-vault/session-scanner-discovery.ts | 15 +- .../session-scanner-incremental-fixtures.ts | 4 +- ...canner-opencode-sqlite-coexistence.test.ts | 1 + ...session-scanner-parse-cache-agents.test.ts | 5 +- .../ai-vault/session-scanner-parse-cache.ts | 3 + .../ai-vault/session-scanner-scope.test.ts | 1 + .../session-scanner-source-discovery.ts | 2 + .../ai-vault/session-scanner-test-fixtures.ts | 35 +++ src/main/ai-vault/session-scanner-types.ts | 2 + src/main/ai-vault/session-scanner.test.ts | 16 +- src/main/ai-vault/session-scanner.ts | 41 ++- src/shared/ai-vault-resume-command.test.ts | 11 + src/shared/ai-vault-types.ts | 4 + 27 files changed, 1171 insertions(+), 121 deletions(-) create mode 100644 src/main/ai-vault/remote-session-scanner-discovery.ts create mode 100644 src/main/ai-vault/session-scanner-antigravity-fixtures.ts create mode 100644 src/main/ai-vault/session-scanner-antigravity-history.ts create mode 100644 src/main/ai-vault/session-scanner-antigravity-parser.test.ts create mode 100644 src/main/ai-vault/session-scanner-antigravity-parser.ts create mode 100644 src/main/ai-vault/session-scanner-antigravity-paths.ts create mode 100644 src/main/ai-vault/session-scanner-antigravity-source.test.ts create mode 100644 src/main/ai-vault/session-scanner-antigravity-sources.ts diff --git a/src/main/ai-vault/remote-session-scanner-discovery.ts b/src/main/ai-vault/remote-session-scanner-discovery.ts new file mode 100644 index 000000000000..1791baa82cd5 --- /dev/null +++ b/src/main/ai-vault/remote-session-scanner-discovery.ts @@ -0,0 +1,168 @@ +import { extname } from 'node:path' +import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types' +import type { ExecutionHostId } from '../../shared/execution-host' +import type { FileStat, IFilesystemProvider } from '../providers/types' +import { joinRemotePath } from '../ssh/ssh-remote-platform' +import { partitionSubagentTranscriptPaths } from './session-scanner-subagent-transcripts' +import type { FileWithMtime } from './session-scanner-types' +import { errorMessage } from './session-scanner-values' +import type { + RemoteScannerContext, + RemoteSessionCandidate, + RemoteSessionSource +} from './remote-session-scanner-types' + +const REMOTE_DISCOVERY_CONCURRENCY = 8 + +export async function discoverRemoteSourceCandidates(args: { + source: RemoteSessionSource + context: RemoteScannerContext + issues: AiVaultScanIssue[] +}): Promise { + const walked = args.source.fixedChildFileSegments + ? await listRemoteFixedChildFiles(args.source, args.context, args.issues) + : await walkRemoteSessionFiles(args.source, args.context, args.issues) + const partition = args.source.collectSubagentSiblingCounts + ? partitionSubagentTranscriptPaths(walked) + : null + const paths = partition ? partition.sessionFilePaths : walked + const files = await mapDiscoveryConcurrently(paths, (path) => + statRemoteFile( + args.context.provider, + path, + args.source.agent, + args.context.executionHostId, + args.issues, + Boolean(args.source.fixedChildFileSegments) + ) + ) + return files + .filter((file): file is FileWithMtime => Boolean(file)) + .map((file) => ({ + source: args.source, + file, + subagentTranscriptCount: partition?.subagentTranscriptCounts.get(file.path) ?? 0 + })) +} + +async function listRemoteFixedChildFiles( + source: RemoteSessionSource, + context: RemoteScannerContext, + issues: AiVaultScanIssue[] +): Promise { + let entries + try { + entries = await context.provider.readDir(source.rootDir) + } catch (err) { + recordRemoteDirectoryIssue(source, context.executionHostId, issues, source.rootDir, err) + return [] + } + const segments = source.fixedChildFileSegments ?? [] + // Why: Antigravity's transcript path is fixed. Constructing it avoids three + // serialized SSH readDir round trips for every conversation directory. + return entries + .filter((entry) => entry.isDirectory && !entry.isSymlink) + .map((entry) => joinRemotePath(context.hostPlatform, source.rootDir, entry.name, ...segments)) + .filter((path) => source.filePredicate?.(path) ?? true) +} + +async function walkRemoteSessionFiles( + source: RemoteSessionSource, + context: RemoteScannerContext, + issues: AiVaultScanIssue[], + dirPath = source.rootDir, + depth = 0 +): Promise { + let entries + try { + entries = await context.provider.readDir(dirPath) + } catch (err) { + recordRemoteDirectoryIssue(source, context.executionHostId, issues, dirPath, err) + return [] + } + + const extensions = new Set(source.extensions) + const files: string[] = [] + for (const entry of entries) { + const fullPath = joinRemotePath(context.hostPlatform, dirPath, entry.name) + if ( + entry.isDirectory && + !entry.isSymlink && + (source.directoryPredicate?.(entry.name, depth) ?? true) + ) { + files.push(...(await walkRemoteSessionFiles(source, context, issues, fullPath, depth + 1))) + continue + } + if ( + !entry.isSymlink && + extensions.has(extname(entry.name).toLowerCase()) && + (source.filePredicate?.(fullPath) ?? true) + ) { + files.push(fullPath) + } + } + return files +} + +async function statRemoteFile( + provider: IFilesystemProvider, + path: string, + agent: AiVaultAgent, + executionHostId: ExecutionHostId, + issues: AiVaultScanIssue[], + missingIsExpected: boolean +): Promise { + try { + const stat = await provider.stat(path) + const mtimeMs = remoteStatMtimeMs(stat) + return { path, mtimeMs, modifiedAt: new Date(mtimeMs).toISOString() } + } catch (err) { + if (!missingIsExpected || !isMissingRemotePathError(err)) { + issues.push({ executionHostId, agent, path, message: errorMessage(err) }) + } + return null + } +} + +function recordRemoteDirectoryIssue( + source: RemoteSessionSource, + executionHostId: ExecutionHostId, + issues: AiVaultScanIssue[], + path: string, + err: unknown +): void { + if (!isMissingRemotePathError(err)) { + issues.push({ executionHostId, agent: source.agent, path, message: errorMessage(err) }) + } +} + +function isMissingRemotePathError(err: unknown): boolean { + const code = + err && typeof err === 'object' && 'code' in err && typeof err.code === 'string' + ? err.code + : null + if (code === 'ENOENT' || code === 'ENOTDIR') { + return true + } + // Relay/provider boundaries can preserve only the underlying Node error text. + return /(?:^|[\s:])(ENOENT|ENOTDIR)(?=[\s:]|$)/.test(errorMessage(err)) +} + +function remoteStatMtimeMs(stat: FileStat): number { + if (typeof stat.mtimeMs === 'number' && Number.isFinite(stat.mtimeMs)) { + return stat.mtimeMs + } + return stat.mtime > 10_000_000_000 ? stat.mtime : stat.mtime * 1000 +} + +async function mapDiscoveryConcurrently( + items: readonly T[], + mapper: (item: T) => Promise +): Promise { + const results: U[] = [] + for (let index = 0; index < items.length; index += REMOTE_DISCOVERY_CONCURRENCY) { + const batch = items.slice(index, index + REMOTE_DISCOVERY_CONCURRENCY) + results.push(...(await Promise.all(batch.map(mapper)))) + } + return results +} diff --git a/src/main/ai-vault/remote-session-scanner-sources.ts b/src/main/ai-vault/remote-session-scanner-sources.ts index c05261de31dc..c33a0ae7701a 100644 --- a/src/main/ai-vault/remote-session-scanner-sources.ts +++ b/src/main/ai-vault/remote-session-scanner-sources.ts @@ -1,6 +1,8 @@ import type { AiVaultAgent, AiVaultSession } from '../../shared/ai-vault-types' import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' import { joinRemotePath } from '../ssh/ssh-remote-platform' +import { parseAntigravitySessionContent } from './session-scanner-antigravity-parser' +import { isAntigravityTranscriptPath } from './session-scanner-antigravity-paths' import { parseCodexSessionContent } from './session-scanner-codex-parser' import { parseDevinSessionContent } from './session-scanner-devin-parser' import { parseDroidSessionContent } from './session-scanner-droid-parser' @@ -49,6 +51,7 @@ export function remoteSessionSources( // top-level sessions carrying the parent's sessionId. collectSubagentSiblingCounts: true }, + remoteAntigravitySource(remoteHome, hostPlatform), source( 'gemini', remoteHome, @@ -108,6 +111,30 @@ export function remoteSessionSources( ] } +function remoteAntigravitySource( + remoteHome: string, + hostPlatform: RemoteHostPlatform +): RemoteSessionSource { + const cliRoot = joinRemotePath(hostPlatform, remoteHome, '.gemini', 'antigravity-cli') + const historyPath = joinRemotePath(hostPlatform, cliRoot, 'history.jsonl') + return { + agent: 'antigravity', + rootDir: joinRemotePath(hostPlatform, cliRoot, 'brain'), + extensions: ['.jsonl'], + filePredicate: isAntigravityTranscriptPath, + fixedChildFileSegments: ['.system_generated', 'logs', 'transcript.jsonl'], + parse: async (file, content, context) => { + const session = await parseAntigravitySessionContent( + file, + content, + context.hostPlatform.os, + parserOptions(context) + ) + return session ? context.antigravityWorkspaceResolver.enrich(session, historyPath) : null + } + } +} + function source( agent: AiVaultAgent, remoteHome: string, @@ -115,13 +142,15 @@ function source( segments: readonly string[], extensions: readonly string[], parseContent: RemoteContentParser, - filePredicate?: (path: string) => boolean + filePredicate?: (path: string) => boolean, + directoryPredicate?: (name: string, depth: number) => boolean ): RemoteSessionSource { return { agent, rootDir: joinRemotePath(hostPlatform, remoteHome, ...segments), extensions, filePredicate, + directoryPredicate, parse: (file, content, context) => Promise.resolve(parseContent(file, content, context.hostPlatform.os, parserOptions(context))) } diff --git a/src/main/ai-vault/remote-session-scanner-types.ts b/src/main/ai-vault/remote-session-scanner-types.ts index c65ef99aded8..4da4bd3f4a6f 100644 --- a/src/main/ai-vault/remote-session-scanner-types.ts +++ b/src/main/ai-vault/remote-session-scanner-types.ts @@ -3,12 +3,14 @@ import type { ExecutionHostId } from '../../shared/execution-host' import type { IFilesystemProvider } from '../providers/types' import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' import type { FileWithMtime } from './session-scanner-types' +import type { AntigravityWorkspaceResolver } from './session-scanner-antigravity-history' export type RemoteScannerContext = { provider: IFilesystemProvider executionHostId: ExecutionHostId hostPlatform: RemoteHostPlatform titleCaches: Map>> + antigravityWorkspaceResolver: AntigravityWorkspaceResolver } export type RemoteParserOptions = { @@ -21,6 +23,10 @@ export type RemoteSessionSource = { rootDir: string extensions: readonly string[] filePredicate?: (path: string) => boolean + // Depth 0 denotes a direct child of rootDir. + directoryPredicate?: (name: string, depth: number) => boolean + // A canonical file directly beneath every top-level session directory. + fixedChildFileSegments?: readonly string[] // Claude layout: count `/subagents/*.jsonl` siblings from the walked // listing and drop them from candidates instead of indexing them as sessions. collectSubagentSiblingCounts?: boolean diff --git a/src/main/ai-vault/remote-session-scanner.test.ts b/src/main/ai-vault/remote-session-scanner.test.ts index a2280b291650..4f9cbd73bb06 100644 --- a/src/main/ai-vault/remote-session-scanner.test.ts +++ b/src/main/ai-vault/remote-session-scanner.test.ts @@ -6,13 +6,29 @@ import { scanRemoteAiVaultSessions } from './remote-session-scanner' class MemoryRemoteProvider implements IFilesystemProvider { private readonly files = new Map() + private readonly readDirErrors = new Map() + private readonly statErrors = new Map() + readonly readDirPaths: string[] = [] addFile(path: string, content: string, mtimeMs: number): void { this.files.set(normalize(path), { content, mtimeMs }) } + failStat(path: string, error: Error): void { + this.statErrors.set(normalize(path), error) + } + + failReadDir(path: string, error: Error): void { + this.readDirErrors.set(normalize(path), error) + } + async readDir(dirPath: string): Promise { const dir = normalize(dirPath) + this.readDirPaths.push(dir) + const readDirError = this.readDirErrors.get(dir) + if (readDirError) { + throw readDirError + } const prefix = dir.endsWith('/') ? dir : `${dir}/` const entries = new Map() for (const path of this.files.keys()) { @@ -45,6 +61,10 @@ class MemoryRemoteProvider implements IFilesystemProvider { } async stat(filePath: string): Promise { + const statError = this.statErrors.get(normalize(filePath)) + if (statError) { + throw statError + } const file = this.files.get(normalize(filePath)) if (!file) { throw new Error(`ENOENT: ${filePath}`) @@ -201,6 +221,184 @@ describe('scanRemoteAiVaultSessions', () => { }) }) + it('parses only canonical Antigravity transcripts on SSH hosts', async () => { + const provider = new MemoryRemoteProvider() + const sessionId = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' + const logsDir = `/home/ada/.gemini/antigravity-cli/brain/${sessionId}/.system_generated/logs` + provider.addFile( + `${logsDir}/transcript.jsonl`, + jsonLines([ + { + source: 'USER_EXPLICIT', + type: 'USER_INPUT', + created_at: '2026-07-15T11:39:10Z', + content: 'Fix remote Antigravity history' + }, + { + source: 'MODEL', + type: 'PLANNER_RESPONSE', + created_at: '2026-07-15T11:39:12Z', + content: 'Done' + } + ]), + 50 + ) + provider.addFile(`${logsDir}/transcript_full.jsonl`, 'duplicate', 51) + provider.addFile( + `/home/ada/.gemini/antigravity-cli/brain/${sessionId}/artifacts/task.jsonl`, + 'not a transcript', + 52 + ) + provider.addFile( + '/home/ada/.gemini/antigravity-cli/history.jsonl', + jsonLines([ + { + display: 'Fix remote Antigravity history', + timestamp: Date.parse('2026-07-15T11:39:10.100Z'), + workspace: '/home/ada/project' + } + ]), + 53 + ) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64'), + scopePaths: ['/home/ada/project'] + }) + + expect(result.issues).toEqual([]) + expect(result.sessions).toHaveLength(1) + expect(result.sessions[0]).toMatchObject({ + executionHostId: 'ssh:dev-box', + executionHostPlatform: 'linux', + agent: 'antigravity', + sessionId, + title: 'Fix remote Antigravity history', + cwd: '/home/ada/project', + messageCount: 2, + resumeCommand: `agy --conversation '${sessionId}'`, + filePath: `${logsDir}/transcript.jsonl` + }) + expect( + provider.readDirPaths.filter((path) => + path.startsWith('/home/ada/.gemini/antigravity-cli/brain') + ) + ).toEqual(['/home/ada/.gemini/antigravity-cli/brain']) + }) + + it('keeps Antigravity SSH discovery to one listing as the session store grows', async () => { + const provider = new MemoryRemoteProvider() + const brainDir = '/home/ada/.gemini/antigravity-cli/brain' + for (let index = 0; index < 40; index++) { + provider.addFile( + `${brainDir}/session-${index}/.system_generated/logs/transcript.jsonl`, + jsonLines([ + { + source: 'USER_EXPLICIT', + type: 'USER_INPUT', + created_at: `2026-07-15T11:39:${String(index).padStart(2, '0')}Z`, + content: `Remote session ${index}` + } + ]), + 100 + index + ) + } + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64') + }) + + expect(result.issues).toEqual([]) + expect(result.sessions).toHaveLength(40) + expect(provider.readDirPaths.filter((path) => path.startsWith(brainDir))).toEqual([brainDir]) + }) + + it('ignores missing canonical Antigravity transcripts but reports other stat failures', async () => { + const provider = new MemoryRemoteProvider() + const brainDir = '/home/ada/.gemini/antigravity-cli/brain' + provider.addFile(`${brainDir}/missing-session/artifacts/task.jsonl`, 'artifact', 1) + const deniedTranscript = `${brainDir}/denied-session/.system_generated/logs/transcript.jsonl` + provider.addFile(deniedTranscript, 'unreadable', 2) + provider.failStat( + deniedTranscript, + new Error(`EACCES: permission denied, stat '${deniedTranscript}'`) + ) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64') + }) + + expect(result.sessions).toEqual([]) + expect(result.issues).toEqual([ + expect.objectContaining({ + agent: 'antigravity', + path: deniedTranscript, + message: expect.stringContaining('EACCES') + }) + ]) + }) + + it('reports non-missing fixed and recursive remote directory failures', async () => { + const provider = new MemoryRemoteProvider() + const brainDir = '/home/ada/.gemini/antigravity-cli/brain' + const claudeProjectDir = '/home/ada/.claude/projects/repo' + provider.addFile(`${claudeProjectDir}/session.jsonl`, 'unreadable', 1) + provider.failReadDir(brainDir, new Error(`EACCES: permission denied, scandir '${brainDir}'`)) + provider.failReadDir( + claudeProjectDir, + new Error(`ECONNRESET: connection lost while reading '${claudeProjectDir}'`) + ) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64') + }) + + expect(result.sessions).toEqual([]) + expect(result.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + agent: 'antigravity', + path: brainDir, + message: expect.stringContaining('EACCES') + }), + expect.objectContaining({ + agent: 'claude', + path: claudeProjectDir, + message: expect.stringContaining('ECONNRESET') + }) + ]) + ) + expect(result.issues).toHaveLength(2) + }) + + it('keeps missing optional remote directories silent', async () => { + const provider = new MemoryRemoteProvider() + const brainDir = '/home/ada/.gemini/antigravity-cli/brain' + provider.failReadDir(brainDir, new Error(`ENOENT: no such directory, scandir '${brainDir}'`)) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64') + }) + + expect(result.sessions).toEqual([]) + expect(result.issues).toEqual([]) + }) + it('excludes Claude subagent transcripts from remote scans', async () => { const provider = new MemoryRemoteProvider() provider.addFile( @@ -357,6 +555,49 @@ describe('scanRemoteAiVaultSessions', () => { ) }) + it('loads Antigravity workspace history with Windows remote paths', async () => { + const provider = new MemoryRemoteProvider() + const sessionId = 'dddddddd-eeee-4fff-8aaa-bbbbbbbbbbbb' + provider.addFile( + `C:/Users/Ada/.gemini/antigravity-cli/brain/${sessionId}/.system_generated/logs/transcript.jsonl`, + jsonLines([ + { + source: 'USER_EXPLICIT', + type: 'USER_INPUT', + created_at: '2026-07-15T11:39:10.000Z', + content: 'Windows Antigravity title' + } + ]), + 30 + ) + provider.addFile( + 'C:/Users/Ada/.gemini/antigravity-cli/history.jsonl', + jsonLines([ + { + display: 'Windows Antigravity title', + timestamp: Date.parse('2026-07-15T11:39:10.100Z'), + workspace: 'C:/repo/app' + } + ]), + 31 + ) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:win-box', + remoteHome: 'C:/Users/Ada', + hostPlatform: getRemoteHostPlatform('win32-x64') + }) + + expect(result.issues).toEqual([]) + expect(result.sessions[0]).toMatchObject({ + agent: 'antigravity', + sessionId, + cwd: 'C:/repo/app', + executionHostPlatform: 'win32' + }) + }) + it('continues past skipped candidates to fill the remote scan limit', async () => { const provider = new MemoryRemoteProvider() provider.addFile( diff --git a/src/main/ai-vault/remote-session-scanner.ts b/src/main/ai-vault/remote-session-scanner.ts index cc5ae37dad3f..c8e07929b615 100644 --- a/src/main/ai-vault/remote-session-scanner.ts +++ b/src/main/ai-vault/remote-session-scanner.ts @@ -1,25 +1,18 @@ -import { extname } from 'node:path' import type { - AiVaultAgent, AiVaultListResult, AiVaultScanIssue, AiVaultSession } from '../../shared/ai-vault-types' import { isPathInsideOrEqual } from '../../shared/cross-platform-path' import type { ExecutionHostId } from '../../shared/execution-host' -import type { FileStat, IFilesystemProvider } from '../providers/types' +import type { IFilesystemProvider } from '../providers/types' import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' -import { joinRemotePath } from '../ssh/ssh-remote-platform' import { sessionSortTime } from './session-scanner-accumulator' -import { partitionSubagentTranscriptPaths } from './session-scanner-subagent-transcripts' -import type { FileWithMtime } from './session-scanner-types' +import { createAntigravityWorkspaceResolver } from './session-scanner-antigravity-history' import { errorMessage } from './session-scanner-values' +import { discoverRemoteSourceCandidates } from './remote-session-scanner-discovery' import { remoteSessionSources } from './remote-session-scanner-sources' -import type { - RemoteScannerContext, - RemoteSessionCandidate, - RemoteSessionSource -} from './remote-session-scanner-types' +import type { RemoteScannerContext, RemoteSessionCandidate } from './remote-session-scanner-types' const DEFAULT_REMOTE_SCAN_LIMIT = 1000 const REMOTE_SCAN_CONCURRENCY = 8 @@ -39,7 +32,15 @@ export async function scanRemoteAiVaultSessions(args: { provider: args.provider, executionHostId: args.executionHostId, hostPlatform: args.hostPlatform, - titleCaches: new Map() + titleCaches: new Map(), + antigravityWorkspaceResolver: createAntigravityWorkspaceResolver(async (historyPath) => { + try { + const read = await args.provider.readFile(historyPath) + return read.isBinary ? null : read.content + } catch { + return null + } + }) } const candidates = ( await mapRemoteScanConcurrently( @@ -73,70 +74,6 @@ export async function scanRemoteAiVaultSessions(args: { } } -async function discoverRemoteSourceCandidates(args: { - source: RemoteSessionSource - context: RemoteScannerContext - issues: AiVaultScanIssue[] -}): Promise { - const walked = await walkRemoteSessionFiles( - args.source, - args.context.provider, - args.context.hostPlatform - ) - const partition = args.source.collectSubagentSiblingCounts - ? partitionSubagentTranscriptPaths(walked) - : null - const paths = partition ? partition.sessionFilePaths : walked - const files = await mapRemoteScanConcurrently(paths, (path) => - statRemoteFile( - args.context.provider, - path, - args.source.agent, - args.context.executionHostId, - args.issues - ) - ) - return files - .filter((file): file is FileWithMtime => Boolean(file)) - .map((file) => ({ - source: args.source, - file, - subagentTranscriptCount: partition?.subagentTranscriptCounts.get(file.path) ?? 0 - })) -} - -async function walkRemoteSessionFiles( - source: RemoteSessionSource, - provider: IFilesystemProvider, - hostPlatform: RemoteHostPlatform, - dirPath = source.rootDir -): Promise { - let entries - try { - entries = await provider.readDir(dirPath) - } catch { - return [] - } - - const extensions = new Set(source.extensions) - const files: string[] = [] - for (const entry of entries) { - const fullPath = joinRemotePath(hostPlatform, dirPath, entry.name) - if (entry.isDirectory && !entry.isSymlink) { - files.push(...(await walkRemoteSessionFiles(source, provider, hostPlatform, fullPath))) - continue - } - if ( - !entry.isSymlink && - extensions.has(extname(entry.name).toLowerCase()) && - (source.filePredicate?.(fullPath) ?? true) - ) { - files.push(fullPath) - } - } - return files -} - async function parseRemoteSessionCandidates(args: { candidates: readonly RemoteSessionCandidate[] context: RemoteScannerContext @@ -254,30 +191,6 @@ function normalizeRemoteScopePaths(scopePaths: readonly string[]): string[] { return scopePaths.map((scopePath) => scopePath.trim()).filter(Boolean) } -async function statRemoteFile( - provider: IFilesystemProvider, - path: string, - agent: AiVaultAgent, - executionHostId: ExecutionHostId, - issues: AiVaultScanIssue[] -): Promise { - try { - const stat = await provider.stat(path) - const mtimeMs = remoteStatMtimeMs(stat) - return { path, mtimeMs, modifiedAt: new Date(mtimeMs).toISOString() } - } catch (err) { - issues.push({ executionHostId, agent, path, message: errorMessage(err) }) - return null - } -} - -function remoteStatMtimeMs(stat: FileStat): number { - if (typeof stat.mtimeMs === 'number' && Number.isFinite(stat.mtimeMs)) { - return stat.mtimeMs - } - return stat.mtime > 10_000_000_000 ? stat.mtime : stat.mtime * 1000 -} - function canStopParsingRemoteSessions( sessions: AiVaultSession[], limit: number, diff --git a/src/main/ai-vault/session-scanner-agent-parser.ts b/src/main/ai-vault/session-scanner-agent-parser.ts index 22c6cd46abc0..029227d87452 100644 --- a/src/main/ai-vault/session-scanner-agent-parser.ts +++ b/src/main/ai-vault/session-scanner-agent-parser.ts @@ -1,5 +1,6 @@ import type { AiVaultSession } from '../../shared/ai-vault-types' import { parseDevinSessionFile } from './session-scanner-devin-parser' +import { parseAntigravitySessionFile } from './session-scanner-antigravity-parser' import { parseDroidSessionFile } from './session-scanner-droid-parser' import { parseGrokSessionFile } from './session-scanner-grok-parser' import { parseMessageGraphSessionFile, parseRovoSessionFile } from './session-scanner-graph-parsers' @@ -37,6 +38,8 @@ export async function parseAgentSessionFile( return parseCodexSessionFile(candidate.file, platform, candidate.codexHome) case 'gemini': return parseGeminiSessionFile(candidate.file, platform) + case 'antigravity': + return parseAntigravitySessionFile(candidate.file, platform) case 'copilot': return parseCopilotSessionFile(candidate.file, platform) case 'cursor': diff --git a/src/main/ai-vault/session-scanner-antigravity-fixtures.ts b/src/main/ai-vault/session-scanner-antigravity-fixtures.ts new file mode 100644 index 000000000000..cbcab494933a --- /dev/null +++ b/src/main/ai-vault/session-scanner-antigravity-fixtures.ts @@ -0,0 +1,35 @@ +import type { IncrementalAgentFixture } from './session-scanner-incremental-fixtures' + +export function antigravityFixture(): IncrementalAgentFixture { + const record = (source: string, type: string, content: string, createdAt: string) => + JSON.stringify({ source, type, content, created_at: createdAt }) + return { + agent: 'antigravity', + fileName: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee/.system_generated/logs/transcript.jsonl', + seedLines: [ + record( + 'USER_EXPLICIT', + 'USER_INPUT', + 'antigravity seed question', + '2026-05-01T10:00:00.000Z' + ), + record('MODEL', 'PLANNER_RESPONSE', 'antigravity seed answer', '2026-05-01T10:00:30.000Z') + ], + appendLines: [ + record( + 'USER', + 'REQUEST', + 'antigravity follow-up', + '2026-05-01T10:01:00.000Z' + ) + ], + truncatedLines: [ + record( + 'USER_EXPLICIT', + 'USER_INPUT', + 'antigravity rewritten', + '2026-05-01T10:00:00.000Z' + ) + ] + } +} diff --git a/src/main/ai-vault/session-scanner-antigravity-history.ts b/src/main/ai-vault/session-scanner-antigravity-history.ts new file mode 100644 index 000000000000..989ac7b4179c --- /dev/null +++ b/src/main/ai-vault/session-scanner-antigravity-history.ts @@ -0,0 +1,77 @@ +import type { AiVaultSession } from '../../shared/ai-vault-types' +import { normalizeTitleText, parseJsonObject, timestampMs } from './session-scanner-values' + +const HISTORY_MATCH_WINDOW_MS = 2_000 + +type AntigravityHistoryEntry = { + timestampMs: number + workspace: string +} + +type AntigravityHistoryIndex = Map + +export type AntigravityWorkspaceResolver = { + enrich(session: AiVaultSession, historyPath: string): Promise +} + +export function createAntigravityWorkspaceResolver( + readHistory: (historyPath: string) => Promise +): AntigravityWorkspaceResolver { + const indexes = new Map>() + + return { + async enrich(session, historyPath) { + if (session.agent !== 'antigravity' || session.cwd) { + return session + } + let index = indexes.get(historyPath) + if (!index) { + index = readHistory(historyPath).then(indexAntigravityHistory) + indexes.set(historyPath, index) + } + const workspace = findAntigravityWorkspace(session, await index) + return workspace ? { ...session, cwd: workspace } : session + } + } +} + +function indexAntigravityHistory(content: string | null): AntigravityHistoryIndex { + const index: AntigravityHistoryIndex = new Map() + for (const line of content?.split(/\r?\n/) ?? []) { + const record = parseJsonObject(line) + const display = typeof record?.display === 'string' ? normalizeTitleText(record.display) : null + const workspace = typeof record?.workspace === 'string' ? record.workspace.trim() : '' + const entryTimestampMs = timestampMs(record?.timestamp) + if (!display || !workspace || !Number.isFinite(entryTimestampMs)) { + continue + } + const entries = index.get(display) ?? [] + entries.push({ timestampMs: entryTimestampMs, workspace }) + index.set(display, entries) + } + return index +} + +function findAntigravityWorkspace( + session: AiVaultSession, + index: AntigravityHistoryIndex +): string | null { + // Why: truncated titles are not prompt identities; long worker prompts often + // share the same 96-character prefix across unrelated workspaces. + if (session.title.endsWith('...')) { + return null + } + const firstTitledUserTimestamp = session.previewMessages.find( + (message) => message.role === 'user' && normalizeTitleText(message.text) === session.title + )?.timestamp + const promptTimestampMs = timestampMs(firstTitledUserTimestamp ?? session.createdAt) + if (!Number.isFinite(promptTimestampMs)) { + return null + } + const matches = (index.get(session.title) ?? []).filter( + (entry) => Math.abs(entry.timestampMs - promptTimestampMs) <= HISTORY_MATCH_WINDOW_MS + ) + // Why: history rows have no conversation id. A unique prompt/time match is + // evidence for cwd; ambiguity must stay unknown instead of crossing projects. + return matches.length === 1 ? (matches[0]?.workspace ?? null) : null +} diff --git a/src/main/ai-vault/session-scanner-antigravity-parser.test.ts b/src/main/ai-vault/session-scanner-antigravity-parser.test.ts new file mode 100644 index 000000000000..c8d037098ca9 --- /dev/null +++ b/src/main/ai-vault/session-scanner-antigravity-parser.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest' +import { parseAntigravitySessionContent } from './session-scanner-antigravity-parser' +import type { FileWithMtime } from './session-scanner-types' +import { jsonLines } from './session-scanner-test-fixtures' + +const SESSION_ID = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' + +describe('Antigravity AI Vault parser', () => { + it('parses the established transcript contract without leaking metadata wrappers', async () => { + const session = await parseAntigravitySessionContent( + file( + `/home/ada/.gemini/antigravity-cli/brain/${SESSION_ID}/.system_generated/logs/transcript.jsonl` + ), + jsonLines([ + { + step_index: 0, + source: 'USER_EXPLICIT', + type: 'USER_INPUT', + status: 'DONE', + created_at: '2026-07-15T11:39:10Z', + content: + '\nFix the vault adapter\n\nignored' + }, + { + step_index: 1, + source: 'SYSTEM', + type: 'CONVERSATION_HISTORY', + created_at: '2026-07-15T11:39:11Z' + }, + { + step_index: 2, + source: 'MODEL', + type: 'PLANNER_RESPONSE', + status: 'DONE', + created_at: '2026-07-15T11:39:12Z', + content: 'The adapter is ready.' + }, + { + step_index: 3, + source: 'SYSTEM', + type: 'CHECKPOINT', + created_at: '2026-07-15T11:39:14Z', + content: 'internal checkpoint' + } + ]), + 'linux' + ) + + expect(session).toMatchObject({ + agent: 'antigravity', + sessionId: SESSION_ID, + title: 'Fix the vault adapter', + cwd: null, + model: null, + createdAt: '2026-07-15T11:39:10.000Z', + updatedAt: '2026-07-15T11:39:14.000Z', + messageCount: 2, + resumeCommand: `agy --conversation '${SESSION_ID}'` + }) + expect(session?.previewMessages).toEqual([ + { + role: 'user', + text: 'Fix the vault adapter', + timestamp: '2026-07-15T11:39:10.000Z' + }, + { + role: 'assistant', + text: 'The adapter is ready.', + timestamp: '2026-07-15T11:39:12.000Z' + } + ]) + }) + + it('derives the conversation id from Windows paths and quotes the resume command', async () => { + const session = await parseAntigravitySessionContent( + file( + `C:\\Users\\Ada\\.gemini\\antigravity-cli\\brain\\${SESSION_ID}\\.system_generated\\logs\\transcript.jsonl` + ), + jsonLines([ + { + source: 'USER', + type: 'REQUEST', + created_at: '2026-07-15T11:39:10Z', + content: 'Windows prompt' + } + ]), + 'win32' + ) + + expect(session).toMatchObject({ + sessionId: SESSION_ID, + title: 'Windows prompt', + resumeCommand: `agy --conversation "${SESSION_ID}"` + }) + }) + + it('rejects files outside the targeted Antigravity transcript layout', async () => { + await expect( + parseAntigravitySessionContent( + file('/home/ada/.gemini/antigravity-cli/brain/transcript.jsonl'), + jsonLines([{ source: 'USER', type: 'REQUEST', content: 'Not a conversation transcript' }]) + ) + ).resolves.toBeNull() + }) +}) + +function file(path: string): FileWithMtime { + return { + path, + mtimeMs: Date.parse('2026-07-15T11:39:14Z'), + modifiedAt: '2026-07-15T11:39:14.000Z', + sizeBytes: 1 + } +} diff --git a/src/main/ai-vault/session-scanner-antigravity-parser.ts b/src/main/ai-vault/session-scanner-antigravity-parser.ts new file mode 100644 index 000000000000..2e3a3a08b4a4 --- /dev/null +++ b/src/main/ai-vault/session-scanner-antigravity-parser.ts @@ -0,0 +1,120 @@ +import { createReadStream } from 'node:fs' +import { createInterface } from 'node:readline' +import type { AiVaultSession } from '../../shared/ai-vault-types' +import type { ExecutionHostId } from '../../shared/execution-host' +import { + accumulatorFoldResumeState, + addPreviewMessage, + createAccumulator, + updateTimeline +} from './session-scanner-accumulator' +import { antigravityConversationIdFromTranscriptPath } from './session-scanner-antigravity-paths' +import type { + FileWithMtime, + ResumableSessionParseState, + SessionAccumulator +} from './session-scanner-types' +import { extractString, normalizeTitleText, parseJsonObject } from './session-scanner-values' + +type ParserSessionOptions = { + executionHostId?: ExecutionHostId + executionHostPlatform?: NodeJS.Platform | null +} + +export async function parseAntigravitySessionFile( + file: FileWithMtime, + platform: NodeJS.Platform = process.platform +): Promise { + const lines = createInterface({ + input: createReadStream(file.path, { encoding: 'utf-8' }), + crlfDelay: Infinity + }) + return parseAntigravitySessionLines({ file, lines, platform }) +} + +export async function parseAntigravitySessionContent( + file: FileWithMtime, + content: string, + platform: NodeJS.Platform = process.platform, + options: ParserSessionOptions = {} +): Promise { + return parseAntigravitySessionLines({ + file, + lines: content.split(/\r?\n/), + platform, + options + }) +} + +export function createAntigravitySessionResumeState( + file: FileWithMtime +): ResumableSessionParseState { + const sessionId = antigravityConversationIdFromTranscriptPath(file.path) ?? '' + // Why: the transcript has no cwd/model fields. Workspace enrichment is a + // separate, conservative history join; protobuf/SQLite blobs are unstable. + return accumulatorFoldResumeState( + createAccumulator({ agent: 'antigravity', file, sessionId }), + consumeAntigravityRecordLine + ) +} + +async function parseAntigravitySessionLines(args: { + file: FileWithMtime + lines: AsyncIterable | Iterable + platform: NodeJS.Platform + options?: ParserSessionOptions +}): Promise { + const state = createAntigravitySessionResumeState(args.file) + for await (const line of args.lines) { + state.consumeLine(line) + } + return state.finalize(args.platform, args.options) +} + +function consumeAntigravityRecordLine(accumulator: SessionAccumulator, line: string): void { + const record = parseJsonObject(line) + if (!record) { + return + } + + updateTimeline(accumulator, record.created_at) + const source = extractString(record.source) + const type = extractString(record.type) + const content = extractString(record.content) + + if ( + (source === 'USER_EXPLICIT' || source === 'USER') && + (type === 'USER_INPUT' || type === 'REQUEST') + ) { + const request = extractAntigravityUserRequest(content ?? '') + if (!request) { + return + } + accumulator.messageCount++ + accumulator.title ??= normalizeTitleText(request) + addPreviewMessage(accumulator, { role: 'user', text: request, timestamp: record.created_at }) + return + } + + if (source === 'MODEL' && type === 'PLANNER_RESPONSE' && content) { + accumulator.messageCount++ + addPreviewMessage(accumulator, { + role: 'assistant', + text: content, + timestamp: record.created_at + }) + } +} + +function extractAntigravityUserRequest(content: string): string | null { + const opener = '' + const startIndex = content.indexOf(opener) + if (startIndex === -1) { + return extractString(content) + } + const bodyStart = startIndex + opener.length + const endIndex = content.indexOf('', bodyStart) + return extractString( + endIndex === -1 ? content.slice(bodyStart) : content.slice(bodyStart, endIndex) + ) +} diff --git a/src/main/ai-vault/session-scanner-antigravity-paths.ts b/src/main/ai-vault/session-scanner-antigravity-paths.ts new file mode 100644 index 000000000000..c505cb7e5eee --- /dev/null +++ b/src/main/ai-vault/session-scanner-antigravity-paths.ts @@ -0,0 +1,42 @@ +import { dirname, join } from 'node:path' + +const ANTIGRAVITY_TRANSCRIPT_FILE = 'transcript.jsonl' +const ANTIGRAVITY_SYSTEM_DIR = '.system_generated' +const ANTIGRAVITY_LOGS_DIR = 'logs' + +export function antigravityConversationIdFromTranscriptPath(filePath: string): string | null { + const segments = pathSegments(filePath) + const transcriptIndex = segments.length - 1 + if ( + segments[transcriptIndex] !== ANTIGRAVITY_TRANSCRIPT_FILE || + segments[transcriptIndex - 1] !== ANTIGRAVITY_LOGS_DIR || + segments[transcriptIndex - 2] !== ANTIGRAVITY_SYSTEM_DIR + ) { + return null + } + return segments[transcriptIndex - 3] ?? null +} + +export function isAntigravityTranscriptPath(filePath: string): boolean { + return antigravityConversationIdFromTranscriptPath(filePath) !== null +} + +export function shouldDescendAntigravityBrainDirectory(name: string, depth: number): boolean { + // Why: brain entries contain large artifact trees; only the fixed transcript + // chain is part of the resumable conversation contract. + if (depth === 0) { + return true + } + if (depth === 1) { + return name === ANTIGRAVITY_SYSTEM_DIR + } + return depth === 2 && name === ANTIGRAVITY_LOGS_DIR +} + +export function antigravityHistoryPathForBrainDir(brainDir: string): string { + return join(dirname(brainDir), 'history.jsonl') +} + +function pathSegments(filePath: string): string[] { + return filePath.split(/[\\/]+/).filter(Boolean) +} diff --git a/src/main/ai-vault/session-scanner-antigravity-source.test.ts b/src/main/ai-vault/session-scanner-antigravity-source.test.ts new file mode 100644 index 000000000000..454b3cf92f66 --- /dev/null +++ b/src/main/ai-vault/session-scanner-antigravity-source.test.ts @@ -0,0 +1,167 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { filterAiVaultSessions } from '../../shared/ai-vault-session-filters' +import { AI_VAULT_AGENTS } from '../../shared/ai-vault-types' +import { scanAiVaultSessions } from './session-scanner' +import { + isolatedScanRoots, + writeAntigravityHistory, + writeAntigravityTranscript +} from './session-scanner-test-fixtures' + +const tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('Antigravity AI Vault discovery', () => { + it('discovers canonical transcripts from WSL homes without indexing sibling artifacts', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-antigravity-wsl-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const wslHome = join(root, 'wsl-home') + const sessionId = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' + const transcriptPath = await writeAntigravityTranscript( + join(wslHome, '.gemini', 'antigravity-cli', 'brain'), + sessionId, + [ + { + source: 'USER_EXPLICIT', + type: 'USER_INPUT', + created_at: '2026-07-15T11:39:10Z', + content: 'Index the WSL conversation' + } + ] + ) + await writeFile(join(dirname(transcriptPath), 'transcript_full.jsonl'), 'duplicate') + await writeAntigravityHistory(join(wslHome, '.gemini', 'antigravity-cli', 'brain'), [ + { + display: 'Index the WSL conversation', + timestamp: Date.parse('2026-07-15T11:39:10.100Z') / 1000, + workspace: '/home/ada/project' + } + ]) + + const result = await scanAiVaultSessions({ + ...roots, + wslHomeDirs: [wslHome], + platform: 'linux' + }) + + expect(result.issues).toEqual([]) + expect(result.sessions).toHaveLength(1) + expect(result.sessions[0]).toMatchObject({ + agent: 'antigravity', + sessionId, + title: 'Index the WSL conversation', + cwd: '/home/ada/project', + resumeCommand: `agy --conversation '${sessionId}'`, + filePath: transcriptPath + }) + }) + + it('uses a unique history match in the default workspace view', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-antigravity-workspace-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const sessionId = 'bbbbbbbb-cccc-4ddd-8eee-ffffffffffff' + const workspace = join(root, 'active-workspace') + await writeAntigravityTranscript(roots.antigravityBrainDir, sessionId, [ + { + source: 'USER_EXPLICIT', + type: 'USER_INPUT', + created_at: '2026-07-15T11:39:10.000Z', + content: 'ORCA_8742_REPRO' + } + ]) + + const beforeHistory = await scanAiVaultSessions({ ...roots, platform: 'darwin' }) + expect(beforeHistory.sessions[0]?.cwd).toBeNull() + + await writeAntigravityHistory(roots.antigravityBrainDir, [ + { + display: 'ORCA_8742_REPRO', + timestamp: Date.parse('2026-07-15T11:39:10.100Z'), + workspace + } + ]) + // The transcript is unchanged, so this also covers history refresh after a + // cached incremental parse has already been stored. + const result = await scanAiVaultSessions({ ...roots, platform: 'darwin' }) + + expect(result.sessions[0]?.cwd).toBe(workspace) + expect( + filterAiVaultSessions(result.sessions, { + query: '', + agents: AI_VAULT_AGENTS, + scope: 'workspace', + sort: 'updated', + activeWorktreePaths: [workspace], + hideEmptySessions: true + }).map((session) => session.sessionId) + ).toEqual([sessionId]) + }) + + it('keeps workspace unknown when matching history rows are ambiguous', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-antigravity-ambiguous-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const sessionId = 'cccccccc-dddd-4eee-8fff-aaaaaaaaaaaa' + await writeAntigravityTranscript(roots.antigravityBrainDir, sessionId, [ + { + source: 'USER_EXPLICIT', + type: 'USER_INPUT', + created_at: '2026-07-15T11:39:10.000Z', + content: 'Repeated prompt' + } + ]) + await writeAntigravityHistory(roots.antigravityBrainDir, [ + { + display: 'Repeated prompt', + timestamp: Date.parse('2026-07-15T11:39:09.900Z'), + workspace: '/repo/one' + }, + { + display: 'Repeated prompt', + timestamp: Date.parse('2026-07-15T11:39:10.100Z'), + workspace: '/repo/two' + } + ]) + + const result = await scanAiVaultSessions({ ...roots, platform: 'linux' }) + + expect(result.sessions[0]?.cwd).toBeNull() + }) + + it('does not join workspace from a colliding truncated prompt title', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-antigravity-long-title-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const sessionId = 'eeeeeeee-ffff-4aaa-8bbb-cccccccccccc' + const commonPrefix = 'shared worker preamble '.repeat(7) + await writeAntigravityTranscript(roots.antigravityBrainDir, sessionId, [ + { + source: 'USER_EXPLICIT', + type: 'USER_INPUT', + created_at: '2026-07-15T11:39:10.000Z', + content: `${commonPrefix}workspace A` + } + ]) + await writeAntigravityHistory(roots.antigravityBrainDir, [ + { + display: `${commonPrefix}workspace B`, + timestamp: Date.parse('2026-07-15T11:39:10.100Z'), + workspace: '/repo/wrong' + } + ]) + + const result = await scanAiVaultSessions({ ...roots, platform: 'linux' }) + + expect(result.sessions[0]?.title).toHaveLength(96) + expect(result.sessions[0]?.title).toMatch(/\.\.\.$/) + expect(result.sessions[0]?.cwd).toBeNull() + }) +}) diff --git a/src/main/ai-vault/session-scanner-antigravity-sources.ts b/src/main/ai-vault/session-scanner-antigravity-sources.ts new file mode 100644 index 000000000000..a79edc380e69 --- /dev/null +++ b/src/main/ai-vault/session-scanner-antigravity-sources.ts @@ -0,0 +1,34 @@ +import { homedir } from 'node:os' +import { join } from 'node:path' +import type { AiVaultScanIssue } from '../../shared/ai-vault-types' +import { + isAntigravityTranscriptPath, + shouldDescendAntigravityBrainDirectory +} from './session-scanner-antigravity-paths' +import { discoverFiles } from './session-scanner-discovery' +import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types' + +const ANTIGRAVITY_BRAIN_DIR = join(homedir(), '.gemini', 'antigravity-cli', 'brain') + +export function antigravityDiscoveries( + options: AiVaultScanOptions, + wslHomeDirs: readonly string[], + limit: number, + issues: AiVaultScanIssue[] +): Promise[] { + const rootDirs = [ + options.antigravityBrainDir ?? ANTIGRAVITY_BRAIN_DIR, + ...wslHomeDirs.map((homeDir) => join(homeDir, '.gemini', 'antigravity-cli', 'brain')) + ] + return rootDirs.map((rootDir) => + discoverFiles({ + rootDir, + limit, + agent: 'antigravity', + issues, + extensions: ['.jsonl'], + filePredicate: isAntigravityTranscriptPath, + directoryPredicate: shouldDescendAntigravityBrainDirectory + }) + ) +} diff --git a/src/main/ai-vault/session-scanner-codex-workers.test.ts b/src/main/ai-vault/session-scanner-codex-workers.test.ts index 8a6fb183f623..d8a8e2850ac9 100644 --- a/src/main/ai-vault/session-scanner-codex-workers.test.ts +++ b/src/main/ai-vault/session-scanner-codex-workers.test.ts @@ -106,6 +106,7 @@ describe('scanAiVaultSessions Codex worker sessions', () => { claudeProjectsDir: join(root, 'claude-projects'), codexSessionsDir, geminiSessionsDir: join(root, 'gemini-sessions'), + antigravityBrainDir: join(root, 'antigravity-brain'), copilotSessionsDir: join(root, 'copilot-sessions'), cursorProjectsDir: join(root, 'cursor-projects'), opencodeStorageDir: join(root, 'opencode-storage'), diff --git a/src/main/ai-vault/session-scanner-discovery.ts b/src/main/ai-vault/session-scanner-discovery.ts index c176fb3c6e5a..576438a8c492 100644 --- a/src/main/ai-vault/session-scanner-discovery.ts +++ b/src/main/ai-vault/session-scanner-discovery.ts @@ -11,7 +11,7 @@ export async function discoverFiles(args: { issues: AiVaultScanIssue[] extensions: string[] filePredicate?: (path: string) => boolean - directoryPredicate?: (name: string) => boolean + directoryPredicate?: (name: string, depth: number) => boolean }): Promise { const paths = await walkSessionFiles(args.rootDir, args.agent, args.issues, { extensions: new Set(args.extensions), @@ -70,10 +70,11 @@ export async function walkSessionFiles( options: { extensions: Set filePredicate?: (path: string) => boolean - // Return false to skip descending into a directory (matched by its name), - // so pruned subtrees are never stat'd or parsed. - directoryPredicate?: (name: string) => boolean - } + // Return false to skip descending into a directory; depth 0 is a child of + // rootDir, so pruned subtrees are never stat'd or parsed. + directoryPredicate?: (name: string, depth: number) => boolean + }, + depth = 0 ): Promise { let entries try { @@ -88,8 +89,8 @@ export async function walkSessionFiles( if (entry.isDirectory()) { // Skip whole subtrees an agent never wants (e.g. subagent transcripts), // avoiding the readdir cost of descending into them. - if (options.directoryPredicate?.(entry.name) ?? true) { - files.push(...(await walkSessionFiles(fullPath, agent, issues, options))) + if (options.directoryPredicate?.(entry.name, depth) ?? true) { + files.push(...(await walkSessionFiles(fullPath, agent, issues, options, depth + 1))) } continue } diff --git a/src/main/ai-vault/session-scanner-incremental-fixtures.ts b/src/main/ai-vault/session-scanner-incremental-fixtures.ts index 1945193e6c77..54f0171e6c60 100644 --- a/src/main/ai-vault/session-scanner-incremental-fixtures.ts +++ b/src/main/ai-vault/session-scanner-incremental-fixtures.ts @@ -1,5 +1,6 @@ import type { AiVaultAgent } from '../../shared/ai-vault-types' +import { antigravityFixture } from './session-scanner-antigravity-fixtures' import { codexFixture } from './session-scanner-codex-fixtures' // Line builders for the incremental-parse differential tests: each agent gets @@ -286,6 +287,7 @@ export function allIncrementalAgentFixtures(): IncrementalAgentFixture[] { openclawFixture(), piFixture(), ompFixture(), - geminiJsonlFixture() + geminiJsonlFixture(), + antigravityFixture() ] } diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts index 2fb3df216136..1819afe81f24 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts @@ -23,6 +23,7 @@ function isolatedScanRoots(root: string) { claudeProjectsDir: join(root, 'claude-projects'), codexSessionsDir: join(root, 'codex-sessions'), geminiSessionsDir: join(root, 'gemini-sessions'), + antigravityBrainDir: join(root, 'antigravity-brain'), copilotSessionsDir: join(root, 'copilot-sessions'), cursorProjectsDir: join(root, 'cursor-projects'), opencodeStorageDir: join(root, 'opencode-storage'), diff --git a/src/main/ai-vault/session-scanner-parse-cache-agents.test.ts b/src/main/ai-vault/session-scanner-parse-cache-agents.test.ts index 61348ad7cec9..cd4977679d28 100644 --- a/src/main/ai-vault/session-scanner-parse-cache-agents.test.ts +++ b/src/main/ai-vault/session-scanner-parse-cache-agents.test.ts @@ -1,6 +1,6 @@ import { appendFile, mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { parseAgentSessionFile } from './session-scanner-agent-parser' import { @@ -55,6 +55,7 @@ describe.each(allIncrementalAgentFixtures())('incremental parse parity: $agent', it('reuses unchanged files, resumes appends, and matches cold parses exactly', async () => { const root = await makeTempDir() const path = join(root, fixture.fileName) + await mkdir(dirname(path), { recursive: true }) await writeFile(path, `${fixture.seedLines.join('\n')}\n`) const stats = createSessionParseStats() @@ -87,6 +88,7 @@ describe.each(allIncrementalAgentFixtures())('incremental parse parity: $agent', it('includes a trailing unterminated line without double-counting it later', async () => { const root = await makeTempDir() const path = join(root, fixture.fileName) + await mkdir(dirname(path), { recursive: true }) const lastSeedLine = fixture.seedLines.at(-1) const headLines = fixture.seedLines.slice(0, -1) await writeFile(path, `${[...headLines, ''].join('\n')}${lastSeedLine}`) @@ -106,6 +108,7 @@ describe.each(allIncrementalAgentFixtures())('incremental parse parity: $agent', it('tolerates a mid-write truncated trailing line and never double-counts it', async () => { const root = await makeTempDir() const path = join(root, fixture.fileName) + await mkdir(dirname(path), { recursive: true }) // A writer caught mid-record: the trailing line is invalid JSON. await writeFile(path, `${fixture.seedLines.join('\n')}\n{"type":"user","mess`) diff --git a/src/main/ai-vault/session-scanner-parse-cache.ts b/src/main/ai-vault/session-scanner-parse-cache.ts index 70d0d311d6b8..d42da123437a 100644 --- a/src/main/ai-vault/session-scanner-parse-cache.ts +++ b/src/main/ai-vault/session-scanner-parse-cache.ts @@ -1,6 +1,7 @@ import { createReadStream } from 'node:fs' import { open } from 'node:fs/promises' import type { AiVaultSession } from '../../shared/ai-vault-types' +import { createAntigravitySessionResumeState } from './session-scanner-antigravity-parser' import { parseAgentSessionFile } from './session-scanner-agent-parser' import { createCodexSessionResumeState } from './session-scanner-codex-parser' import { createDroidSessionResumeState } from './session-scanner-droid-parser' @@ -67,6 +68,8 @@ function resumableStateFactoryFor( return candidate.file.path.endsWith('.jsonl') ? () => createGeminiJsonlSessionResumeState(candidate.file) : null + case 'antigravity': + return () => createAntigravitySessionResumeState(candidate.file) case 'devin': case 'grok': case 'hermes': diff --git a/src/main/ai-vault/session-scanner-scope.test.ts b/src/main/ai-vault/session-scanner-scope.test.ts index 77931d687150..487748de97a3 100644 --- a/src/main/ai-vault/session-scanner-scope.test.ts +++ b/src/main/ai-vault/session-scanner-scope.test.ts @@ -19,6 +19,7 @@ function scopedScanOptions(claudeProjectsDir: string, extra: Partial[] { return [ + ...antigravityDiscoveries(options, wslHomeDirs, limit, issues), ...sessionRootDirs(options.geminiSessionsDir ?? GEMINI_SESSIONS_DIR, wslHomeDirs, [ '.gemini', 'tmp' diff --git a/src/main/ai-vault/session-scanner-test-fixtures.ts b/src/main/ai-vault/session-scanner-test-fixtures.ts index 0a3b6c7c1a85..f69bf2939c79 100644 --- a/src/main/ai-vault/session-scanner-test-fixtures.ts +++ b/src/main/ai-vault/session-scanner-test-fixtures.ts @@ -6,6 +6,7 @@ export function isolatedScanRoots(root: string) { claudeProjectsDir: join(root, 'claude-projects'), codexSessionsDir: join(root, 'codex-sessions'), geminiSessionsDir: join(root, 'gemini-sessions'), + antigravityBrainDir: join(root, 'antigravity-brain'), copilotSessionsDir: join(root, 'copilot-sessions'), cursorProjectsDir: join(root, 'cursor-projects'), opencodeStorageDir: join(root, 'opencode-storage'), @@ -34,3 +35,37 @@ export async function writeJsonlFile(filePath: string, records: unknown[]): Prom await mkdir(dirname(filePath), { recursive: true }) await writeFile(filePath, jsonLines(records)) } + +export async function writeAntigravityTranscript( + brainDir: string, + sessionId: string, + records: unknown[] +): Promise { + const transcriptPath = join(brainDir, sessionId, '.system_generated', 'logs', 'transcript.jsonl') + await writeJsonlFile(transcriptPath, records) + return transcriptPath +} + +export function writeAntigravityHistory(brainDir: string, records: unknown[]): Promise { + return writeJsonlFile(join(dirname(brainDir), 'history.jsonl'), records) +} + +export function writeAntigravityScannerFixture( + brainDir: string, + sessionId: string +): Promise { + return writeAntigravityTranscript(brainDir, sessionId, [ + { + source: 'USER_EXPLICIT', + type: 'USER_INPUT', + created_at: '2026-05-01T10:02:30.000Z', + content: 'Antigravity title' + }, + { + source: 'MODEL', + type: 'PLANNER_RESPONSE', + created_at: '2026-05-01T10:02:31.000Z', + content: 'Done' + } + ]) +} diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index 803554316c1c..efc6660ab1ad 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -12,6 +12,7 @@ export type AiVaultScanOptions = { additionalCodexSessionsDirs?: readonly string[] wslHomeDirs?: readonly string[] geminiSessionsDir?: string + antigravityBrainDir?: string copilotSessionsDir?: string cursorProjectsDir?: string opencodeStorageDir?: string @@ -52,6 +53,7 @@ export type SessionFileCandidate = { agent: AiVaultAgent file: FileWithMtime codexHome: string | null + antigravityHistoryPath?: string } export type SessionFileDiscovery = { diff --git a/src/main/ai-vault/session-scanner.test.ts b/src/main/ai-vault/session-scanner.test.ts index d4404341fc11..7ba805560513 100644 --- a/src/main/ai-vault/session-scanner.test.ts +++ b/src/main/ai-vault/session-scanner.test.ts @@ -4,7 +4,11 @@ import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { AI_VAULT_AGENTS } from '../../shared/ai-vault-types' import { scanAiVaultSessions } from './session-scanner' -import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures' +import { + isolatedScanRoots, + jsonLines, + writeAntigravityScannerFixture +} from './session-scanner-test-fixtures' let tempRoots: string[] = [] @@ -423,6 +427,9 @@ describe('scanAiVaultSessions', () => { }) ) + const antigravitySessionId = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' + await writeAntigravityScannerFixture(roots.antigravityBrainDir, antigravitySessionId) + await mkdir(roots.copilotSessionsDir, { recursive: true }) await writeFile( join(roots.copilotSessionsDir, 'copilot-session.jsonl'), @@ -718,11 +725,7 @@ describe('scanAiVaultSessions', () => { ]) ) - const result = await scanAiVaultSessions({ - ...roots, - platform: 'darwin', - limit: 20 - }) + const result = await scanAiVaultSessions({ ...roots, platform: 'darwin', limit: 20 }) expect(result.issues).toEqual([]) expect(new Set(result.sessions.map((session) => session.agent))).toEqual( @@ -739,6 +742,7 @@ describe('scanAiVaultSessions', () => { `cd '/tmp/codex' && CODEX_HOME='${root}' codex resume 'codex-session'` ) expect(commandByAgent.get('gemini')).toBe("gemini --resume 'gemini-session'") + expect(commandByAgent.get('antigravity')).toBe(`agy --conversation '${antigravitySessionId}'`) expect(commandByAgent.get('copilot')).toBe( "cd '/tmp/copilot' && copilot --resume='copilot-session'" ) diff --git a/src/main/ai-vault/session-scanner.ts b/src/main/ai-vault/session-scanner.ts index 5ba2a95d5035..ddb9ba7a8617 100644 --- a/src/main/ai-vault/session-scanner.ts +++ b/src/main/ai-vault/session-scanner.ts @@ -1,3 +1,4 @@ +import { readFile } from 'node:fs/promises' import type { AiVaultListResult, AiVaultScanIssue, @@ -6,6 +7,11 @@ import type { import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host' import { withSpan } from '../observability/tracer' import { sessionSortTime } from './session-scanner-accumulator' +import { + createAntigravityWorkspaceResolver, + type AntigravityWorkspaceResolver +} from './session-scanner-antigravity-history' +import { antigravityHistoryPathForBrainDir } from './session-scanner-antigravity-paths' import { codexHomeForSessionsDir } from './session-scanner-codex-paths' import { createSessionParseStats, @@ -54,6 +60,7 @@ export async function scanAiVaultSessions( const executionHostId = options.executionHostId ?? LOCAL_EXECUTION_HOST_ID const issues: AiVaultScanIssue[] = [] const parseStats = createSessionParseStats() + const antigravityWorkspaceResolver = createAntigravityWorkspaceResolver(readOptionalTextFile) const discoveries = await discoverAiVaultSessionSources({ options, limitPerAgent, issues }) const candidates = discoveries @@ -65,7 +72,11 @@ export async function scanAiVaultSessions( codexHome: discovery.agent === 'codex' ? codexHomeForSessionsDir(discovery.rootDir, DEFAULT_CODEX_HOME_DIR) - : null + : null, + antigravityHistoryPath: + discovery.agent === 'antigravity' + ? antigravityHistoryPathForBrainDir(discovery.rootDir) + : undefined }) ) ) @@ -77,7 +88,8 @@ export async function scanAiVaultSessions( platform, executionHostId, issues, - parseStats + parseStats, + antigravityWorkspaceResolver }) const cappedSessions = parsedSessions @@ -175,6 +187,7 @@ async function parseSessionCandidates(args: { executionHostId: ExecutionHostId issues: AiVaultScanIssue[] parseStats: SessionParseStats + antigravityWorkspaceResolver?: AntigravityWorkspaceResolver }): Promise { const sessions: AiVaultSession[] = [] let index = 0 @@ -190,7 +203,13 @@ async function parseSessionCandidates(args: { const batch = args.candidates.slice(index, index + batchSize) const results = await Promise.all( batch.map((candidate) => - parseSessionCandidate(candidate, args.platform, args.executionHostId, args.parseStats) + parseSessionCandidate( + candidate, + args.platform, + args.executionHostId, + args.parseStats, + args.antigravityWorkspaceResolver + ) ) ) @@ -213,10 +232,14 @@ async function parseSessionCandidate( candidate: SessionFileCandidate, platform: NodeJS.Platform, executionHostId: ExecutionHostId, - parseStats: SessionParseStats + parseStats: SessionParseStats, + antigravityWorkspaceResolver?: AntigravityWorkspaceResolver ): Promise { try { - const session = await parseAgentSessionFileCached(candidate, platform, parseStats) + let session = await parseAgentSessionFileCached(candidate, platform, parseStats) + if (session && candidate.antigravityHistoryPath && antigravityWorkspaceResolver) { + session = await antigravityWorkspaceResolver.enrich(session, candidate.antigravityHistoryPath) + } return { session: session ? withSessionExecutionHost(session, executionHostId) : null, issue: null @@ -234,6 +257,14 @@ async function parseSessionCandidate( } } +async function readOptionalTextFile(path: string): Promise { + try { + return await readFile(path, 'utf-8') + } catch { + return null + } +} + function withSessionExecutionHost( session: AiVaultSession, executionHostId: ExecutionHostId diff --git a/src/shared/ai-vault-resume-command.test.ts b/src/shared/ai-vault-resume-command.test.ts index a654dd8217e5..c7ac1423e10c 100644 --- a/src/shared/ai-vault-resume-command.test.ts +++ b/src/shared/ai-vault-resume-command.test.ts @@ -3,6 +3,17 @@ import { describe, expect, it } from 'vitest' import { buildAiVaultResumeCommand } from './ai-vault-types' describe('buildAiVaultResumeCommand', () => { + it('uses Antigravity conversation ids instead of Gemini resume flags', () => { + expect( + buildAiVaultResumeCommand({ + agent: 'antigravity', + sessionId: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee', + cwd: '/repo/app', + platform: 'darwin' + }) + ).toBe("cd '/repo/app' && agy --conversation 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'") + }) + it('builds a self-contained cmd wrapper when no live shell is known', () => { expect( buildAiVaultResumeCommand({ diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts index fe07aca6963d..dd780ef9ac4f 100644 --- a/src/shared/ai-vault-types.ts +++ b/src/shared/ai-vault-types.ts @@ -15,6 +15,7 @@ export const AI_VAULT_AGENTS = [ 'omp', 'cursor', 'gemini', + 'antigravity', 'rovo', 'copilot', 'opencode', @@ -43,6 +44,7 @@ export const AI_VAULT_AGENT_LABELS = { omp: 'OMP', cursor: 'Cursor', gemini: 'Gemini', + antigravity: 'Antigravity', rovo: 'Rovo Dev', copilot: 'GitHub Copilot', opencode: 'OpenCode', @@ -323,6 +325,8 @@ function buildAgentResumeInvocation( // but the `--resume ` invocation form is identical to the others here. case 'omp': return `${baseCommand} --resume ${sessionArg}` + case 'antigravity': + return `${baseCommand} --conversation ${sessionArg}` } } From 7e743c41c2eaa416c7ffc8f0b59c2459c48cb6e9 Mon Sep 17 00:00:00 2001 From: Tim Suchanek Date: Thu, 16 Jul 2026 04:17:21 -0700 Subject: [PATCH 075/351] fix(ssh): reconnect persisted PTYs after shutdown races (#8403) * fix(ssh): reconnect persisted PTYs after shutdown races * fix(ssh): harden shutdown reconnect-target derivation - exclude runtime-owned (ephemeral-VM) targets: a renderer-driven startup ssh.connect would dispose the runtime layer's live relay session - exclude explicitly disconnected / auth-failed targets so startup never dials a host the user chose to leave offline (sessions still restore on tab focus via the deferred flow) - avoid re-running buildTerminalSessionData via the default parameter when the remote-session map is legitimately empty (null sentinel) - recompute activeConnectionIdsAtShutdown alongside terminal-field patches so a crash between patches cannot strand a stale target - pin all of the above with tests, including the patch-writer path Co-authored-by: Orca * fix(ssh): filter runtime-owned targets at startup reconnect consumption Main's windowless-promotion path can persist runtime-owned ids into activeConnectionIdsAtShutdown; renderer must never dial those. Co-authored-by: Orca * test(ssh): pin runtime-owned exclusion independently of the status gate Give the runtime-owned fixture a transient status so the status gate alone cannot exclude it; verified by mutation (guard removal fails). Co-authored-by: Orca * refactor(session): extract reconnect-target derivation to its own module workspace-session.ts crossed the max-lines budget; the derivation is a self-contained concern (session ids + live SSH state -> startup targets). Parameter is now required (null = none) since every caller has the terminal session data or must opt into the scan explicitly. Co-authored-by: Orca * fix(ssh): exclude runtime-owned targets from the connected-states path too A pane-level optimistic write can stamp a runtime-owned connection state; the live 'connected' filter must apply the same exclusion as the session-id union (CodeRabbit review finding). Pinned in the test on both paths. Co-authored-by: Orca --------- Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> Co-authored-by: Orca --- src/renderer/src/App.tsx | 14 +- .../lib/workspace-session-liveness.test.ts | 133 ++++++++++++++++++ .../src/lib/workspace-session-patch.test.ts | 23 +++ .../src/lib/workspace-session-patch.ts | 19 ++- .../workspace-session-reconnect-targets.ts | 47 +++++++ src/renderer/src/lib/workspace-session.ts | 20 +-- 6 files changed, 237 insertions(+), 19 deletions(-) create mode 100644 src/renderer/src/lib/workspace-session-reconnect-targets.ts diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 014e3950e42a..9fecbe8fd84e 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -164,7 +164,11 @@ import { type KeybindingContext, type PhysicalModifierToken } from '../../shared/keybindings' -import { toRuntimeExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' +import { + isRuntimeOwnedSshTargetId, + toRuntimeExecutionHostId, + type ExecutionHostId +} from '../../shared/execution-host' import { ModifierDoubleTapDetector, toModifierDoubleTapEvent @@ -1001,7 +1005,13 @@ function App(): React.JSX.Element { // tabs through pty.attach on the relay. Passphrase-protected targets // are deferred to tab focus to avoid stacking credential dialogs at // startup before the user has context. - const connectionIds = sessionRead.session.activeConnectionIdsAtShutdown ?? [] + // Why: runtime-owned (ephemeral-VM) targets must never be dialed from + // the renderer — ssh.connect would dispose the runtime layer's live + // relay session. Main's windowless-promotion path can persist such + // ids into this list, so filter at the consumption boundary too. + const connectionIds = (sessionRead.session.activeConnectionIdsAtShutdown ?? []).filter( + (targetId) => !isRuntimeOwnedSshTargetId(targetId) + ) if (connectionIds.length > 0) { try { const SSH_RECONNECT_TIMEOUT_MS = 15_000 diff --git a/src/renderer/src/lib/workspace-session-liveness.test.ts b/src/renderer/src/lib/workspace-session-liveness.test.ts index db06aaf0e6cb..e46fa1b66d73 100644 --- a/src/renderer/src/lib/workspace-session-liveness.test.ts +++ b/src/renderer/src/lib/workspace-session-liveness.test.ts @@ -91,4 +91,137 @@ describe('workspace session live PTY persistence', () => { expect(payload.activeWorktreeIdsOnShutdown).toEqual([]) expect(payload.remoteSessionIdsByTabId).toBeUndefined() }) + + it('reconnects a persisted SSH PTY when shutdown observes a transient relay drop', () => { + const payload = buildWorkspaceSessionPayload( + createSnapshot({ + tabsByWorktree: { + 'wt-ssh': [ + { + id: 'tab-ssh', + title: 'remote', + ptyId: null, + worktreeId: 'wt-ssh' + } as never + ] + }, + ptyIdsByTabId: { 'tab-ssh': [] }, + lastKnownRelayPtyIdByTabId: { 'tab-ssh': 'ssh:conn-1@@pty-42' }, + // Why 'reconnecting': an involuntary transport drop always lands in + // 'reconnecting' / 'reconnection-failed' / 'error' — never + // 'disconnected', which only an explicit user disconnect produces. + sshConnectionStates: new Map([['conn-1', { status: 'reconnecting' } as never]]), + repos: [ + { + id: 'repo-ssh', + path: '/repo-ssh', + displayName: 'SSH', + badgeColor: '#fff', + addedAt: 1, + connectionId: 'conn-1' + } + ], + worktreesByRepo: { + 'repo-ssh': [{ id: 'wt-ssh', repoId: 'repo-ssh' } as never] + } + }) + ) + + expect(payload.remoteSessionIdsByTabId).toEqual({ + 'tab-ssh': 'ssh:conn-1@@pty-42' + }) + expect(payload.activeConnectionIdsAtShutdown).toEqual(['conn-1']) + }) + + it('keeps explicitly disconnected hosts out of the startup reconnect list', () => { + const payload = buildWorkspaceSessionPayload( + createSnapshot({ + tabsByWorktree: { + 'wt-ssh': [ + { + id: 'tab-ssh', + title: 'remote', + ptyId: null, + worktreeId: 'wt-ssh' + } as never + ] + }, + ptyIdsByTabId: { 'tab-ssh': [] }, + lastKnownRelayPtyIdByTabId: { 'tab-ssh': 'ssh:conn-1@@pty-42' }, + // 'disconnected' = the user chose to take this host offline. The + // session id must still persist (restore-on-focus), but startup must + // not auto-dial the host against the user's intent. + sshConnectionStates: new Map([['conn-1', { status: 'disconnected' } as never]]), + repos: [ + { + id: 'repo-ssh', + path: '/repo-ssh', + displayName: 'SSH', + badgeColor: '#fff', + addedAt: 1, + connectionId: 'conn-1' + } + ], + worktreesByRepo: { + 'repo-ssh': [{ id: 'wt-ssh', repoId: 'repo-ssh' } as never] + } + }) + ) + + expect(payload.remoteSessionIdsByTabId).toEqual({ + 'tab-ssh': 'ssh:conn-1@@pty-42' + }) + expect(payload.activeConnectionIdsAtShutdown).toBeUndefined() + }) + + it('never derives runtime-owned SSH targets into the startup reconnect list', () => { + const payload = buildWorkspaceSessionPayload( + createSnapshot({ + tabsByWorktree: { + 'wt-vm': [ + { + id: 'tab-vm', + title: 'vm', + ptyId: null, + worktreeId: 'wt-vm' + } as never + ] + }, + ptyIdsByTabId: { 'tab-vm': [] }, + lastKnownRelayPtyIdByTabId: { 'tab-vm': 'ssh:runtime-ssh-vm1@@pty-7' }, + // Why a status entry: without one the status gate already excludes the + // target and the runtime-owned check would be untested dead weight. + // A pane-driven optimistic write CAN stamp runtime-owned states + // (TerminalSshReconnectOverlay), so pin the exclusion independently — + // on both the session-id union path ('reconnecting') and the live + // connected-states path ('connected'). + sshConnectionStates: new Map([ + ['runtime-ssh-vm1', { status: 'reconnecting' } as never], + ['runtime-ssh-vm2', { status: 'connected' } as never] + ]), + repos: [ + { + id: 'repo-vm', + path: '/repo-vm', + displayName: 'VM', + badgeColor: '#fff', + addedAt: 1, + connectionId: 'runtime-ssh-vm1' + } + ], + worktreesByRepo: { + 'repo-vm': [{ id: 'wt-vm', repoId: 'repo-vm' } as never] + } + }) + ) + + // Why: the renderer must never drive startup ssh.connect for runtime-owned + // (ephemeral-VM) targets — their lifecycle belongs to the runtime layer, + // and ssh.listTargets() hides them so the connect would target a host the + // user cannot see or manage. + expect(payload.remoteSessionIdsByTabId).toEqual({ + 'tab-vm': 'ssh:runtime-ssh-vm1@@pty-7' + }) + expect(payload.activeConnectionIdsAtShutdown).toBeUndefined() + }) }) diff --git a/src/renderer/src/lib/workspace-session-patch.test.ts b/src/renderer/src/lib/workspace-session-patch.test.ts index ec4ea11e643a..da578c1ff42d 100644 --- a/src/renderer/src/lib/workspace-session-patch.test.ts +++ b/src/renderer/src/lib/workspace-session-patch.test.ts @@ -205,6 +205,10 @@ describe('buildWorkspaceSessionPatch', () => { expect(Object.keys(patch).sort()).toEqual( [ + // Why: the reconnect list derives from remote session ids, so any + // patch that rewrites remoteSessionIdsByTabId must carry it too — + // otherwise a crash between patches strands a stale target on disk. + 'activeConnectionIdsAtShutdown', 'activeWorktreeIdsOnShutdown', 'remoteSessionIdsByTabId', 'tabsByWorktree', @@ -225,6 +229,25 @@ describe('buildWorkspaceSessionPatch', () => { expect(patch.activeConnectionIdsAtShutdown).toBeUndefined() }) + it('derives reconnect targets from surviving relay session ids in terminal-field patches', () => { + const patch = buildWorkspaceSessionPatch( + createSnapshot({ + tabsByWorktree: { + 'wt-ssh': [{ id: 'tab-ssh', title: 'remote', ptyId: null, worktreeId: 'wt-ssh' } as never] + }, + ptyIdsByTabId: { 'tab-ssh': [] }, + lastKnownRelayPtyIdByTabId: { 'tab-ssh': 'ssh:conn-1@@pty-42' }, + sshConnectionStates: new Map([['conn-1', { status: 'reconnecting' } as never]]), + repos: [createRepo('repo-ssh', 'conn-1')], + worktreesByRepo: { 'repo-ssh': [{ id: 'wt-ssh', repoId: 'repo-ssh' } as never] } + }), + ['lastKnownRelayPtyIdByTabId'] + ) + + expect(patch.remoteSessionIdsByTabId).toEqual({ 'tab-ssh': 'ssh:conn-1@@pty-42' }) + expect(patch.activeConnectionIdsAtShutdown).toEqual(['conn-1']) + }) + it('patches tab chrome as a sanitized bundle when split groups change', () => { const patch = buildWorkspaceSessionPatch( createSnapshot({ diff --git a/src/renderer/src/lib/workspace-session-patch.ts b/src/renderer/src/lib/workspace-session-patch.ts index eff46edfa231..e88f476a8f6b 100644 --- a/src/renderer/src/lib/workspace-session-patch.ts +++ b/src/renderer/src/lib/workspace-session-patch.ts @@ -72,10 +72,21 @@ export function buildWorkspaceSessionPatch( 'worktreesByRepo' ] as const) ) { - Object.assign(patch, buildTerminalSessionData(snapshot)) - } - if (changed.has('sshConnectionStates')) { - patch.activeConnectionIdsAtShutdown = buildActiveConnectionIdsAtShutdown(snapshot) + const terminalSessionData = buildTerminalSessionData(snapshot) + Object.assign(patch, terminalSessionData) + // Why: the reconnect list is derived from persisted remote session ids as + // well as live SSH state. Recompute it alongside remoteSessionIdsByTabId + // so a crash between patches cannot leave a target on disk whose sessions + // were all closed (or vice versa). + patch.activeConnectionIdsAtShutdown = buildActiveConnectionIdsAtShutdown( + snapshot, + terminalSessionData.remoteSessionIdsByTabId ?? null + ) + } else if (changed.has('sshConnectionStates')) { + patch.activeConnectionIdsAtShutdown = buildActiveConnectionIdsAtShutdown( + snapshot, + buildTerminalSessionData(snapshot).remoteSessionIdsByTabId ?? null + ) } if ( hasAnyChangedField(changed, [ diff --git a/src/renderer/src/lib/workspace-session-reconnect-targets.ts b/src/renderer/src/lib/workspace-session-reconnect-targets.ts new file mode 100644 index 000000000000..713e4388e53b --- /dev/null +++ b/src/renderer/src/lib/workspace-session-reconnect-targets.ts @@ -0,0 +1,47 @@ +import type { WorkspaceSessionState } from '../../../shared/types' +import { parseAppSshPtyId } from '../../../shared/ssh-pty-id' +import { isRuntimeOwnedSshTargetId } from '../../../shared/execution-host' +import type { WorkspaceSessionSnapshot } from './workspace-session' + +export function buildActiveConnectionIdsAtShutdown( + snapshot: WorkspaceSessionSnapshot, + // Why: required (null = none) rather than defaulted — every caller already + // has the terminal session data in hand, and a default initializer would + // re-run the full repo/worktree scan on the shutdown-critical path. + remoteSessionIdsByTabId: WorkspaceSessionState['remoteSessionIdsByTabId'] | null +): WorkspaceSessionState['activeConnectionIdsAtShutdown'] { + // Why: sshConnectionStates is a Map, not a plain + // object. Object.entries() on a Map returns [] — must use Array.from(). + // Runtime-owned states are normally never broadcast to the renderer, but a + // pane-level optimistic write could stamp one; exclude them here too. + const targetIds = new Set( + Array.from(snapshot.sshConnectionStates.entries()) + .filter( + ([targetId, state]) => state.status === 'connected' && !isRuntimeOwnedSshTargetId(targetId) + ) + .map(([targetId]) => targetId) + ) + + // Why: shutdown can observe SSH in a transient state (relay drop mid-quit, + // exhausted reconnect) after the socket closed but before the snapshot + // flushed. The durable PTY id still names the target Orca must reconnect to + // restore that surviving remote session. Two exclusions: + // 'disconnected'/'auth-failed'/never-observed usually mean an explicit user + // disconnect or a failed/cancelled connect — startup must not auto-dial a + // host the user left offline or stack credential dialogs (sessions still + // restore on tab focus via the deferred flow, so only eagerness is lost). + // Runtime-owned (ephemeral-VM) targets belong to the runtime layer; a + // renderer-driven ssh.connect would dispose the runtime's live relay session. + for (const sessionId of Object.values(remoteSessionIdsByTabId ?? {})) { + const connectionId = parseAppSshPtyId(sessionId)?.connectionId + if (!connectionId || isRuntimeOwnedSshTargetId(connectionId)) { + continue + } + const status = snapshot.sshConnectionStates.get(connectionId)?.status + if (status && status !== 'disconnected' && status !== 'auth-failed') { + targetIds.add(connectionId) + } + } + + return targetIds.size > 0 ? Array.from(targetIds) : undefined +} diff --git a/src/renderer/src/lib/workspace-session.ts b/src/renderer/src/lib/workspace-session.ts index f0acc22e620e..23cc4da80e05 100644 --- a/src/renderer/src/lib/workspace-session.ts +++ b/src/renderer/src/lib/workspace-session.ts @@ -12,6 +12,9 @@ import type { OpenFile } from '../store/slices/editor' import { buildPersistedUnifiedTabSessionData } from './workspace-session-unified-tabs' import { buildLastVisitedAtByWorktreeId } from './workspace-session-focus-recency' import { buildSleepingAgentSessionData } from './workspace-session-sleeping-agents' +import { buildActiveConnectionIdsAtShutdown } from './workspace-session-reconnect-targets' + +export { buildActiveConnectionIdsAtShutdown } /** Why (issue #1158): the debounced + shutdown session writers share this * gate so a hydration failure cannot overwrite orca-data.json with the @@ -331,18 +334,6 @@ export function buildTerminalSessionData( } } -export function buildActiveConnectionIdsAtShutdown( - snapshot: WorkspaceSessionSnapshot -): WorkspaceSessionState['activeConnectionIdsAtShutdown'] { - // Why: sshConnectionStates is a Map, not a plain - // object. Object.entries() on a Map returns [] — must use Array.from(). - const connectedTargetIds = Array.from(snapshot.sshConnectionStates.entries()) - .filter(([, state]) => state.status === 'connected') - .map(([targetId]) => targetId) - - return connectedTargetIds.length > 0 ? connectedTargetIds : undefined -} - export function buildWorkspaceSessionPayload( snapshot: WorkspaceSessionSnapshot ): WorkspaceSessionState { @@ -380,7 +371,10 @@ export function buildWorkspaceSessionPayload( // Persist only layouts backed by real tabs so a reload cannot restore a // blank split pane from that transient midpoint. ...buildPersistedUnifiedTabSessionData(snapshot), - activeConnectionIdsAtShutdown: buildActiveConnectionIdsAtShutdown(snapshot), + activeConnectionIdsAtShutdown: buildActiveConnectionIdsAtShutdown( + snapshot, + terminalSessionData.remoteSessionIdsByTabId ?? null + ), remoteSessionIdsByTabId: terminalSessionData.remoteSessionIdsByTabId, // Why: per-worktree focus-recency for Cmd+J's empty-query ordering. // Omit when empty so sessions written by builds that never stamped From ef23b6760c01399817aec1123374cfac44710af5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:50:02 +0000 Subject: [PATCH 076/351] Update README downloads badge --- docs/assets/readme-downloads.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index d6127bbe29b7..417924fc997a 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 5.7m + + downloads: 5.8m @@ -15,7 +15,7 @@ downloads downloads - 5.7m - 5.7m + 5.8m + 5.8m From 92fc79ee66e10a57a3193d7b33f8a445a470c142 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:49:44 +0000 Subject: [PATCH 077/351] release: v1.4.144-rc.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ca641723c64f..1797c755bb61 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.143-rc.0", + "version": "1.4.144-rc.0", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 6be4e293945a1857c987b24969288047e99f6050 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:21:07 -0700 Subject: [PATCH 078/351] fix(remote): isolate shared control request timeouts (#9016) Co-authored-by: Orca --- ...-runtime-shared-control-connection.test.ts | 55 +++++++++++-------- ...emote-runtime-shared-control-connection.ts | 5 +- ...e-shared-control-keepalive-refresh.test.ts | 15 ++--- .../remote-runtime-shared-control-requests.ts | 18 +----- 4 files changed, 41 insertions(+), 52 deletions(-) diff --git a/src/shared/remote-runtime-shared-control-connection.test.ts b/src/shared/remote-runtime-shared-control-connection.test.ts index b355bda7dfa4..0a2dd4a2e889 100644 --- a/src/shared/remote-runtime-shared-control-connection.test.ts +++ b/src/shared/remote-runtime-shared-control-connection.test.ts @@ -454,32 +454,37 @@ describe('RemoteRuntimeSharedControlConnection', () => { connection.close() }) - it('tears down the socket when a request times out and reconnects active subscriptions', async () => { - const server = await createServer({ silentMethods: ['worktree.hang'] }) + it('keeps unrelated pending requests alive when one request times out', async () => { + const server = await createServer({ + silentMethods: ['worktree.hang'], + delayedMethods: ['worktree.ps'] + }) const connection = new RemoteRuntimeSharedControlConnection(server.pairing) - const onResponse = vi.fn() - const onClose = vi.fn() - await connection.subscribe('runtime.clientEvents.subscribe', null, 1000, { - onResponse, - onError: vi.fn(), - onClose - }) - await vi.waitFor(() => expect(onResponse).toHaveBeenCalled()) + const timedOut = connection.request('worktree.hang', undefined, 250) + void timedOut.catch(() => undefined) + await vi.waitFor(() => + expect(server.requests.map(({ method }) => method)).toContain('worktree.hang') + ) + const survivor = connection.request('worktree.ps', undefined, 1000).then( + (response) => ({ ok: true as const, response }), + (error: unknown) => ({ ok: false as const, error }) + ) + await vi.waitFor(() => + expect(server.requests.map(({ method }) => method)).toContain('worktree.ps') + ) - // Why: mirrors RemoteRuntimeRequestConnection — a request the server never - // answered marks the socket as suspect and must not leave it 'ready'. - await expect(connection.request('worktree.hang', undefined, 50)).rejects.toThrow('Timed out') + await expect(timedOut).rejects.toThrow('Timed out') + // Why: a single slow method is not evidence that a shared socket is dead; + // liveness monitoring owns connection-wide failure detection. + expect(connection.getDiagnostics()).toMatchObject({ state: 'ready', pendingRequestCount: 1 }) - await vi.waitFor(() => expect(server.connectionCount()).toBe(2), { timeout: 5000 }) - await vi.waitFor( - () => - expect( - server.requests.filter((request) => request.method === 'runtime.clientEvents.subscribe') - ).toHaveLength(2), - { timeout: 5000 } - ) - expect(onClose).not.toHaveBeenCalled() + server.flushDelayedResponses() + await expect(survivor).resolves.toMatchObject({ + ok: true, + response: { ok: true, result: { method: 'worktree.ps' } } + }) + expect(server.connectionCount()).toBe(1) connection.close() }) @@ -515,6 +520,7 @@ async function createServer( // Why: half-open simulation — the socket stays open but never answers // protocol pings, like a wedged tunnel that swallows frames silently. disableAutoPong?: boolean + delayedMethods?: string[] silentMethods?: string[] } = {} ): Promise { @@ -613,6 +619,7 @@ function handleRequest( sendUnknownResponseBeforeResponse?: boolean closeAfterStreamingResponse?: () => boolean closeBeforeResponse?: boolean + delayedMethods?: string[] silentMethods?: string[] }, delayedResponses: (() => void)[] @@ -665,6 +672,10 @@ function handleRequest( delayedResponses.push(sendResponse) return } + if (options.delayedMethods?.includes(request.method)) { + delayedResponses.push(sendResponse) + return + } if (options.responseDelayMs !== undefined) { setTimeout(() => { sendResponse() diff --git a/src/shared/remote-runtime-shared-control-connection.ts b/src/shared/remote-runtime-shared-control-connection.ts index 585d3478cbac..88be10796b82 100644 --- a/src/shared/remote-runtime-shared-control-connection.ts +++ b/src/shared/remote-runtime-shared-control-connection.ts @@ -74,10 +74,7 @@ export class RemoteRuntimeSharedControlConnection { timeoutMs, ensureReady: () => this.ensureReadyWithTimeout(timeoutMs), send: (requestId, requestMethod, requestParams) => - this.sendRequest(requestId, requestMethod, requestParams), - // Why: a timed-out request marks the socket as suspect (#7718) — tear - // it down so reconnect+replay runs instead of keeping a zombie socket. - onTimeout: (error) => this.handleSocketClosed(error) + this.sendRequest(requestId, requestMethod, requestParams) }) } diff --git a/src/shared/remote-runtime-shared-control-keepalive-refresh.test.ts b/src/shared/remote-runtime-shared-control-keepalive-refresh.test.ts index 6c1ec5c7db53..2f49bc082d18 100644 --- a/src/shared/remote-runtime-shared-control-keepalive-refresh.test.ts +++ b/src/shared/remote-runtime-shared-control-keepalive-refresh.test.ts @@ -22,10 +22,8 @@ describe('shared control keepalive timeout refresh semantics', () => { function startRequest(options: { refreshTimeoutOnKeepalive?: boolean } = {}): { pendingRequests: Map> promise: Promise - onTimeout: ReturnType } { const pendingRequests = new Map>() - const onTimeout = vi.fn() const promise = requestSharedControl({ pendingRequests, method: 'git.status', @@ -35,16 +33,15 @@ describe('shared control keepalive timeout refresh semantics', () => { // server never answers, modelling a genuinely stuck server-side call. ensureReady: () => Promise.resolve(), send: () => undefined, - onTimeout, refreshTimeoutOnKeepalive: options.refreshTimeoutOnKeepalive }) // Swallow the eventual rejection so unhandled-rejection noise doesn't leak. promise.catch(() => undefined) - return { pendingRequests, promise, onTimeout } + return { pendingRequests, promise } } it('times out a stuck short RPC even while keepalive frames keep arriving', async () => { - const { pendingRequests, promise, onTimeout } = startRequest() + const { pendingRequests, promise } = startRequest() // Periodic keepalives arrive faster than the 1000ms deadline — as they // would while a long-poll subscription streams over the same socket. @@ -55,13 +52,11 @@ describe('shared control keepalive timeout refresh semantics', () => { await vi.advanceTimersByTimeAsync(1) await expect(promise).rejects.toThrow() - // The stuck-request path tears the connection down so reconnect+replay runs. - expect(onTimeout).toHaveBeenCalledTimes(1) expect(pendingRequests.size).toBe(0) }) it('keeps refreshing a long-poll request that opted into keepalive refresh', async () => { - const { pendingRequests, promise, onTimeout } = startRequest({ + const { pendingRequests, promise } = startRequest({ refreshTimeoutOnKeepalive: true }) @@ -72,7 +67,6 @@ describe('shared control keepalive timeout refresh semantics', () => { refreshSharedControlPendingRequestTimeouts(pendingRequests) } - expect(onTimeout).not.toHaveBeenCalled() expect(pendingRequests.size).toBe(1) // It still resolves normally once the server finally answers. @@ -87,12 +81,11 @@ describe('shared control keepalive timeout refresh semantics', () => { }) it('fires the deadline for a short RPC when no keepalives arrive', async () => { - const { pendingRequests, promise, onTimeout } = startRequest() + const { pendingRequests, promise } = startRequest() await vi.advanceTimersByTimeAsync(1001) await expect(promise).rejects.toThrow() - expect(onTimeout).toHaveBeenCalledTimes(1) expect(pendingRequests.size).toBe(0) }) }) diff --git a/src/shared/remote-runtime-shared-control-requests.ts b/src/shared/remote-runtime-shared-control-requests.ts index 91f555af7876..759ac4f8757b 100644 --- a/src/shared/remote-runtime-shared-control-requests.ts +++ b/src/shared/remote-runtime-shared-control-requests.ts @@ -1,9 +1,5 @@ import { randomUUID } from 'node:crypto' -import type { RemoteRuntimeClientError } from './remote-runtime-client-error' -import { - remoteRuntimeTimeoutError, - remoteRuntimeUnavailableError -} from './remote-runtime-request-frames' +import { remoteRuntimeTimeoutError } from './remote-runtime-request-frames' import type { RuntimeRpcResponse } from './runtime-rpc-envelope' import { toRemoteRuntimeClientError } from './remote-runtime-shared-control-protocol' import { rejectSharedControlPendingRequest } from './remote-runtime-shared-control-state' @@ -16,7 +12,6 @@ export function requestSharedControl(args: { timeoutMs: number ensureReady: () => Promise send: (requestId: string, method: string, params: unknown) => void - onTimeout?: (error: RemoteRuntimeClientError) => void // Why: default off — ordinary short RPCs keep an absolute deadline. Only // long-polls routed through this path opt in so keepalives extend them. refreshTimeoutOnKeepalive?: boolean @@ -29,16 +24,9 @@ export function requestSharedControl(args: { return } args.pendingRequests.delete(requestId) + // Why: one stalled method does not prove the shared socket is dead; + // socket liveness owns connection-wide teardown so other RPCs survive. pending.reject(remoteRuntimeTimeoutError()) - // Why: a request the server never answered means the socket is suspect - // (half-open tunnels swallow frames silently); mirror - // RemoteRuntimeRequestConnection and hand the connection a teardown - // error so reconnect+replay runs instead of keeping a zombie socket. - args.onTimeout?.( - remoteRuntimeUnavailableError( - 'Remote Orca runtime did not answer in time; resetting the control connection.' - ) - ) }, args.timeoutMs) args.pendingRequests.set(requestId, { method: args.method, From b8925657a08ebbb603196436f7f2be3fc8eba1a9 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:38:31 -0700 Subject: [PATCH 079/351] fix(terminal): release reattach fit on an unchanged stable grid (#9056) --- .../pane-fit-resize-observer.test.ts | 17 ++ .../pane-manager/pane-fit-resize-observer.ts | 18 +- ...al-warm-reattach-split-live-output.spec.ts | 263 ++++++++++++++++++ 3 files changed, 288 insertions(+), 10 deletions(-) create mode 100644 tests/e2e/terminal-warm-reattach-split-live-output.spec.ts diff --git a/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts b/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts index b4675047d68a..e9c891baabc5 100644 --- a/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts @@ -9,6 +9,7 @@ import { beginTerminalScrollIntentBufferRebuild, endTerminalScrollIntentBufferRebuild } from './terminal-scroll-intent-rebuild' +import { safeFitAndThen } from './pane-tree-ops' type ResizeObserverCallbackLike = ConstructorParameters[0] @@ -178,6 +179,22 @@ describe('attachPaneFitResizeObserver', () => { expect(onSettled).toHaveBeenCalledTimes(1) }) + it('releases a hidden reattach continuation when the stable grid already matches', async () => { + const pane = createPane() + vi.mocked(pane.fitAddon.proposeDimensions).mockReturnValue(undefined) + const continuation = vi.fn() + const pending = safeFitAndThen(pane, 'reattach-pty-resize', continuation) + + expect(continuation).not.toHaveBeenCalled() + vi.mocked(pane.fitAddon.proposeDimensions).mockReturnValue({ cols: 79, rows: 24 }) + requestStablePaneFit(pane) + flushAnimationFrames() + + await expect(pending.completion).resolves.toBe(true) + expect(continuation).toHaveBeenCalledTimes(1) + expect(pane.fitAddon.fit).not.toHaveBeenCalled() + }) + it('does not notify settled callbacks until a replay-deferred fit completes', async () => { const onSettled = vi.fn() const pane = createPane() diff --git a/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.ts b/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.ts index 16d1a0a5b156..53d2f6b6f113 100644 --- a/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.ts +++ b/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.ts @@ -74,13 +74,11 @@ function flushStableFitCallbacks(pane: StableFitPane): void { } } -function finishStableFit(pane: StableFitPane, shouldFit: boolean): void { +function finishStableFit(pane: StableFitPane): void { setPendingObservedFitRafId(pane, null) - if (shouldFit) { - safeFitAndThen(pane, 'stable-pane-fit', () => flushStableFitCallbacks(pane)) - return - } - flushStableFitCallbacks(pane) + // Why: an equal grid still proves a restored pane is measurable, so it must + // release reattach continuations that were parked while the tab was hidden. + safeFitAndThen(pane, 'stable-pane-fit', () => flushStableFitCallbacks(pane)) } export function requestStablePaneFit(pane: StableFitPane, onSettled?: () => void): void { @@ -113,23 +111,23 @@ export function requestStablePaneFit(pane: StableFitPane, onSettled?: () => void frameCount += 1 if (!next) { - finishStableFit(pane, true) + finishStableFit(pane) return } if (terminalDimensionsEqual(pane, next)) { - finishStableFit(pane, false) + finishStableFit(pane) return } if (dimensionsEqual(previous, next)) { - finishStableFit(pane, true) + finishStableFit(pane) return } previous = next if (frameCount >= MAX_STABILITY_FRAMES) { - finishStableFit(pane, true) + finishStableFit(pane) return } diff --git a/tests/e2e/terminal-warm-reattach-split-live-output.spec.ts b/tests/e2e/terminal-warm-reattach-split-live-output.spec.ts new file mode 100644 index 000000000000..2d4a85ec3b7c --- /dev/null +++ b/tests/e2e/terminal-warm-reattach-split-live-output.spec.ts @@ -0,0 +1,263 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { TEST_REPO_PATH_FILE } from './global-setup' +import { + execInTerminal, + splitActiveTerminalPane, + waitForActiveTerminalManager, + waitForPaneCount, + waitForPaneIdentitySnapshot +} from './helpers/terminal' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' +import { PTY_SESSION_ID_SEPARATOR } from '../../src/shared/pty-session-id-format' + +type PaneProbe = { + content: string + cols: number + rows: number + proposed: { cols: number; rows: number } | null + rect: { width: number; height: number } +} + +function frameNumber(content: string): number { + const matches = [...content.matchAll(/REATTACH_FRAME_(\d+)/g)] + return Number(matches.at(-1)?.[1] ?? 0) +} + +function heartbeatNumber(path: string): number { + return Number(readFileSync(path, 'utf8').trim()) +} + +function writeStreamingTui(scriptPath: string): void { + mkdirSync(path.dirname(scriptPath), { recursive: true }) + writeFileSync( + scriptPath, + [ + "import { writeFileSync } from 'node:fs'", + 'const heartbeatPath = process.argv[2]', + 'let frame = 0', + "process.stdout.write('\\x1b[?1049h\\x1b[?25l')", + 'setInterval(() => {', + ' frame += 1', + ' writeFileSync(heartbeatPath, String(frame))', + ' const body = [', + " '╭────────────────────────────────────────────────────╮',", + " `│ Orca warm reattach frame ${String(frame).padStart(6, '0')} 🟢 │`,", + " '├────────────────────────────────────────────────────┤',", + ' `│ REATTACH_FRAME_${frame} live daemon output │`,', + " '╰────────────────────────────────────────────────────╯'", + " ].join('\\r\\n')", + ' process.stdout.write(`\\x1b[?2026h\\x1b[2J\\x1b[H${body}\\x1b[?2026l`)', + '}, 50)', + '' + ].join('\n') + ) +} + +async function setFullscreen(app: ElectronApplication, page: Page): Promise { + await app.evaluate(({ BrowserWindow }) => { + const window = BrowserWindow.getAllWindows()[0] + if (!window) { + throw new Error('No Electron window') + } + window.show() + window.setFullScreen(true) + }) + await expect + .poll( + async () => ({ + fullscreen: await app.evaluate( + ({ BrowserWindow }) => BrowserWindow.getAllWindows()[0]?.isFullScreen() ?? false + ), + size: await page.evaluate(() => ({ width: innerWidth, height: innerHeight })) + }), + { timeout: 15_000 } + ) + .toMatchObject({ + fullscreen: true, + size: { width: expect.any(Number), height: expect.any(Number) } + }) + await page.waitForTimeout(1_200) +} + +async function createActiveDecoyTab(page: Page, worktreeId: string): Promise { + await page.evaluate((worktreeId) => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + const state = store.getState() + const tab = state.createTab(worktreeId, undefined, undefined, { activate: true }) + state.setActiveTab(tab.id) + state.setActiveTabType('terminal') + }, worktreeId) + await waitForActiveTerminalManager(page, 30_000) + await waitForPaneCount(page, 1, 30_000) +} + +async function findRestoredTabForPty(page: Page, ptyId: string): Promise { + return page.evaluate((ptyId) => { + const layouts = window.__store?.getState().terminalLayoutsByTabId ?? {} + for (const [tabId, layout] of Object.entries(layouts)) { + if (Object.values(layout.ptyIdsByLeafId ?? {}).includes(ptyId)) { + return tabId + } + } + return null + }, ptyId) +} + +async function activateTabWithoutFocus(page: Page, tabId: string): Promise { + await page.evaluate((tabId) => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + store.getState().setActiveTabType('terminal') + store.getState().setActiveTab(tabId) + }, tabId) +} + +async function probePane(page: Page, tabId: string, ptyId: string): Promise { + return page.evaluate( + ({ tabId, ptyId }) => { + const pane = window.__paneManagers + ?.get(tabId) + ?.getPanes?.() + .find((candidate) => candidate.container.dataset.ptyId === ptyId) + if (!pane) { + return null + } + let proposed: { cols: number; rows: number } | null = null + try { + proposed = pane.fitAddon.proposeDimensions() ?? null + } catch { + proposed = null + } + const rect = pane.container.getBoundingClientRect() + return { + content: pane.serializeAddon.serialize(), + cols: pane.terminal.cols, + rows: pane.terminal.rows, + proposed, + rect: { width: rect.width, height: rect.height } + } + }, + { tabId, ptyId } + ) +} + +test.describe.configure({ mode: 'serial' }) + +test('restored hidden split drains live alternate-screen output without a click @headful', async (// oxlint-disable-next-line no-empty-pattern -- This restart test owns both app launches. +{}, testInfo) => { + test.setTimeout(300_000) + test.skip(process.platform === 'win32', 'The streaming fixture uses a POSIX shell command') + const repoPath = readFileSync(TEST_REPO_PATH_FILE, 'utf8').trim() + test.skip(!repoPath || !existsSync(repoPath), 'Global setup did not produce a seeded test repo') + + const scriptPath = testInfo.outputPath('streaming-tui.mjs') + const heartbeatPath = testInfo.outputPath('streaming-tui-heartbeat.txt') + writeStreamingTui(scriptPath) + + const session = createRestartSession(testInfo) + let firstApp: ElectronApplication | null = null + let secondApp: ElectronApplication | null = null + try { + const first = await session.launch() + firstApp = first.app + await setFullscreen(first.app, first.page) + const worktreeId = await attachRepoAndOpenTerminal(first.page, repoPath) + await waitForSessionReady(first.page) + await waitForActiveWorktree(first.page) + await ensureTerminalVisible(first.page) + await waitForActiveTerminalManager(first.page, 30_000) + await waitForPaneCount(first.page, 1, 30_000) + + await splitActiveTerminalPane(first.page, 'horizontal') + await waitForPaneCount(first.page, 2, 30_000) + const split = await waitForPaneIdentitySnapshot(first.page, 2) + const streamingPane = split.panes.find((pane) => pane.leafId === split.activeLeafId) + if (!streamingPane?.ptyId) { + throw new Error('Split did not expose its active PTY') + } + expect(streamingPane.ptyId).toContain(PTY_SESSION_ID_SEPARATOR) + await execInTerminal( + first.page, + streamingPane.ptyId, + `node ${JSON.stringify(scriptPath)} ${JSON.stringify(heartbeatPath)}` + ) + await expect + .poll( + async () => + frameNumber( + (await probePane(first.page, split.tabId, streamingPane.ptyId))?.content ?? '' + ), + { + timeout: 20_000, + message: 'Streaming TUI did not start in the split pane' + } + ) + .toBeGreaterThan(5) + + // Hide the split tab before quit so relaunch restores the same cold-view boundary as the field incident. + await createActiveDecoyTab(first.page, worktreeId) + await first.page.waitForTimeout(1_000) + const beforeCloseHeartbeat = heartbeatNumber(heartbeatPath) + await session.close(firstApp) + firstApp = null + await new Promise((resolve) => setTimeout(resolve, 750)) + expect(heartbeatNumber(heartbeatPath)).toBeGreaterThan(beforeCloseHeartbeat) + + const second = await session.launch() + secondApp = second.app + await waitForSessionReady(second.page) + await waitForActiveWorktree(second.page) + await ensureTerminalVisible(second.page) + + let restoredTabId: string | null = null + await expect + .poll( + async () => { + restoredTabId = await findRestoredTabForPty(second.page, streamingPane.ptyId!) + return restoredTabId + }, + { timeout: 20_000, message: 'Persisted split PTY was not restored into a tab layout' } + ) + .not.toBeNull() + await activateTabWithoutFocus(second.page, restoredTabId!) + await waitForActiveTerminalManager(second.page, 30_000) + await waitForPaneCount(second.page, 2, 30_000) + + const initial = await probePane(second.page, restoredTabId!, streamingPane.ptyId) + expect(initial, 'Streaming pane did not remount after tab activation').not.toBeNull() + const initialFrame = frameNumber(initial!.content) + const initialHeartbeat = heartbeatNumber(heartbeatPath) + await second.page.waitForTimeout(2_000) + const later = await probePane(second.page, restoredTabId!, streamingPane.ptyId) + const laterHeartbeat = heartbeatNumber(heartbeatPath) + + expect( + laterHeartbeat, + 'Fixture process stopped instead of exercising live output' + ).toBeGreaterThan(initialHeartbeat + 20) + expect(later?.rect.width ?? 0).toBeGreaterThan(0) + expect(later?.rect.height ?? 0).toBeGreaterThan(0) + expect(later?.proposed).toEqual({ cols: later?.cols, rows: later?.rows }) + expect( + frameNumber(later?.content ?? ''), + `Renderer frame did not advance while daemon heartbeat moved ${initialHeartbeat} -> ${laterHeartbeat}; initial=${JSON.stringify(initial)} later=${JSON.stringify(later)}` + ).toBeGreaterThan(initialFrame + 20) + } finally { + if (secondApp) { + await session.close(secondApp) + } + if (firstApp) { + await session.close(firstApp) + } + await session.dispose() + } +}) From 1536171fdb534f3b5f69a2819b0d28a558b57bbd Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:06:21 -0700 Subject: [PATCH 080/351] Add a native macOS menu bar status item with activity indicator (#9042) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add native macOS menu bar status item with settings toggle Extend the Windows-only system tray into a shared status-item module so macOS gets a template menu bar icon (Open/Settings/Check for Updates/Quit), a theme-aware attention dot, and a "Show Menu Bar Icon" appearance setting. Also fixes a startup race where a tray "Settings…" click could fire before the renderer's ui:openSettings listener attached, by queuing a one-shot pending-open-settings intent the renderer consumes on mount. * Fix Retina blur, race conditions, and menu-label duplication in tray Set - Rebuild the tray attention icon's @2x representation since toBitmap only read 1x pixels, blurring the glyph on Retina displays - Fix premultiplied-alpha math so light-glyph tinting uses per-pixel alpha instead of a flat 0xff, keeping antialiased edges valid - Always push ui:openSettings and leave a longer-lived pending flag, since there was no reliable signal that a renderer's listener was attached - Preserve tray attention state across macOS menu-bar hide/show toggles instead of resetting it on tray destroy - Route macOS tray creation through syncMacMenuBarIcon so startup and the live toggle share one visibility policy - Reuse app-menu translation keys for tray Settings/Check for Updates and drop the now-duplicate tray-scoped locale strings * Make menu bar icon default on and platform-independent in settings Previously showMenuBarIcon was hardcoded to darwin-only in both the default settings and the sanitize/load paths, so a profile written on macOS lost its opt-out when touched from another OS. Store the raw boolean everywhere and let darwin-specific consumers decide whether to act on it, so the value round-trips unchanged across platforms. * Fix Settings menu click being silently dropped after a slow cold rendere Replace the 60s pendingOpenSettings TTL with an untimed intent, since a cold renderer start can outrun any fixed timeout and cause the flag to expire before the click is consumed. webContents-id scoping plus consume-on-read still prevent the intent from leaking to an unrelated renderer. Adds a test covering the queued-before-mount pull path. --- resources/tray/orca-menu-barTemplate.png | Bin 0 -> 324 bytes resources/tray/orca-menu-barTemplate@2x.png | Bin 0 -> 625 bytes src/main/index.ts | 140 +++++-- src/main/ipc/notifications.ts | 4 +- src/main/persistence.test.ts | 67 ++++ src/main/persistence.ts | 6 + src/main/tray/system-tray.test.ts | 365 ++++++++++++++---- src/main/tray/system-tray.ts | 199 +++++++++- src/main/tray/tray-attention-icon.test.ts | 58 ++- src/main/tray/tray-attention-icon.ts | 30 ++ src/preload/api-types.ts | 2 + src/preload/index.ts | 2 + .../settings/AppearanceInterfaceSection.tsx | 29 +- .../settings/AppearancePane.test.tsx | 22 ++ .../components/settings/AppearancePane.tsx | 6 +- .../components/settings/appearance-search.ts | 76 +--- .../appearance-system-presence-search.ts | 103 +++++ .../settings/terminal-search.test.ts | 9 + src/renderer/src/hooks/useIpcEvents.test.ts | 138 +++++++ src/renderer/src/hooks/useIpcEvents.ts | 12 + .../hooks/useSettingsNavigationMetadata.ts | 3 +- src/renderer/src/i18n/locales/en.json | 7 +- src/renderer/src/i18n/locales/es.json | 7 +- src/renderer/src/i18n/locales/ja.json | 7 +- src/renderer/src/i18n/locales/ko.json | 7 +- src/renderer/src/i18n/locales/zh.json | 7 +- src/renderer/src/web/web-preload-api.ts | 3 + src/shared/constants.test.ts | 4 + src/shared/constants.ts | 3 + src/shared/types.ts | 3 + 30 files changed, 1110 insertions(+), 209 deletions(-) create mode 100644 resources/tray/orca-menu-barTemplate.png create mode 100644 resources/tray/orca-menu-barTemplate@2x.png create mode 100644 src/renderer/src/components/settings/appearance-system-presence-search.ts diff --git a/resources/tray/orca-menu-barTemplate.png b/resources/tray/orca-menu-barTemplate.png new file mode 100644 index 0000000000000000000000000000000000000000..b98b90f2fda93ef4ad2f865d70d20b5849419e21 GIT binary patch literal 324 zcmV-K0lWT*P)3ZN00006VoOIv0RI60 z0RN!9r;`8x0QpHoK~y-6t<%3N$3Yy&@rSRz<=28tVl-heFj!b*wvY@;DT~F-uKWXv zkwr;aWHd>#C>f|?cl zQ6&T1hX-`U`W#1SLnk`&^0J)NgE+=JzLK$97CL~5SkL0M4tS55oV1ImM)*epyuf_) zva}?0#a#j%&xO_0E*yU$LT~-yp;FDE2ZzGl4mY~78_hq=w_**aLQB11qX^c7ejH#F z&$z-h-l8`p^yfNmu!*7Q{|es1SWNbbr^J7ZuuGg_8+$k@N?b-&?EELNrV-%_H>7V) W!7**T>O8Ul0000-DGQ@4EF>F=W?>^EB*_MaBt?=E#xxC@xh&puyXW3HckU_Yo;!Z^)cZX5Jmd<%2&6)QBc*?f>%oCfs;zGZ!xz)T#4!uTGv1<_EHJBrye)$#3tqE*IHUB zY<2Nmus4>g2zw(#MzB+8-tl^SF1+EpupBd^xy@LEUg6K7P;p&HG_No6N3bkQ*dqxy zt!B06J&Wai62*>Y3A-mH=w$W**Kt571Hvj7gc>gH%s;Cc^KiPxyb=74#-qZw*cjg6 zg>d?+)DEEs?~h$Or8S6ajfP08cHtPgq|qR0)hpEVf1`cs^hN#`QFhDXbocVT00000 LNkvXXu0mjfuaY6N literal 0 HcmV?d00001 diff --git a/src/main/index.ts b/src/main/index.ts index 21c4a9ab51ef..ababeb6e24ef 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -5,7 +5,7 @@ import { existsSync, statSync } from 'node:fs' import { isAbsolute, join } from 'node:path' import os from 'node:os' -import { app, BrowserWindow, dialog, ipcMain, nativeTheme } from 'electron' +import { app, BrowserWindow, dialog, ipcMain, nativeTheme, type Tray } from 'electron' import { electronApp, is } from '@electron-toolkit/utils' import * as QRCode from 'qrcode' import { @@ -54,6 +54,7 @@ import { rebuildAppMenu } from './menu/register-app-menu' import { checkForUpdatesFromMenu, isQuittingForUpdate } from './updater' +import type { UpdateCheckOptions } from '../shared/types' import { recordUpdaterLifecycle } from './updater-lifecycle-diagnostics' import { configureElectronNetworkCompatibility, @@ -118,7 +119,13 @@ import { ensureAutoUpdaterConfigured } from './window/attach-main-window-services' import { createMainWindow, loadMainWindow } from './window/createMainWindow' -import { createSystemTray, destroySystemTray, setTrayAttention } from './tray/system-tray' +import { + createSystemTray, + destroySystemTray, + setMacMenuBarIconVisible, + setTrayAttention, + type SystemTrayOptions +} from './tray/system-tray' import { focusExistingMainWindow } from './window/focus-existing-window' import { notifyMainWindowBecameVisible } from './window/main-window-visibility' import { CodexAccountService } from './codex-accounts/service' @@ -240,6 +247,9 @@ let keybindings: KeybindingService | null = null // sweep spare live sessions across that one reload (#5787). const expectedRendererReload = createWebContentsTimedFlag() const recoveryReloadInFlight = createWebContentsTimedFlag() +// Why: a tray/menu-bar "Settings…" click can precede the renderer attaching +// its ui:openSettings listener; the renderer pulls this one-shot on mount. +const pendingOpenSettings = createWebContentsTimedFlag() let firstWindowStartupServicesReady: Promise = Promise.resolve() let managedWslCliReconciliationReady: Promise = Promise.resolve() let managedWslCliStartupBarrierReady: Promise = Promise.resolve() @@ -667,6 +677,12 @@ ipcMain.handle('app:awaitFirstWindowStartupServices', async () => { await Promise.all([firstWindowStartupServicesReady, managedWslCliStartupBarrierReady]) }) +// Why: the renderer pulls this once its ui:openSettings listener is attached so +// a tray/menu-bar Settings request queued before mount is not lost to a race. +ipcMain.handle('ui:consumePendingOpenSettings', (event) => + pendingOpenSettings.matches(event.sender.id, { consume: true }) +) + ipcMain.handle( 'app:startupDiagnostic', (_event, event: string, details?: Record) => { @@ -789,6 +805,69 @@ function showMainWindowFromTray(): void { } } +function openSettingsFromSystemMenu(): void { + showMainWindowFromTray() + const targetWindow = mainWindow && !mainWindow.isDestroyed() ? mainWindow : null + if (!targetWindow) { + return + } + recordCrashBreadcrumb('settings_opened') + + // Why: no main-side signal proves the renderer listener is attached, so push + // and leave a one-shot intent — a mounted renderer acts on the push, an + // unmounted one pulls the intent at mount; only one fires per renderer life. + targetWindow.webContents.send('ui:openSettings') + // Why: leave an untimed intent — any TTL can be outrun by a slow cold start and + // would silently drop the Settings click. webContents-id scoping plus consume-on- + // read still prevents the intent from leaking to a later, unrelated renderer. + pendingOpenSettings.mark(targetWindow.webContents.id, Number.POSITIVE_INFINITY) +} + +function quitFromSystemTray(): void { + if (mainWindow && !mainWindow.isDestroyed()) { + // Why: a real quit can still surface renderer save/discard prompts; the + // window must be visible if a hidden session vetoes shutdown. + showMainWindowFromTray() + } + // Why: set the quit latch before app.quit() so the window 'close' handler + // proceeds to teardown instead of re-hiding to the Windows tray. + isQuitting = true + app.quit() +} + +// Why: a manual check must run against a configured updater; the menu and +// tray are both clickable before anything else configures it. +function runUserInitiatedUpdateCheck(options?: UpdateCheckOptions): void { + ensureAutoUpdaterConfigured() + checkForUpdatesFromMenu(options) +} + +function getSystemTrayOptions(): SystemTrayOptions | null { + if (!store) { + return null + } + return { + appIcon: store.getSettings().appIcon, + onOpen: showMainWindowFromTray, + onOpenSettings: openSettingsFromSystemMenu, + onCheckForUpdates: () => { + // Why: updater status renders in the main window; with every window + // closed a bare check would complete invisibly. + showMainWindowFromTray() + runUserInitiatedUpdateCheck() + }, + onQuit: quitFromSystemTray + } +} + +function syncMacMenuBarIcon(showMenuBarIcon: boolean): Tray | null { + if (process.platform !== 'darwin' || isServeMode) { + return null + } + const options = getSystemTrayOptions() + return options ? setMacMenuBarIconVisible(showMenuBarIcon, options) : null +} + function openMainWindow(): BrowserWindow { logStartupMilestone('open-main-window-start') if (!store) { @@ -900,38 +979,26 @@ function openMainWindow(): BrowserWindow { }) recordCrashBreadcrumb('main_window_created') logStartupMilestone('window-created') - // Why: new Tray() is a synchronous Shell_NotifyIcon call that can block for - // seconds while explorer.exe's notification area is busy (part of issue - // #7225's pre-paint stall), so create it after first paint. The timer - // fallback covers windows revealed without ready-to-show ever firing - // (createMainWindow's 10s reveal fallback) — those can still be - // hidden to the tray on close, so the icon must exist by then. + // Why: Windows Tray construction can synchronously block on Shell_NotifyIcon, + // so both desktop status-item platforms defer creation to after first paint. let trayCreated = false const createSystemTrayDeferred = (): void => { if (trayCreated || window.isDestroyed() || isQuitting || !store) { return } trayCreated = true - // Why: Windows-only system tray. createSystemTray is idempotent and a - // no-op off win32, so calling it on each window open keeps exactly one - // live icon. - createSystemTray({ - appIcon: store.getSettings().appIcon, - onOpen: showMainWindowFromTray, - onQuit: () => { - if (mainWindow && !mainWindow.isDestroyed()) { - // Why: a real quit can still surface renderer save/discard prompts; - // the window must be visible if a hidden-to-tray session vetoes - // shutdown. - showMainWindowFromTray() - } - // Why: set the quit latch before app.quit() so the window 'close' - // handler proceeds to teardown instead of re-hiding to the tray. - isQuitting = true - app.quit() + if (process.platform === 'darwin') { + // Why: route through syncMacMenuBarIcon so startup and the live toggle + // share one serve-mode/visibility policy. + if (syncMacMenuBarIcon(store.getSettings().showMenuBarIcon !== false)) { + logStartupMilestone('tray-created') } - }) - logStartupMilestone('tray-created') + return + } + const options = getSystemTrayOptions() + if (options && createSystemTray(options)) { + logStartupMilestone('tray-created') + } } window.once('ready-to-show', () => { logStartupMilestone('ready-to-show') @@ -1681,6 +1748,13 @@ app.whenReady().then(async () => { const activeOrcaProfile = ensureActiveOrcaProfile() store = new Store({ dataFile: activeOrcaProfile.dataFile }) logStartupMilestone('store-loaded') + store.onSettingsChanged((updates, settings) => { + if ('showMenuBarIcon' in updates) { + // Why: Store is the mutation authority for renderer, RPC, and future + // settings writes, so every macOS toggle updates the native item live. + syncMacMenuBarIcon(settings.showMenuBarIcon !== false) + } + }) // Why: must run before ClaudeRuntimeAuthService's constructor sync — a Claude // CLI that survived the restart inside the daemon still holds the current // single-use refresh token, and an unguarded early refresh would rotate it @@ -2002,22 +2076,14 @@ app.whenReady().then(async () => { logStartupMilestone('i18n-ready') registerAppMenu({ - onCheckForUpdates: (options) => { - // Why: the menu is clickable before first paint; a manual check must - // run against a configured updater (see attach-main-window-services). - ensureAutoUpdaterConfigured() - return checkForUpdatesFromMenu(options) - }, + onCheckForUpdates: (options) => runUserInitiatedUpdateCheck(options), onBeforeReload: ({ ignoreCache, webContentsId }) => { if (mainWindow?.webContents.id === webContentsId) { markExpectedRendererReload(webContentsId) } recordCrashBreadcrumb('manual_reload_requested', { ignoreCache }) }, - onOpenSettings: () => { - recordCrashBreadcrumb('settings_opened') - mainWindow?.webContents.send('ui:openSettings') - }, + onOpenSettings: openSettingsFromSystemMenu, onOpenSetupGuide: (targetWindow) => { recordCrashBreadcrumb('setup_guide_opened') const targetBrowserWindow = targetWindow instanceof BrowserWindow ? targetWindow : null diff --git a/src/main/ipc/notifications.ts b/src/main/ipc/notifications.ts index 4eb8938f2b4c..9d6c825498d3 100644 --- a/src/main/ipc/notifications.ts +++ b/src/main/ipc/notifications.ts @@ -436,8 +436,8 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime // hold back the dot. It rides the notification dispatch, so it follows the // renderer's per-source decision to notify: bells always reach here, while // an agent completion is suppressed upstream when its notification is - // disabled. Tray exists only on Windows, so setTrayAttention no-ops - // elsewhere. + // disabled. The status item exists on Windows and macOS, so + // setTrayAttention lights its attention dot there and no-ops on Linux. if (args.source === 'agent-task-complete' || args.source === 'terminal-bell') { const activeWindow = BrowserWindow.getAllWindows().find((win) => !win.isDestroyed()) ?? null if (!isMainWindowVisible(activeWindow)) { diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 57153e31fef0..acde5dd02c39 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -21,6 +21,7 @@ import type { ProjectGroup, ProjectHostSetup, Repo, + GlobalSettings, TerminalPaneLayoutNode, TerminalTab, WorktreeLineage, @@ -701,6 +702,72 @@ describe('Store', () => { expect(store.getSettings().minimizeToTrayOnClose).toBe(false) }) + it('defaults the menu bar icon on regardless of platform', async () => { + await withPlatform('darwin', async () => { + const store = await createStore() + expect(store.getSettings().showMenuBarIcon).toBe(true) + }) + + await withPlatform('linux', async () => { + const store = await createStore() + expect(store.getSettings().showMenuBarIcon).toBe(true) + }) + }) + + it('enables the menu bar icon when an existing macOS profile has no stored value', async () => { + await withPlatform('darwin', async () => { + const persisted = getDefaultPersistedState(testState.dir) + delete (persisted.settings as Partial).showMenuBarIcon + writeDataFile(persisted) + + const store = await createStore() + + expect(store.getSettings().showMenuBarIcon).toBe(true) + }) + }) + + it('persists an explicit macOS menu bar opt-out', async () => { + await withPlatform('darwin', async () => { + const store = await createStore() + store.updateSettings({ showMenuBarIcon: false }) + store.flush() + + expect((readDataFile() as PersistedState).settings.showMenuBarIcon).toBe(false) + expect((await createStore()).getSettings().showMenuBarIcon).toBe(false) + }) + }) + + it('normalizes menu bar icon writes to a strict boolean', async () => { + await withPlatform('darwin', async () => { + const store = await createStore() + store.updateSettings({ showMenuBarIcon: 'true' as unknown as boolean }) + expect(store.getSettings().showMenuBarIcon).toBe(false) + store.updateSettings({ showMenuBarIcon: true }) + expect(store.getSettings().showMenuBarIcon).toBe(true) + }) + }) + + it('round-trips a macOS menu bar opt-out through a non-mac host unchanged', async () => { + await withPlatform('darwin', async () => { + const store = await createStore() + store.updateSettings({ showMenuBarIcon: false }) + store.flush() + }) + + // Why: a profile opened on another OS must not rewrite the mac preference + // on its next flush; only the darwin consumers act on the value. + await withPlatform('win32', async () => { + const store = await createStore() + store.updateSettings({ minimizeToTrayOnClose: true }) + store.flush() + expect((readDataFile() as PersistedState).settings.showMenuBarIcon).toBe(false) + }) + + await withPlatform('darwin', async () => { + expect((await createStore()).getSettings().showMenuBarIcon).toBe(false) + }) + }) + it('defaults trayMinimizeNoticeShown to false and persists it strictly', async () => { const store = await createStore() expect(store.getUI().trayMinimizeNoticeShown).toBe(false) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 0a2d7821f91c..a237e941150c 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -3200,6 +3200,9 @@ export class Store { // Why: persisted settings can be user-edited or written by older // builds; keep tray-minimize false unless the stored value is true. minimizeToTrayOnClose: parsed.settings?.minimizeToTrayOnClose === true, + // Why: missing means default-on, and the value must round-trip + // unchanged on non-mac hosts; the darwin consumers gate the effect. + showMenuBarIcon: parsed.settings?.showMenuBarIcon !== false, uiLanguage: normalizeUiLanguage(parsed.settings?.uiLanguage), defaultTaskSource: taskProviderSettings.defaultTaskSource, visibleTaskProviders: taskProviderSettings.visibleTaskProviders, @@ -5265,6 +5268,9 @@ export class Store { if ('minimizeToTrayOnClose' in updates) { sanitizedUpdates.minimizeToTrayOnClose = updates.minimizeToTrayOnClose === true } + if ('showMenuBarIcon' in updates) { + sanitizedUpdates.showMenuBarIcon = updates.showMenuBarIcon === true + } if ('disabledTuiAgents' in updates) { sanitizedUpdates.disabledTuiAgents = normalizeDisabledTuiAgents(updates.disabledTuiAgents) } diff --git a/src/main/tray/system-tray.test.ts b/src/main/tray/system-tray.test.ts index 9f5ad72caade..71c94f84e74a 100644 --- a/src/main/tray/system-tray.test.ts +++ b/src/main/tray/system-tray.test.ts @@ -2,19 +2,60 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as SystemTrayModule from './system-tray' const { - trayInstances, - menuFromTemplateMock, - createAppIconImageMock, + attentionImage, + baseMacImage, composeAttentionMock, - resizedImage + createAppIconImageMock, + createFromPathMock, + menuFromTemplateMock, + nativeThemeMock, + resizedImage, + retinaMacImage, + themeState, + tintedMacImage, + tintTemplateMock, + trayInstances } = vi.hoisted(() => { - const resizedImage = { resized: true } + const makeImage = (name: string) => ({ + name, + addRepresentation: vi.fn(), + getScaleFactors: vi.fn(() => [1, 2]), + getSize: vi.fn(() => ({ width: 16, height: 16 })), + setTemplateImage: vi.fn(), + toBitmap: vi.fn(() => Buffer.alloc(16 * 16 * 4)), + toDataURL: vi.fn(() => `data:image/png;base64,${name}`) + }) + const resizedImage = makeImage('windows') + const baseMacImage = makeImage('mac-base') + const retinaMacImage = makeImage('mac-retina') + const tintedMacImage = makeImage('mac-tinted') + const attentionImage = makeImage('attention') + const themeState = { updatedListener: null as (() => void) | null } + const nativeThemeMock = { + shouldUseDarkColors: false, + on: vi.fn((_event: string, listener: () => void) => { + themeState.updatedListener = listener + }), + removeListener: vi.fn((_event: string, listener: () => void) => { + if (themeState.updatedListener === listener) { + themeState.updatedListener = null + } + }) + } return { - trayInstances: [] as FakeTray[], - menuFromTemplateMock: vi.fn((template: unknown) => ({ template })), + attentionImage, + baseMacImage, + composeAttentionMock: vi.fn(() => attentionImage), createAppIconImageMock: vi.fn(), - composeAttentionMock: vi.fn((image: unknown) => ({ dotted: image })), - resizedImage + createFromPathMock: vi.fn(), + menuFromTemplateMock: vi.fn((template: unknown) => ({ template })), + nativeThemeMock, + resizedImage, + retinaMacImage, + themeState, + tintedMacImage, + tintTemplateMock: vi.fn(() => tintedMacImage), + trayInstances: [] as FakeTray[] } }) @@ -32,7 +73,17 @@ class FakeTray { vi.mock('electron', () => ({ Tray: FakeTray, - Menu: { buildFromTemplate: menuFromTemplateMock } + Menu: { buildFromTemplate: menuFromTemplateMock }, + nativeImage: { createFromPath: createFromPathMock }, + nativeTheme: nativeThemeMock +})) + +vi.mock('../../../resources/tray/orca-menu-barTemplate.png?asset&asarUnpack', () => ({ + default: '/assets/orca-menu-barTemplate.png' +})) + +vi.mock('../../../resources/tray/orca-menu-barTemplate@2x.png?asset&asarUnpack', () => ({ + default: '/assets/orca-menu-barTemplate@2x.png' })) vi.mock('../app-icon', () => ({ @@ -40,10 +91,12 @@ vi.mock('../app-icon', () => ({ })) vi.mock('./tray-attention-icon', () => ({ - composeTrayAttentionIcon: composeAttentionMock + composeTrayAttentionIcon: composeAttentionMock, + tintTrayTemplateForAttention: tintTemplateMock })) type TrayModule = typeof SystemTrayModule +type MenuItem = { label?: string; type?: string; click?: () => void } const originalPlatform = process.platform @@ -56,7 +109,15 @@ async function loadModule(): Promise { return import('./system-tray') } -type MenuItem = { label?: string; type?: string; click?: () => void } +function createOptions() { + return { + appIcon: 'classic', + onOpen: vi.fn(), + onOpenSettings: vi.fn(), + onCheckForUpdates: vi.fn(), + onQuit: vi.fn() + } +} function builtMenuItems(): MenuItem[] { return menuFromTemplateMock.mock.calls.at(-1)?.[0] as MenuItem[] @@ -66,139 +127,289 @@ beforeEach(() => { trayInstances.length = 0 menuFromTemplateMock.mockClear() composeAttentionMock.mockClear() + tintTemplateMock.mockClear() createAppIconImageMock.mockReset() createAppIconImageMock.mockReturnValue({ resize: vi.fn(() => resizedImage) }) + createFromPathMock.mockReset() + createFromPathMock.mockImplementation((path: string) => + path.includes('@2x') ? retinaMacImage : baseMacImage + ) + for (const image of [baseMacImage, retinaMacImage, tintedMacImage, attentionImage]) { + image.addRepresentation.mockClear() + image.getScaleFactors.mockReset().mockReturnValue([1, 2]) + image.getSize.mockReset().mockReturnValue({ width: 16, height: 16 }) + image.setTemplateImage.mockClear() + image.toBitmap.mockClear() + image.toDataURL.mockClear().mockReturnValue(`data:image/png;base64,${image.name}`) + } + nativeThemeMock.shouldUseDarkColors = false + nativeThemeMock.on.mockClear() + nativeThemeMock.removeListener.mockClear() + themeState.updatedListener = null }) afterEach(() => { setPlatform(originalPlatform) + vi.restoreAllMocks() }) describe('createSystemTray', () => { - it('creates a tray with an Orca tooltip and Open/Quit menu on win32', async () => { + it('keeps the Windows icon, Open/Quit menu, and left-click behavior', async () => { setPlatform('win32') const { createSystemTray } = await loadModule() - const onOpen = vi.fn() - const onQuit = vi.fn() + const options = createOptions() - const tray = createSystemTray({ appIcon: 'classic', onOpen, onQuit }) + createSystemTray(options) - expect(tray).not.toBeNull() expect(trayInstances).toHaveLength(1) expect(trayInstances[0].image).toBe(resizedImage) expect(trayInstances[0].setToolTip).toHaveBeenCalledWith('Orca') - const items = builtMenuItems() - expect(items.map((i) => i.label)).toEqual(['Open Orca', undefined, 'Quit']) - expect(items[1].type).toBe('separator') + expect(builtMenuItems().map((item) => item.label)).toEqual(['Open Orca', undefined, 'Quit']) + const clickHandler = trayInstances[0].on.mock.calls.find((call) => call[0] === 'click')?.[1] + expect(clickHandler).toBeTypeOf('function') + + builtMenuItems()[0].click?.() + ;(clickHandler as () => void)() + builtMenuItems()[2].click?.() + expect(options.onOpen).toHaveBeenCalledTimes(2) + expect(options.onQuit).toHaveBeenCalledOnce() }) - it('wires Open Orca, the tray click, and Quit to their callbacks', async () => { - setPlatform('win32') + it('creates a macOS template status item with the full native menu', async () => { + setPlatform('darwin') const { createSystemTray } = await loadModule() - const onOpen = vi.fn() - const onQuit = vi.fn() + const options = createOptions() - createSystemTray({ appIcon: 'classic', onOpen, onQuit }) + createSystemTray(options) - const items = builtMenuItems() - items.find((i) => i.label === 'Open Orca')?.click?.() - expect(onOpen).toHaveBeenCalledTimes(1) - - const clickHandler = trayInstances[0].on.mock.calls.find((c) => c[0] === 'click')?.[1] as - | (() => void) - | undefined - clickHandler?.() - expect(onOpen).toHaveBeenCalledTimes(2) - - items.find((i) => i.label === 'Quit')?.click?.() - expect(onQuit).toHaveBeenCalledTimes(1) + expect(trayInstances).toHaveLength(1) + expect(trayInstances[0].image).toBe(baseMacImage) + expect(baseMacImage.setTemplateImage).toHaveBeenCalledWith(true) + expect(baseMacImage.addRepresentation).toHaveBeenCalledWith({ + scaleFactor: 2, + dataURL: 'data:image/png;base64,mac-retina' + }) + expect(builtMenuItems().map((item) => item.label)).toEqual([ + 'Open Orca', + undefined, + 'Settings', + 'Check for Updates...', + undefined, + 'Quit' + ]) + expect(trayInstances[0].on).not.toHaveBeenCalled() + expect(nativeThemeMock.on).toHaveBeenCalledWith('updated', expect.any(Function)) + + for (const [label, callback] of [ + ['Open Orca', options.onOpen], + ['Settings', options.onOpenSettings], + ['Check for Updates...', options.onCheckForUpdates], + ['Quit', options.onQuit] + ] as const) { + builtMenuItems() + .find((item) => item.label === label) + ?.click?.() + expect(callback).toHaveBeenCalledOnce() + } }) - it('is idempotent: a second call does not create a duplicate tray', async () => { - setPlatform('win32') + it('does not create a blank macOS item when the template asset fails to load', async () => { + setPlatform('darwin') + baseMacImage.getSize.mockReturnValue({ width: 0, height: 0 }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) const { createSystemTray } = await loadModule() - const opts = { appIcon: 'classic', onOpen: vi.fn(), onQuit: vi.fn() } - const first = createSystemTray(opts) - const second = createSystemTray(opts) - - expect(trayInstances).toHaveLength(1) - expect(second).toBe(first) + expect(createSystemTray(createOptions())).toBeNull() + expect(trayInstances).toHaveLength(0) + expect(warn).toHaveBeenCalledWith('[system-tray] macOS menu bar icon could not be loaded') }) - it('is a no-op on non-win32 platforms', async () => { + it('is idempotent and remains disabled on Linux', async () => { setPlatform('darwin') const { createSystemTray } = await loadModule() + const options = createOptions() + expect(createSystemTray(options)).toBe(createSystemTray(options)) + expect(trayInstances).toHaveLength(1) + + setPlatform('linux') + const linuxModule = await loadModule() + expect(linuxModule.createSystemTray(options)).toBeNull() + }) +}) - const tray = createSystemTray({ appIcon: 'classic', onOpen: vi.fn(), onQuit: vi.fn() }) +describe('macOS visibility', () => { + it('creates and destroys the item as the setting toggles', async () => { + setPlatform('darwin') + const { setMacMenuBarIconVisible } = await loadModule() + const options = createOptions() - expect(tray).toBeNull() + expect(setMacMenuBarIconVisible(false, options)).toBeNull() expect(trayInstances).toHaveLength(0) + expect(setMacMenuBarIconVisible(true, options)).not.toBeNull() + expect(trayInstances).toHaveLength(1) + + setMacMenuBarIconVisible(false, options) + setMacMenuBarIconVisible(false, options) + expect(trayInstances[0].destroy).toHaveBeenCalledOnce() }) }) describe('setTrayAttention', () => { - it('swaps in the dotted icon when active and restores the base when cleared', async () => { + it('preserves the Windows attention swap behavior', async () => { setPlatform('win32') const { createSystemTray, setTrayAttention } = await loadModule() - createSystemTray({ appIcon: 'classic', onOpen: vi.fn(), onQuit: vi.fn() }) - const tray = trayInstances[0] - tray.setImage.mockClear() + createSystemTray(createOptions()) + const created = trayInstances[0] + created.setImage.mockClear() setTrayAttention(true) expect(composeAttentionMock).toHaveBeenCalledWith(resizedImage) - expect(tray.setImage).toHaveBeenCalledWith({ dotted: resizedImage }) - - tray.setImage.mockClear() + expect(created.setImage).toHaveBeenCalledWith(attentionImage) setTrayAttention(false) - expect(tray.setImage).toHaveBeenCalledWith(resizedImage) + expect(created.setImage).toHaveBeenLastCalledWith(resizedImage) }) - it('ignores repeated same-state calls', async () => { - setPlatform('win32') + it('uses a literal theme-aware glyph for macOS attention, then restores template mode', async () => { + setPlatform('darwin') const { createSystemTray, setTrayAttention } = await loadModule() - createSystemTray({ appIcon: 'classic', onOpen: vi.fn(), onQuit: vi.fn() }) - const tray = trayInstances[0] - tray.setImage.mockClear() + createSystemTray(createOptions()) + const created = trayInstances[0] + created.setImage.mockClear() + created.setToolTip.mockClear() setTrayAttention(true) + expect(tintTemplateMock).toHaveBeenCalledWith(baseMacImage, false) + expect(composeAttentionMock).toHaveBeenCalledWith(tintedMacImage) + // Why: the attention image is rebuilt from 1x pixels, so the @2x + // representation must be re-added or the glyph blurs on Retina. + expect(tintTemplateMock).toHaveBeenCalledWith(baseMacImage, false, 2) + expect(attentionImage.addRepresentation).toHaveBeenCalledWith({ + scaleFactor: 2, + dataURL: 'data:image/png;base64,attention' + }) + expect(attentionImage.setTemplateImage).toHaveBeenCalledWith(false) + expect(created.setImage).toHaveBeenCalledWith(attentionImage) + expect(created.setToolTip).toHaveBeenCalledWith('Orca - activity waiting') + + setTrayAttention(false) + expect(baseMacImage.setTemplateImage).toHaveBeenLastCalledWith(true) + expect(created.setImage).toHaveBeenLastCalledWith(baseMacImage) + expect(created.setToolTip).toHaveBeenLastCalledWith('Orca') + }) + + it('recomposes active macOS attention when the system appearance changes', async () => { + setPlatform('darwin') + const { createSystemTray, setTrayAttention } = await loadModule() + createSystemTray(createOptions()) + setTrayAttention(true) + tintTemplateMock.mockClear() + nativeThemeMock.shouldUseDarkColors = true + + themeState.updatedListener?.() + + expect(tintTemplateMock).toHaveBeenCalledWith(baseMacImage, true) + }) + + it('reflects attention requested before deferred macOS creation', async () => { + setPlatform('darwin') + const { createSystemTray, setTrayAttention } = await loadModule() setTrayAttention(true) + createSystemTray(createOptions()) - expect(tray.setImage).toHaveBeenCalledTimes(1) + expect(trayInstances[0].setImage).toHaveBeenCalledWith(attentionImage) }) - it('reflects attention that was requested before the tray was created', async () => { + it('ignores repeated same-state attention calls', async () => { setPlatform('win32') const { createSystemTray, setTrayAttention } = await loadModule() + createSystemTray(createOptions()) + const created = trayInstances[0] + created.setImage.mockClear() - // Fire the event before the (deferred) tray exists. setTrayAttention(true) - createSystemTray({ appIcon: 'classic', onOpen: vi.fn(), onQuit: vi.fn() }) - const tray = trayInstances[0] + setTrayAttention(true) - expect(tray.setImage).toHaveBeenCalledWith({ dotted: resizedImage }) + expect(created.setImage).toHaveBeenCalledTimes(1) }) - it('is a safe no-op on non-win32 platforms', async () => { + it('keeps a pending attention dot across a macOS hide/show toggle', async () => { setPlatform('darwin') - const { setTrayAttention } = await loadModule() + const { setMacMenuBarIconVisible, setTrayAttention } = await loadModule() + const options = createOptions() + setMacMenuBarIconVisible(true, options) + setTrayAttention(true) - expect(() => setTrayAttention(true)).not.toThrow() - expect(composeAttentionMock).not.toHaveBeenCalled() + setMacMenuBarIconVisible(false, options) + setMacMenuBarIconVisible(true, options) + + expect(trayInstances).toHaveLength(2) + expect(trayInstances[1].setImage).toHaveBeenCalledWith(attentionImage) }) }) describe('destroySystemTray', () => { - it('destroys an existing tray and is safe to call without one', async () => { - setPlatform('win32') + it('destroys idempotently and removes the macOS appearance listener', async () => { + setPlatform('darwin') const { createSystemTray, destroySystemTray } = await loadModule() - createSystemTray({ appIcon: 'classic', onOpen: vi.fn(), onQuit: vi.fn() }) + createSystemTray(createOptions()) const created = trayInstances[0] + const listener = themeState.updatedListener destroySystemTray() - expect(created.destroy).toHaveBeenCalledTimes(1) + destroySystemTray() + + expect(created.destroy).toHaveBeenCalledOnce() + expect(nativeThemeMock.removeListener).toHaveBeenCalledWith('updated', listener) + }) +}) + +describe('macOS hardening', () => { + it('warns but still creates the item when only the @2x asset fails to load', async () => { + setPlatform('darwin') + retinaMacImage.getSize.mockReturnValue({ width: 0, height: 0 }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { createSystemTray } = await loadModule() + + expect(createSystemTray(createOptions())).not.toBeNull() + expect(trayInstances).toHaveLength(1) + expect(baseMacImage.addRepresentation).not.toHaveBeenCalled() + expect(warn).toHaveBeenCalledWith( + '[system-tray] macOS retina menu bar icon could not be loaded' + ) + }) - // Second call with no live tray must not throw. - expect(() => destroySystemTray()).not.toThrow() + it('degrades to the plain icon instead of throwing when attention composition fails', async () => { + setPlatform('darwin') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { createSystemTray, setTrayAttention } = await loadModule() + createSystemTray(createOptions()) + const created = trayInstances[0] + created.setImage.mockClear() + composeAttentionMock.mockImplementationOnce(() => { + throw new Error('native image failure') + }) + + expect(() => setTrayAttention(true)).not.toThrow() + expect(created.setImage).toHaveBeenLastCalledWith(baseMacImage) + expect(created.setToolTip).toHaveBeenLastCalledWith('Orca') + expect(warn).toHaveBeenCalledWith( + '[system-tray] macOS attention icon failed; showing plain icon', + expect.any(Error) + ) + }) + + it('contains a throwing menu-item callback instead of crashing', async () => { + setPlatform('darwin') + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + const { createSystemTray } = await loadModule() + const options = createOptions() + options.onOpenSettings.mockImplementation(() => { + throw new Error('boom') + }) + createSystemTray(options) + + const settingsItem = builtMenuItems().find((item) => item.label === 'Settings') + expect(() => settingsItem?.click?.()).not.toThrow() + expect(error).toHaveBeenCalledWith('[system-tray] menu action failed', expect.any(Error)) }) }) diff --git a/src/main/tray/system-tray.ts b/src/main/tray/system-tray.ts index ada6f58c4e91..27d2487a3c5b 100644 --- a/src/main/tray/system-tray.ts +++ b/src/main/tray/system-tray.ts @@ -1,13 +1,19 @@ -import { Menu, Tray, type NativeImage } from 'electron' +import { Menu, Tray, nativeImage, nativeTheme, type NativeImage } from 'electron' +import menuBarIconPath from '../../../resources/tray/orca-menu-barTemplate.png?asset&asarUnpack' +import menuBarIconRetinaPath from '../../../resources/tray/orca-menu-barTemplate@2x.png?asset&asarUnpack' import { createAppIconImage } from '../app-icon' import { translateMain } from '../i18n/main-i18n' -import { composeTrayAttentionIcon } from './tray-attention-icon' +import { composeTrayAttentionIcon, tintTrayTemplateForAttention } from './tray-attention-icon' -type SystemTrayOptions = { +export type SystemTrayOptions = { /** App icon id from settings; the tray reuses the app icon image. */ appIcon: unknown /** Restore + show + focus the main window (recreating it if needed). */ onOpen: () => void + /** Restore the main window and open its Settings surface. */ + onOpenSettings: () => void + /** Run the existing user-initiated update check. */ + onCheckForUpdates: () => void /** Quit Orca for real (caller must set the quitting latch before quitting). */ onQuit: () => void } @@ -25,6 +31,8 @@ let baseTrayImage: NativeImage | null = null // freshly created tray reflects it immediately. let attentionActive = false +let nativeThemeUpdatedListener: (() => void) | null = null + // Why: on Windows the notification area expects a 16px icon; the app icon PNG // is larger, so downscale to avoid a cropped/blurry tray glyph. const TRAY_ICON_SIZE = 16 @@ -35,46 +43,195 @@ function applyTrayImage(): void { if (!tray || tray.isDestroyed() || !baseTrayImage) { return } + + if (process.platform === 'darwin') { + if (attentionActive) { + try { + // Why: disabling template tinting makes the amber dot possible, but the + // glyph then needs literal pixels chosen for the current menu-bar theme. + const useLightGlyph = nativeTheme.shouldUseDarkColors + const attentionImage = composeTrayAttentionIcon( + tintTrayTemplateForAttention(baseTrayImage, useLightGlyph) + ) + if (baseTrayImage.getScaleFactors().includes(2)) { + // Why: toBitmap reads only the 1x pixels, so rebuild the @2x + // representation or the glyph blurs on Retina during attention. + const retinaAttentionImage = composeTrayAttentionIcon( + tintTrayTemplateForAttention(baseTrayImage, useLightGlyph, 2) + ) + attentionImage.addRepresentation({ + scaleFactor: 2, + dataURL: retinaAttentionImage.toDataURL() + }) + } + attentionImage.setTemplateImage(false) + tray.setImage(attentionImage) + tray.setToolTip(translateMain('tray.activityWaiting', 'Orca - activity waiting')) + return + } catch (error) { + // Why: this path runs inside unguarded callbacks (nativeTheme 'updated', + // the notification bell), so a NativeImage failure must degrade to the + // plain template icon rather than throw an uncaught exception into them. + console.warn('[system-tray] macOS attention icon failed; showing plain icon', error) + } + } + + baseTrayImage.setTemplateImage(true) + tray.setImage(baseTrayImage) + tray.setToolTip('Orca') + return + } + tray.setImage(attentionActive ? composeTrayAttentionIcon(baseTrayImage) : baseTrayImage) } +function createMacMenuBarImage(): NativeImage | null { + const image = nativeImage.createFromPath(menuBarIconPath) + const { width, height } = image.getSize() + if (width <= 0 || height <= 0) { + console.warn('[system-tray] macOS menu bar icon could not be loaded') + return null + } + + const retinaImage = nativeImage.createFromPath(menuBarIconRetinaPath) + const retinaSize = retinaImage.getSize() + if (retinaSize.width > 0 && retinaSize.height > 0) { + try { + // Why: importing the @2x asset guarantees it is packaged; adding the + // representation explicitly avoids relying on bundler-renamed siblings. + image.addRepresentation({ + scaleFactor: 2, + dataURL: retinaImage.toDataURL() + }) + } catch (error) { + // Why: a bad @2x representation must not abort tray creation; the 1x image + // still renders (just blurrier on Retina). + console.warn('[system-tray] macOS retina menu bar icon could not be added', error) + } + } else { + // Why: surface the silently-degraded (non-Retina) icon so a blurry menu-bar + // glyph on a modern display is diagnosable, matching the base-image warning. + console.warn('[system-tray] macOS retina menu bar icon could not be loaded') + } + image.setTemplateImage(true) + return image +} + +function watchMacAppearance(): void { + if (nativeThemeUpdatedListener) { + return + } + nativeThemeUpdatedListener = () => { + if (attentionActive) { + applyTrayImage() + } + } + nativeTheme.on('updated', nativeThemeUpdatedListener) +} + +function stopWatchingMacAppearance(): void { + if (!nativeThemeUpdatedListener) { + return + } + nativeTheme.removeListener('updated', nativeThemeUpdatedListener) + nativeThemeUpdatedListener = null +} + +// Why: Electron Menu/Tray click callbacks are plain event listeners (not +// promise-wrapped like ipcMain.handle), so an uncaught throw here is fatal to +// the main process; contain it to a logged, failed menu action instead. +function safeMenuAction(action: () => void): () => void { + return () => { + try { + action() + } catch (error) { + console.error('[system-tray] menu action failed', error) + } + } +} + /** - * Creates the Windows system tray icon. No-op on macOS/Linux. Idempotent: a - * second call while a tray is alive returns the existing one instead of - * stacking a duplicate ghost icon. + * Creates the Windows notification icon or macOS menu bar status item. No-op + * on Linux. Idempotent: repeated calls never stack duplicate icons. */ export function createSystemTray(opts: SystemTrayOptions): Tray | null { - if (process.platform !== 'win32') { + if (process.platform !== 'win32' && process.platform !== 'darwin') { return null } if (tray && !tray.isDestroyed()) { return tray } - baseTrayImage = createAppIconImage(opts.appIcon).resize({ - width: TRAY_ICON_SIZE, - height: TRAY_ICON_SIZE - }) + + if (process.platform === 'darwin') { + baseTrayImage = createMacMenuBarImage() + if (!baseTrayImage) { + return null + } + } else { + baseTrayImage = createAppIconImage(opts.appIcon).resize({ + width: TRAY_ICON_SIZE, + height: TRAY_ICON_SIZE + }) + } + tray = new Tray(baseTrayImage) // Why: reflect any attention event that fired before the tray existed. applyTrayImage() - tray.setToolTip('Orca') + const menu = Menu.buildFromTemplate([ - { label: translateMain('tray.openOrca', 'Open Orca'), click: () => opts.onOpen() }, + { + label: translateMain('tray.openOrca', 'Open Orca'), + click: safeMenuAction(() => opts.onOpen()) + }, { type: 'separator' }, - { label: translateMain('tray.quit', 'Quit'), click: () => opts.onQuit() } + // Why: reuse the app menu's keys so the two entry points never drift. + ...(process.platform === 'darwin' + ? ([ + { + label: translateMain('menu.settings', 'Settings'), + click: safeMenuAction(() => opts.onOpenSettings()) + }, + { + label: translateMain('menu.checkForUpdates', 'Check for Updates...'), + click: safeMenuAction(() => opts.onCheckForUpdates()) + }, + { type: 'separator' } + ] as Electron.MenuItemConstructorOptions[]) + : []), + { label: translateMain('tray.quit', 'Quit'), click: safeMenuAction(() => opts.onQuit()) } ]) tray.setContextMenu(menu) - // Why: a left-click on the tray icon is the conventional Windows gesture to - // restore a minimized-to-tray app. - tray.on('click', () => opts.onOpen()) + if (process.platform === 'win32') { + tray.setToolTip('Orca') + // Why: a left-click on the tray icon is the conventional Windows gesture to + // restore a minimized-to-tray app; macOS opens the attached menu instead. + tray.on( + 'click', + safeMenuAction(() => opts.onOpen()) + ) + } else { + watchMacAppearance() + } return tray } +/** Applies the persisted macOS visibility preference without affecting Windows. */ +export function setMacMenuBarIconVisible(visible: boolean, opts: SystemTrayOptions): Tray | null { + if (process.platform !== 'darwin') { + return null + } + if (!visible) { + destroySystemTray() + return null + } + return createSystemTray(opts) +} + /** * Shows or hides a red/amber attention dot on the tray icon. Call with `true` * when a terminal bell or agent completion fires while the window is - * minimized/hidden, and `false` once the window is shown again. No-op on - * macOS/Linux (no tray) and safe to call before the tray is created. + * minimized/hidden, and `false` once the window is shown again. Safe to call + * before the tray is created or on Linux where no tray is available. */ export function setTrayAttention(active: boolean): void { if (attentionActive === active) { @@ -86,10 +243,12 @@ export function setTrayAttention(active: boolean): void { /** Destroys the tray icon if present. Safe to call repeatedly or with no tray. */ export function destroySystemTray(): void { + stopWatchingMacAppearance() if (tray && !tray.isDestroyed()) { tray.destroy() } tray = null baseTrayImage = null - attentionActive = false + // Why: attention is owned by the notification/visibility flow, and must + // survive the macOS hide/show toggle so a re-shown icon keeps its dot. } diff --git a/src/main/tray/tray-attention-icon.test.ts b/src/main/tray/tray-attention-icon.test.ts index 42d554417e06..090847e284ee 100644 --- a/src/main/tray/tray-attention-icon.test.ts +++ b/src/main/tray/tray-attention-icon.test.ts @@ -12,7 +12,7 @@ vi.mock('electron', () => ({ nativeImage: { createFromBitmap: createFromBitmapMock } })) -import { composeTrayAttentionIcon } from './tray-attention-icon' +import { composeTrayAttentionIcon, tintTrayTemplateForAttention } from './tray-attention-icon' type FakeImage = { getSize: () => { width: number; height: number } @@ -86,3 +86,59 @@ describe('composeTrayAttentionIcon', () => { expect(paintedInBottomLeft).toBe(0) // the opposite corner is never touched }) }) + +describe('tintTrayTemplateForAttention', () => { + it('preserves alpha while selecting literal black pixels for a light menu bar', () => { + createFromBitmapMock.mockClear() + const bitmap = Buffer.from([0x22, 0x33, 0x44, 0x80, 0xaa, 0xbb, 0xcc, 0x00]) + const base = { + getSize: () => ({ width: 2, height: 1 }), + toBitmap: () => bitmap + } + + tintTrayTemplateForAttention(base as never, false) + + expect(createFromBitmapMock.mock.calls[0][0]).toEqual( + Buffer.from([0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00]) + ) + }) + + it('selects literal white pixels for a dark menu bar', () => { + createFromBitmapMock.mockClear() + const base = { + getSize: () => ({ width: 1, height: 1 }), + toBitmap: () => Buffer.from([0x00, 0x00, 0x00, 0xff]) + } + + tintTrayTemplateForAttention(base as never, true) + + expect(createFromBitmapMock.mock.calls[0][0]).toEqual(Buffer.from([0xff, 0xff, 0xff, 0xff])) + }) + + it('keeps white edge pixels premultiplied-valid at partial alpha', () => { + createFromBitmapMock.mockClear() + const base = { + getSize: () => ({ width: 1, height: 1 }), + toBitmap: () => Buffer.from([0x00, 0x00, 0x00, 0x80]) + } + + tintTrayTemplateForAttention(base as never, true) + + // Why: the bitmap is premultiplied — white at 50% coverage is 0x80, not 0xff. + expect(createFromBitmapMock.mock.calls[0][0]).toEqual(Buffer.from([0x80, 0x80, 0x80, 0x80])) + }) + + it('reads and sizes the requested scale factor', () => { + createFromBitmapMock.mockClear() + const toBitmap = vi.fn(() => Buffer.from([0x00, 0x00, 0x00, 0xff])) + const base = { + getSize: () => ({ width: 1, height: 1 }), + toBitmap + } + + tintTrayTemplateForAttention(base as never, true, 2) + + expect(toBitmap).toHaveBeenCalledWith({ scaleFactor: 2 }) + expect(createFromBitmapMock.mock.calls[0][1]).toEqual({ width: 2, height: 2 }) + }) +}) diff --git a/src/main/tray/tray-attention-icon.ts b/src/main/tray/tray-attention-icon.ts index d9b042db37f1..ffdf4528d474 100644 --- a/src/main/tray/tray-attention-icon.ts +++ b/src/main/tray/tray-attention-icon.ts @@ -7,6 +7,36 @@ const DOT_RGB = { r: 0xf5, g: 0x9e, b: 0x0b } // A near-white halo separates the dot from the icon glyph on any tray theme. const RING_RGB = { r: 0xff, g: 0xff, b: 0xff } +/** + * Converts a template mask into a literal glyph for the attention state. A + * non-template image needs real RGB values because macOS no longer tints it. + */ +export function tintTrayTemplateForAttention( + base: NativeImage, + useLightGlyph: boolean, + scaleFactor = 1 +): NativeImage { + const { width, height } = base.getSize() + if (width <= 0 || height <= 0) { + return base + } + + const bitmap = Buffer.from(base.toBitmap({ scaleFactor })) + for (let offset = 0; offset < bitmap.length; offset += 4) { + // Why: the bitmap is premultiplied, so a light glyph must write the pixel's + // alpha (not 0xff) or antialiased edges become invalid over-bright pixels. + const channel = useLightGlyph ? bitmap[offset + 3] : 0x00 + bitmap[offset] = channel + bitmap[offset + 1] = channel + bitmap[offset + 2] = channel + } + + return nativeImage.createFromBitmap(bitmap, { + width: width * scaleFactor, + height: height * scaleFactor + }) +} + /** * Returns a copy of `base` with a small amber attention dot composited into the * top-right corner. Electron's NativeImage has no compositing API, so we merge diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 2ce823028c5f..7e228f8495ff 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -2758,6 +2758,8 @@ export type PreloadApi = { recordFeatureInteraction: (id: FeatureInteractionId) => Promise onStateChanged: (callback: (ui: PersistedUIState) => void) => () => void onOpenSettings: (callback: () => void) => () => void + /** Consumes a one-shot tray/menu-bar "open settings" intent queued before mount. */ + consumePendingOpenSettings: () => Promise onOpenSetupGuide: (callback: () => void) => () => void onOpenFeatureTour: (callback: () => void) => () => void onOpenCrashReport: (callback: () => void) => () => void diff --git a/src/preload/index.ts b/src/preload/index.ts index 912e1852cfa4..f4f816596711 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -3169,6 +3169,8 @@ const api = { ipcRenderer.on('ui:openSettings', listener) return () => ipcRenderer.removeListener('ui:openSettings', listener) }, + consumePendingOpenSettings: (): Promise => + ipcRenderer.invoke('ui:consumePendingOpenSettings'), onOpenSetupGuide: (callback: () => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent) => callback() ipcRenderer.on('ui:openSetupGuide', listener) diff --git a/src/renderer/src/components/settings/AppearanceInterfaceSection.tsx b/src/renderer/src/components/settings/AppearanceInterfaceSection.tsx index 31afc7d68435..9d5bb1cbbab3 100644 --- a/src/renderer/src/components/settings/AppearanceInterfaceSection.tsx +++ b/src/renderer/src/components/settings/AppearanceInterfaceSection.tsx @@ -17,6 +17,7 @@ import { import { DEFAULT_APP_FONT_FAMILY } from '../../../../shared/constants' import { getLanguageEntries, + getMenuBarIconEntries, getSystemTrayEntries, getThemeEntries, getTitlebarEntries, @@ -37,6 +38,7 @@ type AppearanceInterfaceSectionProps = { updateSettings: (updates: Partial) => void applyTheme: (theme: 'system' | 'dark' | 'light') => void fontSuggestions: string[] + isDesktopMac: boolean isDesktopWindows: boolean onRequestFontSuggestions?: () => void forceVisiblePrimary?: boolean @@ -47,6 +49,7 @@ export function AppearanceInterfaceSection({ updateSettings, applyTheme, fontSuggestions, + isDesktopMac, isDesktopWindows, onRequestFontSuggestions, forceVisiblePrimary = false @@ -56,6 +59,7 @@ export function AppearanceInterfaceSection({ const zoomInKeyCombos = useShortcutKeyComboDetails('zoom.in') const zoomOutKeyCombos = useShortcutKeyComboDetails('zoom.out') const languageEntry = getLanguageEntries()[0] + const menuBarIconEntry = getMenuBarIconEntries({ showMenuBarIcon: true })[0] const systemTrayEntry = getSystemTrayEntries({ showSystemTray: true })[0] const themeEntry = getThemeEntries()[0] const themeLabel = translate('auto.components.settings.AppearancePane.932ff1fbff', 'Theme') @@ -65,7 +69,8 @@ export function AppearanceInterfaceSection({ const advancedEntries = [ ...(SHOW_UI_LANGUAGE_SETTING ? getLanguageEntries() : []), ...getTitlebarEntries(), - ...getSystemTrayEntries({ showSystemTray: isDesktopWindows }) + ...getSystemTrayEntries({ showSystemTray: isDesktopWindows }), + ...getMenuBarIconEntries({ showMenuBarIcon: isDesktopMac }) ] const showAdvanced = !isSearching || matchesSettingsSearch(searchQuery, advancedEntries) @@ -236,6 +241,28 @@ export function AppearanceInterfaceSection({ /> ) : null} + + {isDesktopMac ? ( + + + updateSettings({ showMenuBarIcon: settings.showMenuBarIcon === false }) + } + /> + + ) : null}

Z-%z0d!Y%55-k{&pyjb7>(oG?j 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 Z`+t-TKbBqUhHwA?002ovPDHLkV1i0z#=QUl diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index 2b467df982dc..0116866f0480 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -235,11 +235,9 @@ yay -S stably-orca-bin - **Discord:** 加入 **[Discord](https://discord.gg/fzjDKHxv8Q)** 社区。 - **Twitter / X:** 关注 **[@orca_build](https://x.com/orca_build)** 获取更新和公告。 -- **微信:** 扫描二维码加入社区。如果第一个群已满,请使用备用微信群。 +- **微信:** 第一个微信群已满,现在可以加入第二个群。 - Orca 社区微信群二维码 -     - Orca 社区备用微信群二维码 + Orca 社区微信群二维码 - **反馈与想法:** 我们发布很快。缺少什么功能?[提交功能请求](https://github.com/stablyai/orca/issues)。 - **隐私:** 查看[隐私与遥测文档](https://www.onorca.dev/docs/telemetry),了解 Orca 收集哪些匿名使用数据以及如何退出。 From cf02cc46b1434a0b84597a268bd232c9898e61f6 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 16 Jul 2026 00:43:11 -0700 Subject: [PATCH 073/351] Add missing WeChat QR image referenced by README The previous commit updated the README to docs/assets/wechat-qr.jpg and removed the old dual-QR assets, but never added the new image file, so GitHub could not load it. --- docs/assets/wechat-qr.jpg | Bin 0 -> 403265 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/assets/wechat-qr.jpg diff --git a/docs/assets/wechat-qr.jpg b/docs/assets/wechat-qr.jpg new file mode 100644 index 0000000000000000000000000000000000000000..0b4598e0f883781b50d8aee3533d7d4ac372f258 GIT binary patch literal 403265 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

aNLS;DzmZ%3!;_4s3z8WSCiayZ`i>c5vz95oe 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⁢}8m&Wy}$d2Znr*i^Vs?zAR z#TqhGxj%YzboYEIZf#LA2YkLAojX0fdM~}qzg>K&E)*eTz2$L4nu zhf!<~FyQ@_-$KiG#czo2v>P%j%y>mWjW+<0F$$pwujiJd+QNmFI?Nih?bva)#tw0? zc4No|^pZ)GZgU8FW@d5ntx{{_R_~X&_Y4krrhhr#J+D9Y6te}M;Q*r%R@P|wzbcyu zKgnEgVu5wHJ5sh@z7_?A6Ro!`4O<%!ZUSXU6!pLOdiQXs*7tvWBvfK5MX{OADoF>2 z9hxD#RMMo95L2lfreU{{DPxw%Dao#O?0DDc;1Fq&Qwf8?>}t#T9CNTs4wE&-v@&a^ z?`^+7pFe)r_4|JJAN#sW)_T@+xS#vJU-$ib31h>xiHTrI7L!-XQVKhH@p0*i__iFZ z%`MCNuEZw*UbwoWo9~8k_QI~3RXq=>wd95H`fS9ibeXrvfBc3_AW7gy1rNIww^q`{ zl-Z+R2??d%DQiEwQV#jp9sDvBKeds2|K1x3-EZ~!2#toK-r`Wf@-`DZH-1&xS z8qqpn(J|>$!{x-=khCZMXvJ@UC(T$$Qfa#>3<~mJ=qP^qdH(%q$b+1*xPEhY%}*(R zjXq83^l?w0ua-reIt%bzZljw3>~JW)?zg{?IY@X9g_fx`WoK&y8yoJV9zC`9_39QaF^E*n zkVZglYN!xBGtGr@X_!PkWM;~D9qciJYK`_?f7l#cntxNRcEh7#z(HOvQlIriZT14k zWwTA@EdOVL^X!G+0i3EgmHetM@u**}Nsu)s6V$Uv{oT0kT{7o3;n$A;zTtmf1SL+5 zLUiBlBe3?Tk=h+G8}cYcA>)77ubZb$scqJJRiv&S`f6Lz=-&<3UCu^Rta+kOQvf;4 zj>XFM#dt_KHI0WH*6hU+a@KE%T=eAajTraDPkT-fzkHs7Y5F0b(3TGpcpFy6xuX&B zOWYmiYM9CLMd-;vx;;l1Z} zc-g$q`g{!=mjC+QXw^sRo$A9kUW-nH+F@yc`gcqC-XDZrMo8@;L^P<=`fwTLT>sCH z-d|#D|Om9MuR_y}yH z;PKt$U~7+@2N7SYN}G7av0R+LReXfuFQe4X`tYl+b{eC8f%kdS?J&z;|Ni6Uzntgd zOCnUddi{nL=?Y)^LP4C{^KFXV?9Sel&Ez9wuQML=F06HgqB2Th5PYfuwf}}Yg@N?X zb=#S*>fn{kn^`9329ADoz9s<{OOqh-Tdej4n2`TxkLN+T-~ zTJ4m*-^!T!-}boAoBN5Ne%XPPM?sIAhU&H$e=zS(PWdlL&%n@mi8jwycSg+w!PnIO zdVf9t)1`vElQA!|h_Y8R8?W0;HRlkdUH44%91e$dV;&|DW6{qvf||B-sZ{Qtax zFpAIFHk7Iv`xn^^{j~fWC_PtY6Ob3n)g0X6z|0)Fp9yNwgr9(SwnGgkBelahE9B71}{;c>Q~L#lrKa*xIHH}&!>p# zLH@I6ZRHX1({Fx_EFjcO&7QR#CcbMlnsSTVm$R-JH--%2>>g#GF;I2-UtfdVUdiq^| z5_#+OL|ZiF-Ufo|2}RWWVWxB~4G-R!-#|IZOFDNYdfyf}RF*5wnBG2hedI=L>77ek z=BufG2kZ6=2R**T9sYd@dq8{yPH>#G@TYZz(CmPJP{L0)_YU0*edkJGyZ_hhUIhNK zQ(WAP)Bm9?MTlmyn+sm4UueJ2@>~;oP4v6&wrkghu7Rp$-J2)b$25;>cpjVekQxi(f5Ff$H6k z?uIoUn4fH!kCPcwuVM5}&5|=@Qis}6t>rPgb5_m%H8ca3b-}+PWsm|Db;r$o28E@+ z!odPund0q)S$~t)(MPpOk`?YArlvucTL@XC2Nd+^nwjHr5+>6zHvbu^h$mfIvw5~A zHIBS4?A*V1S#JLG_@%2X)nw(k(Fr(#a0H5O%u@?h?Ao?~uu+J13XKgyNfNt%ADj1y zaF7TCh*5!UTKH8iet*9Vw?hjyIoKUz9YJ-MUfTTnpA**TwD*(Mae>UM`ipE!s*5p4uoP{1J!7 z|15e9mg_ahpLfDVR%==s3gF9G=Y?y)o|@e>bTwywmShzU=_6F-;0R{b3d zf=Z0G@BzQ$vfE`v+1SM?$`WR>WXZk98WYJtY5kHXF5-#CC#H7|k3)18M@!&MpD0no z-B{shg!G#N5W`*UHIRvMu4`V3b<4-G0@>Cv{-6cslgni5s2^%Ag-rLzg?lE+PSz0- z2Ln|4sB^SENi4N(V(ls%+k%BSp}P}^iB{Z2)weX>m5E5O3G08Rpt zb26B{Qy>|@@(__0Gh=H_Y*9fXYsnCGF5NI8q0(JU(*IPEo?!1LzKiUAdGoD%3+|Xj z*YCOng(`vR&zb`B0y9#wOQpB}KI9lW|F?%LqeDB?Cez1@6wBFdoU-PIrR@2tw=|%E zmBmU{ed^0id|HyC>tpKD9!4d8LNAv0fykbxgyH-Pid+dxg>-nq4wnW)d8+?opwI&9 zr5x9b&;!1w-J*uF%N`OAniHV^W4|Sfes4_&?-Wh%_{gA1>(q|lRQ-J6l4=6>iuU4p z+qoQGMXVE0nUT%LeAmm!MH}_M0K^>0_aYZ^$2z8?~U$9-I_)2H7 z(y9KEJX8EvwoG-$I;U^2_=IK3iHCw9HRVbW>VR_Au_`?gv}o!0Lkc%0oc%Y2I2mP8 z<7?JoMKfk?e%Wg8BQvO-X5=Ja-0>}`av@~A`kf_~(-T)MT~4RFwTAu0(imE{i7OSQ z*l#b`qJIcvl}XQ$@g)j~vTJv;{D!JlK~=#T3Fn$>C7N@y|5+BsbDmT zKKoMhwMvA`JY2Vcne1KtqT)pxYm2fRgd5ft`{y!a-P=kstG4korUo+3@r(zmTEF;OGzkEavzUT&}62%ga(ORBzHvr z@9DJS(CkkUj~{-LmqUxQ+{g3)udBIn+k2>*(RXg z?s4_AWtFYVUr(n%y2z=bxFsx+TPh8r>*%?5gliup>M2u!=ay5>k;=qB2uqp8@&n4d zmAhmxqw1ZOO~m@@wIO;q1vN+zbxnEGU;SknW#I)ZP(sa-!8M%>b8~WM;fTucW!EO$ zj)6R&O6G*UsC3Gz&QlxVm89bfAq0$2tYWS0W^Y%uke;U}*&Cn*b>9rbc}Xmv9AN!0 ztv5o5n+kp_J8CmMcn9qls4TiB3ztniih0Ahq;QS>rMU7{QAs5)Ml$`*E?U<}4#h(; z5d76TBEiDhV~~2GoC)bIucs}x*AN#nnY6>QFi`$EFEZA^cb46N@^V}9eVIk0uWoF8 zL`L|NuIFP5vG7vrxLZ{2+9&5YZbjbFy6N&q@KgXCYWGo2fq@Fe8Bic{hPQ~REk2X> zG8=L=viQ40v^dVfjbS^Syj+U2n*bt;C)>)>a>Y&hpCv6LkAXl#Kt&~Vj|%I!P5BvJ zuO1u=B9QKsfF+lM{hIw)`F!S1$%DEpB+iR8o~M4e+*SxrL0U-GD{_}i@sm>f>pd$3uS(`T=#ec|E; z&T@Em%mh;?zl2>xzpCb$DlG7ym3I(-;S%P@$kY;i{kpZccIR3lW~)%mW1zRh z$Ys?sAIwD#Ib+kyliC#BT{10b!s8}=qb;!CdnPA7;lK--`^4pHE2bXbHa<5nH<@8I zWo9vNB;D%|Yd0F5m^bEOc>VDuweK@Qhz69f$F!(=!I7p@6ATRmLd<)y9vy2u+z@$|b-wWJ5NRnyzxC7UiTB$q&a`ekh+Q|=a&52t*j{4i<8-(y zq;xs4bz&A$+Yi7GR|DHtr&`nj3DuSOFV*N^LT*60udPhTN0NWTnj3x_k+=36hc@Uj z4-X0A?6;F&=FO>}PHDU$t}=WVnRfGZ(Un(M33v;P@-pOQDd)J@I$?ckEM-yk78<5r zW56j^3go=HR2`rOkiQah=R?^@TTowcQN&Q9M+1m(3?~M&4qptvk6qgSbQA6bdT-U2 zbF$-apZz%P(n9Q@=)(R3ED=3?SC2n!l9GpSR$gOy=LH&)@8r;!1@$y-l|`+DsONjk z(~%x3(fJJ&2H0qK{K+n>JCZ2p!^!+=;}*+x6T$wjt9G@OOqCZ!tAF9jAC!c@-w&e+383RJ8`nima-$w%{)bAcSM5i;FIN(QeOgh^ zHm#I}Bw88o96QuD)kJOZsQCQw>A*-@Yff4p`$mA8G6~`PA+^22$>io9@ayLBh{$BE z^A|i%9UF(qxsBzj<~b_h;Me|Qg=ngJg!4+@Qu+XW2X&@bwAu zor7A`4Qv&7%1!`PSDuiW>n77plFYI9mb^uPU*4i}w`zUoimJ#_f}DgUXsfj0BH!&9 z1&RJ4IlN-H-N8g%VG>9}-7I>XRrA0XN%%ofQ!Qd9?^nL8)R3(VNw9{QVC}l^Jt}9L zR&=}{srVL$A7}}hzK{}$@)`{1-|`JY7L=qe3s5aht!d{Z+LG$cO`YP~8`4Ci-UQs* z1~4iYYq?`u3e`t5<)a8<-+$Il~M7T?RJ@Xb1-Se-=@aK|HHgu%)ez!yU+`6i(B5o@N)E=x#B zyq~6^I%7c9>Z^_Qie0I0AVEbm6f%?RZKd+UA!Dtbo%ljYlu-lQScW9pJLY@3+y@}~ z$pD zb~lpmk=_;R)i*Z1hIf@sVW#u-MyD;mIA_0g30 zY$y=^&zHVq@5dGd88+}~#+w>HY~<>^(CQ&ApooOcnAtZzQ5`FnSX`s{krsJlhigqp zoh&zr-Cb=-YEA00QF-6mWPfv2ZsQos;7wu4ZmLaNr95b>M>8{YZHq))oT(n+Z?$n?NjXUnz?(A|_Vqh`7X0 z-!9eMDUn)YfC*h?t-4R68!YH=3RfoQ&<2BAdM)~Mi?24dT_C{uOQoPr9Bf1ScoWKL zB>hC*rus{@3T@^S3B8VwF@F6eNO{+*h5; zc+hokHO@$=N5lA=zWF2<}taHY<46*I&wtsQ8E z{}h-e)sVqyiOFO4a?sz-E0a{_pLpOk(NS;tuizp2`G<%?l6iXUSvqD7ZF-viQC@F%M*5Y?73UGfK9)v$ zgYWE?(nv-7Ny*Q+jpXN3-4phE#c{Hzw-{rRNF-cL#r22f;B>{{Sh1Yi&W0RMtpm7= zaY(Vvc<&I;IH1gTRTo)vS1!HFIBqt+n{N)^OD^`TZ&mB}dlls32)RbJuO zX>-TCmWEYy9k|9|5Xd`~Hz}fz6WBW5cr2`1Uv7V_YMvjO?tmG|9=c#_&@pk^Ey)qt*&=as44EiPNaz+AHeIc=-(i}}g;^p> z4m>pH#(nU9TWBJ1s6dsEx-)J!ynCr%Y@U|b=b$w)f$M#fH^Xe-4W{^UqKoi3%D8F* zD-_$3gRK=hVLmdKA^M2P0xU_wQ4cUkhfeqp9VG?AK7pvJq49QZV4C$bHLg^EMP?Hh zGEedMbo=pS^Do6P6AlsZnR%A8+k~hWHAzLp3}zhAK!qJsU49PpwclHSHA9tzis;oF zM4m0pKgj~pV+lGB^f0z|f?BoloZ z<9g;3?*~C%#)G-{J)H_c%bQ*GsOod_u1H<`8lDWr5pUFi{;sY6u4*q|B8Qmt&=Bq@E-WxvsihwTmXHm35Y z%L*QOj<8p^Y?^W)O3g(6(sD5S$H#b@)%s(=#IN^Cei}=D5L7)2tx}~939owCnqFbA z!kS$wTBT>7$tWq6R-wTCW&NU+pQo+Sf+o+ zf^V$HxA``G8^1TZqXbyXcrIP$;SA)kOtVnyYH2^fj0>p+LBz-Fw8qNu-5PKsn*Sg? zSfQ8?*iH!s0Y>W<2U0UtnxHt2`vXf(02Q=_e-KQjd@K%ONwQh;xtj_rDLG{~z~u<2 zU{oAqB6aMg?Pn@@m8DSWQIf})EL;=rO7#G#@^bGKzhj-@+n-KpXA%GWq(08=agt9c zv%vb2NRzz2ZSvnb!;Gj)`vYt_SFDuk4Snv7FGW0KrkU5Pb}M6#+QH4x*JW;Sq@rcQ z%7^IG85=PDF{z+;G{gE1k~^A~uAeulrf7vvfS=w->FE z+S~5kkzM}0{N~4iNvXjutO|J&^sV`){bd=xxBQ@a$w1T2#mFV|@u|2PiavMTqNHGK zVB3-Bui8t^Z9hx+ZtYy@$Kq;WY`i1=JkDioT4a~({mMJ>pBTv?b0}l(L*}Dh;b#uc z`$KISoa7mm(U@{oCl7TYE@18M19?x_qtRQE^n!ODrHVAd;kojTAv&G*(ubzp9 zr9)_XGHWh~O)m)OnvO5Z?Q_ve(oDCyUd+yjza6XWm}^jS6M4^ zFg=hP4Milb#DlGj$_Q}7N&5F?+$;Zdm=6jSp>^$8`A|6={E(-|lIB-Lz2k}t&vp%p z5Bd3-Jy=5cO2n3zOD$lZ-Na6wm$lab7ZN90wMh90H@K8ROJT3Wwn+x>1Ts)worzgx zq_M$1nJXtHq(Th+$D>~y8Ri{*geD-2L20ypj%scp8gT@u zk0K6Wt7NOh3<1Q_RIyN(6@vOFS30mX9*^GiInGRBt$2)SOAPo7*NyhnNPFN_%E97+ z@eH~qvtG5FYR&El=jTU9lBAcxFaxjlFMz>vdKCg*al+LSVsEq9IfiXIQp}7HT`YRGI8lz_hCLVsN;*)zj-72V+qEbe_p2#l}}UDh!wjwZbx> z5kk-qd5AIt_m@n}4bh@ImggO^{_k|DL2UDi;_QsFLyw1eE&!`72 zf(I2c&AV9v&h?|y&jT*CXbn@x55t_SflJnrce1&6q88L|Jub6$C~WaVD3L!5hS2)H z>|sqcrOVz!oT_vD&+Iakk@i?b1YkyYV~D-oqk-i9ruC(FO%Hb$W&&;2t>c_MrLB|F zjp_i1#Ka)AwY>6ld@)^gnpezeNU;$`@F{U#W#!p)_O@2C7Dj2THhF?6r9qSvpo^w3y|zkoK*orhI?tr=XfFu>3>VUknM?x3mX%CutbO&O=` zw}T=WQESlIJSTH)+P#&Xl;`^|M3W4V+6YPz5!>XSTCIVbVql*%Oq73CFH`+3R1X&# zk5)YWgFVObeIl**Gy}1i$ksbkuh)9K=kiuW9ww z_67vKT3NjeO7HS!3Om|WTRzP+dQnt5@Pa?;aPV`UUz80&mkA~5le%&bK&%$ZKY1U5 zfn%&(c?SrSrVg>|{!mi}K;6a$STFVu!iUXBxq<6J!pnxY%g^$$YFzBj-LR3OB@!d1%+{6UyJ43r9m@CJDK8%x#CNbgsc3p_(hvF)JN{`Q}d z6Ytal3L-KLDfS%lVVcg%_4@wZjvBx)*!(@2#OV60@dBg8Ek*$ zjP|DziXLtwU#@`&FU%Ef{Phn)KE1X??Rzn7ZfE4pa@8hibU%a{{2&w<>;u{)DIoFx zw%Yx#z(^+?-4*G0_AB`!Scv{JKL~jfiSXbsSO-_=F!{isVQaONe>SK7AWUh)P!eF$ z&cLLd8O95nvytArd*Pk2Za_h-$|tBwKB?n5Gl^f}cUS66tbSn(6d5=ult|3~q&__X zA8AMV=Nl44R9L~Bcb2122hpk-O=%ob&5Qd%SP$ckNkDpKsxH!WH7nAAqk5{7)=3R0)4CVx5nsnbO=O299|Xgzr! z7SeQ(@n@`GVPCwV{&!6}fTPY7f9Rx|2Rr_(odQNMnkf4~KvJ4OR9f{CL3eV${}M57 z3V!q{3*|nRi-XA)SQ1m@%@W=@czHQZNhe;W4gVl?JCHuu%#;_EaK>Rvtv#byh4dD& zX2T%d#rp`VOFR*3@f{k-W`-OR&za5wYBl(l9M*Q&1ag?O6ATN;f1Lnk8UvLs6fxZj zRwg3jvan{Q1HRXR)ODAD5IN|@Vf->&F^GJLG9NbdEjbYDi1+<}Hp9PrLS3N+pS>OV zX9A&96xLGsz@tb4?1*_lP{BY%xjzVBZZ>1(G2MfT34U9hov>J@e*=>q7uvW1c;y}6ynps@@ zL4+rq`dDlmXg0$#kv{I2S&SbYs$a-Nd^wgWQ%FK$R64j3dXxt;y&?7H!9*hFR6Drr zS*Ba(RMhU%X&3i7Eihx8%stjWFj~=7a6vwOHsL|fsNp+q#F^|KU!NK^MJQAa@WFWb zQ9cdgdJt($h#*+9?oj(uJ}y>J;3uw>Q>obcTs}8OsID@?1VA6}f0=bU?VcgZI)97? z90|7&|6eOg2MZr3w~Im5(?~{&qq#*P^(jw~x-F*h_vBv{zKtLMJ`RnDCpaiz(4Ud% z<(#5!l`eU~55m`xEaK2uBc}|KqPg;!W0uMCQ0A!CGIH<0M%uLG}>7h^sae# zLt<SDxh%6@cczX@S;Riut zHUZS1IIem}!u;VC-;1+|e+lDgnh1k~nNg)DB^XOD2TJD>l#i-6fEcQTR!>|M)ICj3 zq5rhS(%G#WcPZZJ^eTzqflE>SGTt)WP7Jye&hjf*h|I{P{Tz8&V48_ZhehFv_Ug@!L$1ya zEx>k(5V7a)K9V%vmlZu^*6lbGepher3yx!FrQf5`Xi_`(+6+CmI;R&4*=ciq)(WatA*3Rh53uODk(1$+&W; z>Jg-9Q^&#RYuG9)D56Z75!;gaO&oG<%8rsm(whi3Ck+YX$yOt_f@F%=T$XcNVtjOZ z*c(fFfW@c;zL^q_1eWt->_65o^L+#oJF@|#!8+p#S6P%_3zh0dLl&4V^qcV{01`9P z>uz8LqTDN-`V9So``oLL&MBPa=wp$JLvQG-o-pZ0DrDB#nwd{W>X6bvuPbQ=JKxFI z1s~N9h$FBgRW%5DRM>x5HMcwu-}6HGA3Wk=(*yjNb^4$+8d85(2R|>~;Z!f*Hc=w0 zDoklmrpPmg)B@Lhyd70=Z1mThmpOy0$;(SS&$ij`IRB6k)h|1&(v60lS;#tqogbpc zPYm(mc*K)3UcwYnXMou*j^Oum=ioqAc)eh5X>}zUk*t-({ak6mwffJk{vIsVCEh&3 z=_Vp|odpc}UD7b+ffjWMf|+*X`b0S_eM~@r?+!F!ynDz(hcpY{)j5vuEyRct(}dHq z^eay)Jp=tU(=C!KcVvT+9Z9**Om%M^pBylv@rqtJa)H0}a!7#_lSO$@6|Du-2tYr@ z#Wp}hiiS!IYAqHq-->CmEE{0eFK6q?)>fhI;`f6~WKVoeI{JPtZL-@fn_s=EqkpgA z=X7}?@lZusslnUqAe+~@1IGrds>TT<8+MHl^(OW6Rdf01P(3i#Lbvl$%+76w27O^} z@^Un_eqt^!#j<%G{Y(Fjr^OdVl8Ulh;SY96j(E>49iT`J3>gid)9HC$p1rgIx`Az; zR;dd767)QRFBZtlv1LQN-Z8=8&scu@aDvX zL@T=&_FE*GyFt+^rYjk5cL5s%>vi!7flM)*qYc- z?X<8i4`jNqR6biDD-e6);S7;WRdAq1xPqv@zppF(ym&y}r}N_rnSQpo|KjP^)Fkc{ zXQ7v;=a|U~na<~@YCY^84*IJpfGAwp?M>sTLFEd=sqNA725VZb__**FE;2TIrNI>&t7cs!;GWw+pm-Dgmx@v2b zR&PwNvck@RNdk0Lj3ksa+xyQ#m(hPrzFg@6@vG6m2l4XId z;iZC`@=-zU$rUe29e)K?55p-ue>-jMOlnL;w{#UH=863|vPomBbzCkMOfv#hxQ@K8 zgKg0Bi&Tpr`AT8bWGm#aY~G8eWzCq|pt>6M4;*ZhO$s97^$Es@L{S#1dFw@#Igk~g zX9`}Q&*MZ3YQI%4?y?VLglOR_53-JTu$D<5B_9LpfhyvWCT08IB zPevPYqLz*@NUSh)5_ayeW9i^`(9njX~o(B8-GtM?#&hL`Qp9ca}1nyFqXD3=;(Q0=QB?}eH+|%{oB4y#-Wp%_j3k!E$K|@b77n}bTxVq z%{~W;GtVeea7Ab1SlmP0Qkkz>5(GMfP~7n;w%$a)V^xPGcM;R2!RwUe79lSl_Y=Qu zqVg{_4JMsGA3~StSie1#?$$C~QDm51m>*;tIY3~AJD9|x4<%3-a+$NJa-l3%Y?@q( zsNpWqp0~rM;hwhaLc+%Lqh2Pj9=$8qPj}0%_t1|>`|RW`9LY#2{CK+{@asTeM&a$x zcQ1z0f7Y6`f^;18upb2k7izTu+1?)UgV1yrY<_^SoH*k^%ox)@SZjf0(->=R9JE%x z;8wcd58%xnXOp~+JNk)Jw(TkIT)+af{Yu)!k|-_l<`yfW!F$}X#bOWov?z-iE7=6} z-9Ec13o(Iv2Z8d8haD555ftSDY7YbrTBJG1_sJHmug2$xh?uzrPOhdbaD9ONh19xg zrFOj`&MkZj@x`3YXh@G&6U%143yZZFIXi(le(SH!x8h>X{rufDXeHS#b4;(FucrJN zjtThvu`hoRKE<#FKxvNwSlyfxO-bP;a*A#6#p&`<_^RtHZy+xqiOcJ$iJbC?tN5Ax zQJTIbX#pQHv++Z1<6$otQw;jiYQT;mTYNYRJ4Krp=sUObQO7x!PN3K*b#{Piam6jy z4tpDVn3|l5JAtF2aSi;-NR+A~@nVE{f9mk}gIaLTYd2Jy!4G|v97L35GE(-I(zl#H=Q)}N=dFku7-Pn{)w`nx` zD>!`VUqZhS^X!h$<4@dPGi<>Y?rKufh1i_~TH3Y#3;8$9^dxou;m z|C*1F^L;k1)u9|Hx$ctL|8iy7WkXN5W3!s^1!D(cwfHz0>y-`jd8n(!EFF(}YqCjE z8+7z*;K!b~Tc*ZWkRLu6^N_QRQX3aw{hsLE_!ODxo(`?6VW3TWtU8x2SyPNG^)g!a zMADH4YPEht{`DNp?l;O6k{;|`5t&6T@`Au!rl}HL?BV!^_dpE?TynrnURun z^74C@s8&5sm$M8zK-CZY?c!0@wxae;A)uMLQx?;cnPTA1YY#U&R;sj;n&Yl#o>0XT z+Q)&s=sXK7N9;YG2!c2R@-j>)zNWIpJW*AfubjZBtY1`<{Mg!v6APpH_BTA19&I*wm-AJ)BM;UPGW906oU(4^hp%50u=% zW^+!4#AViS7FxZcE*^u7RgySemKwj&-kWhUg1G4$|R&>%_%Ve5=28ZqNb)mbz%5>&cmzBRPy?N$^1Cc{H!NJU% zrC=mxf57}Dq*xBY-d`l8iqpy^+a#|@b65w_bbM_^#1*TzdA`QK4b|S<^!O1iAgy|h znSVs#2qmbZZFR>JgQL%i+NXB7&?>tecErk`z`S0Cq7LA#nW*6zshT0VIHMk?$)aGl zPFI*-&jG@T0**TKVg0?ue{iIT#vt~}nwFad7H#34k(5c zhIlo5ESuw0#vNe;l=^$i(S%fdu52Dv*EzrBS>NdN=X6o?0=n(JJ7U>|-+Kn=0}q1-&`aXUz+zhv$DXT+)@Mh7HdI>S2^;9`z8?oF( z_HZnISkiAGo63zlEnDoXTh=`xeJVnw&DTDl(%f6263_pr%Zc)%*bTVZ&e)6(qjc56 zkSoX>{IEC-HAy1I+dCCb5N2l(ty^c_PVrtT4-EmP&%SH$&ICpzmxTR znR4$tejQVGQjeO!<+A-Y$daMlYYvf++|+SF*nfh3d?&*nd2`Bp-mI_q%f#>N{#<{_#Ty+O$0N%I53MvtSC?Uha@ zxeo&?(&d@Q@9u0f;sX=y*G!nlejtBbQeICnVC$OVOSbWCBVs>4VJ(A#W#7B3mv3uJtabD%wvt6cLqIO2*swExz6~DIK{$K3YE;XK` zV~@yg2~>-~1aj+aCsNrfnG@d6(cvplFkdh?+&sj$E99SEV0;WDnnr&;{(fJpSFhKu z9nx{sv%lGv!2W>LZ-(&bSo5?E4{~l|1!fMO&c6b=Y)4fG?Rl=CxkoT$kjPeNw^4LU z;gySUyCsF=eFt*okp()m-5vI=IiSZmE$5iOgHnR|qZT3I8fjHoLfXj9LC+w=i!Xhg z4uJI{Ko{VSX232o$yO=={+8k-5X_1~O$s2bgj~09%}L1`iPvVHD8Dk-N{4>Dlbci8 z1{_%fCjH@qMO?Z;+NHC1&sCpkcvHOlx|)LYf6FL+e~b>R=5{LlSx1ylO1V9;btJV) ze~Q#XoUA<5-oVqHayeFFgT|$$8bA65ilj$h7PtgveKMJUv;T1G(-Ps*QLjHtjJgV; z4)t{yW8Zhyt_Q{r>XTg6iUc^tjYw-1W(r4ASR0~Z2);sD;Tz~KA^!wFE5c_htL%Ny zG5JC)^l2ckM0ka@W5{AI>nwIgrgz|ZJ4@S7+_Y__N+22cHSL%*)^d%d%4Qq-3}4}X zF1<@09j;G)BzWL6=r!tL4cJo0$12$>mF|cu(K8G>lTG_=1HF>hG{#1u!HVPMXNv!P z0z@Z*hB3^->R95Uz^>wBgZ%T%XANtz-`MRGk%mWPhlVAt6A(rx8E}?m%OIjyx(SaQ z(&k)kXDSqXu+Je$+pq+;bipC4W9;WHV)AL3(`c%MB6U+{E1!js2a>tg(a^-DxFuf& z-}kGkhl096T_~TAJWs7l!V#P3umV@_MnhmE`D8*wpI*thU0c>Rsc2rzZ9x{TO{Rs3{0l9hopiL+5Y}K}-ZTR{JqlWVP zIhd2AxlU++-TU%(e|coVpJ5i-7}5|i{gL;$WXsFawvuF{rrmOf6(waE{=oE?HdD)o zg1g@Dp_+Tgpk9Y050aI|kz!S=p>mp;396hFB55hMfuc8ZHLYxLOKVt?#pDQ1k-W4l zQSm!Vjn{r@vsf^)dE`R%s?tsl1n=oi1V!W!kkO66`8|TYMqdeN^SJh*D7VHIsVC|R zG4CU?5I!kEN1RFuwB7wQrW>;0SG?=H$;&&1{ETeXpWiN>z9hdJs9*KaIdaoOsG!{4 zEiD;1Rii!xpo8fIuJ0lj!n6i4sR9#%@;~-8@m`kt(7=W62Cwv(>Q&fD2z>@j8xHpF z%JGFhp9_9R+Ok{286o>i+9s8k0yY^MAMM}Oy6bP9O__`h(NQ)EXE5AMf$Hx5!e#bDaa-Htg6?G1Xv{HouzWDyA6tGHHkEZ9iY8b6-RL%*EKQ{!-wJR;$jk za+l-*{b>c&V1XdqY_k?1Dn$|a!gkfXg$jg4?y5FpH<8q7;JSq|&SAeY1r1~iAoUv1 zJKlA2ueVzXTH2iwUZtm6bn-z-U`XLWz~hVlIlj$0aZPo;Nre)^B&UzAngj8bhJ*5k zN*8-L)ZA#%`(XtZDEcFQtYKOZV;=?W6Wvg;u=*>xRkDM&6Qw1F7Y4QSy^cAqeaW>- zY7evXH|Q&D$c}LhU7#`16AC3V@H|bJN;YdrvOEeS4UuMzQRYob5f7PGyuF>wskRMk zS1ov4=@hXkmm7F*VTBKUZ>Dogc+_taMSaAjb=!JU7mQ{!7KJP}$Y{(p@6i;@6bWf4 z;F(FP0Yp8V)7w9!b&X5{ftB>6DOauJ+3_#n&TUqKOUrAd5fK+7fey1=-@*yHb#$iMc>4robA-`)*U- zJIAt3<7ikl_pKeSk&|ta_-9g+CB?1H*Q!@AsrBU?-Hn!f9WI}qDYd1~!HrFB`-zi# z?bn>S??}*UgnN**45__T%8lmCx17M%6EB0TD6LnDBNs!1$(palF3od-RQ9n4%4xc} z4R+0%7_V`w_g+7`b)o6yb5ofqW-isd7u}my#UARk9I$rXsQ&%TOy}y4PW%=O7tlE? z%74+h=ZIhimN*J25EpyD9|SXKI)@9)wu3iykOer~*a09@4pHeI0En|g)%%0+EtoeA zTWMCFI{FTXSrBa7TNU-IJOzG<+zrTD7;9M;ancjI*vnOO!1Gvt6=q3=^a_F~)74}$ zz**T2q#+|%67bM&VM+iG#wU1zH05!XRTlC0fd3NRLN?~V7>*K5o#?XgOCqUD+h({*h0w(yz12-i42C@%=s?=fZs2@j3ZMHc|0AeLReJd@0iyFi0fBN>5$%5veurYcVpT1r)I*iV zZe9$Huuw3mL12GGYRf2JGB`ba)n|y4Jt;%bn_932n#a667s16n?mLdZU$k;Jfcoq>RGX_Q8tfT~U6swO4 zr!z6wEPqY^7l|XvZ$Sb7KY#O1hbXKqqB+y~mFuCD3no_M2Vq@12DiR{6WR_dM3QcU ze%0m#%0Ip>ym1`}TSn%?#618L*w6XG5Ec=Y_cq-DIG2>PPJOx^ruyrDr`ieEq*|wrtoVwHn9=3*?zVdE4hO;PKzCeNtBj1M2r45lw`A-^;C|DYkOr zNz*T(ycJMWj{x=QnPcqE`(W<75VnYc41$vV(`N`sZX5ww&wK8H7sB6YP+-!Qp<1f@ z2ud}CcHz8#A^Dgi$eVg7ptMlTLOy>L!Sc<3$Nvw65xEGa{j5KJ3+W$I2>^nJmr?Zd z)D<%RG^1dZ=3}ou<=%85nC-VGe87E zNEdeRS-Qd*8O@b}(Bbuz|MtO?#$jX4u7Rx|2$N!uj5i{uxyplV$aTIB!EC!#ZgvnX z_SWLdpwg~-O&0O1$-iczOXWG!P&J?3UcC(%A2l($d@EduuPK4j!-7k{v6fe$OU5hw z6D@@c`kg{AMm*V@Z{|#Ygwc1kFs7os8>h=kSNkpl@}DyjIO$8?8pIb)c^o88sH>I% zh&K!gq_4{BJT!GCZ`E?rf%LaQV)X(Tq5q6?(%!?=J|;twKT||G>7)gPrBQm4Yle+~ zsEC}pk8g$!mSYKz1|1)o#-3E(dw~8DQ4uGI_d~<06LPVmIxaQS>HN2@muAqh^8|zn zStZ^~DvrdNL87i8btF~nJc3G}0wL>sc1N|2l3Pt?hRC}rWt!Ovx4>#!*b{N(`Pj`b z<~zR~>zuGuXe*7p+9w!8F^$7@70q$MCB}zq7mXa1S8PGg48|&x1N|Q*o1G?nUmHz1 ziilDM!TF9!Vk+csTf?A@BK4Y)ctYS!)WkT4yYbKhmmCW|$idp}kut)QIZ0_16A7%7 z_k->kR(5_a*|CkS{uP8fTsx)Q8$3e{bq5~Cil|CfNm$+5S$2}&~bp&Nq3ui=VD#8yIV>%#3V7eL0T#fou( zq=}hIB9ghlF`Hyruxw&?tT@swRcXplagdrR)Rw*6pN$y<8C$jKO&9-D-N#CJ5z z6eKa!XKtRDKSvQgh_d{l2%72uQq9{KCx>XpxL0>6Ja~6=LwfhTH1@2bbZU#e^V3oG z=E%j>T^8q_l*|oNYVCifBXOlVS9am%{#-(oQWm1pIRVei#@Au~LrBs=Ph1Oo#3G8v zAfMhOB3@BZpjV}OH8bPwzGSIwTsp`W7O;iBL*T;lN2gB6xuMklkoVTRPQ0z!i&GR0c6%!xnguNHU6HVY&Cn~b6H3uamg52 z@0l!zr&C#OE9Jz9TPoK}#*_z0^Bx8k(fe*o@7>8R{9F{6J7UvUs7y^X2fc+*CD;&A zKMh+?8o!L{351~BJkHi(?M7|*I$J7t4HepmhEpotB)p{oCd3lr+E+uKB|7vPlO+mT zJZpVGNT@Dx^F8U6%A_|r{Qf`o9=0@o$iZATnj;k4Cqk6FtvN56oXa-Cjw?eh+g(a!=nUvUS3Kl9#Zn9zp^R!qY65?WJ zYMrfa_UbYodMBvQCJ~lH-6k$fx@TniLQ(jmO&FhQ^4kOZ4JMh) z(t+#bI%}6gpLv+3Oq|gWj7PvQoOPu&n3(<_&fYt$sq_6GhoT}P#KBMy;znh)%BX-u zMMaBuzW>Wibhz(M$9lu_R3`mR}# zQQ$`{%W8JXxY4@L*BN^uf)F|5f$ngfOh@kX*fs>`8Du5~ty6$vVs^iEXxScnaWyE+@ zzLiR(74mUAm}Wc-sV8wl9jxM!&p;oDVJAi8QaHgC?oU;<0mjEGUes4U%b-?fLn=CQ zf{JdOnSz6UQ7ck-y`HIb31Jf=XA(s#xD!0BF~FKVq+w(bjdjxlbZ*Xb+3}c%6<5Ms zMhi~C7$^z zxEjQpFo;9_5t4Lk10~lt+`!JhaVL5MEJ8=W9>^`fk@l+1of^=$wX#=S)jRc9@~fWF z(e;G;yo&;KyXzDeeyaw%sfRHjGWjA%PGhJiPdRPLE=wNCJhFW@tTN4 z9Web0IiRT+Qe`;Vokds)QfCvueUQto49BkJ*K9LP4BNooQ{f}8cit=yPjh`kcjfS9 z9)_(Ylof%YE74rCTYP`nOHzh4YLWQT12zAPU9H(9f*l+=J{5HuRmn4@jSnP1`e3{^ zYUlqNF+7tH=ooX(ucv;GSuDQtqw{J=R(IM{Kx-x$hGy7307Fj~F^_;2eD>;h+Fcf^ z0NXgJht@uRr^+xh>7G#VEo(iW7m9>lBPC7OyHg}@==PT$gWF-rz^G}m z$hTHa1XLD`Cgv^~+K}Rk9LQQ- zj#LF2Kf*SZqUNp{f>X%Ib2cRi!_%~=rY3Pr3iFxShO%+_Z_bvSEx8oUyUO*(tv$owf zCQH%F{>XcdL|HdRHxUb#qvuWxTGg9nzg1%flK$uN&>T**5D!tD4}i76XgnyT+e~L+35Obk^LW zJ4PA>XKhBfy~C}!p*z$S`X8`$58?!cr7QS8()#MG0bdJT9q%+SywrYa^??~m z%z5Oe(NC}5o$lBd6>@2DXI0LH*x>vFk^d$&d`&xtFB^npkt%R>;Gt%kt$RG1{!ue1 z!|Y2?w(LV2;Z{Zg`w4w#T6@cULQW*by0t=Py&S&RAvr4<910)WYSnKzcUe56M=J{m ziR9^rl>3A*h;Zv>|3erPEU@Y^lYY{T`k+P3>cPoQ>F%vy4E)%GK-2a>Z6((0COBK~ zUP;NN?P`r8KNH+2t=}bWaR1ep7S&>CYKzc~>^OT0a_y7pB$LX}fo$SyHn!3qY+&Rl$J~l77HDgn1cw7yIxRGwStLOeLYYH17c~-_55*s=w@NVO+z)} z8AcBgxd<$8o+23CAUBq^-`a@k%L#n4Hmq$atRW~NSx{IL))AMFo!=^7Qkfr|F~jF+ zgR3!a+;ec0S3KNWoR4a`wxm$)?+#o`4qx~iRUJv3C(_1T<+4bfx)Rmr~eeN=IAfOd1EHGn@J%PPvbgSOF16LZ%vH&51g z-JCBmZb#&{1qtetfP1!M#*y%jZBpIAzoJbjZp#ZYk!Y#M4d=CQQlr{4m`$v;D7&@M z8WF{bb%ld>tm^lapqkBsC<%Gl%iQDgFh%-hCKw+t4WzM8RwVKr?0Cgvdx;UuCOw80 zw3f|{J5=||7MSykRH|&c7UHNN_d7*N)x{$uSt~p4%Omra`H|Fjt*qN$IPnh5+l|^7 zxxTmFI!x+gJZK<~sX%&-n$)~<8{z!KE0@l0-3VLs-F#^e>m zNp#FwnA*--^nHD^sRnEF?+Ly$-3}$maI2|#LwWB>a>bZeZ2~va;6Z^)NPfs4l!75Y zzZUh+oT*H><0Mrr@IS6a#Z{4ogR;?5x!YbuXX<^CgsQtwLRP0~^RF1S&gz8N)m)a$ zB;a~TLTWK3>AYoYd_iUL5E(qn4Wp{B37njCP~Ce5%L4gQNcy` zAa%_;Vs|657TsBuJu65I+=zBcJhTNXGx=Ezakt(atka;5`f&{ug0?67$qoCI9vtt&K#$i-H#J53imN05g&_UV$9?NJcN08q& zXNXOtp>|w%C{d!xUsJ^!wg!0Y(63pGk>;^R0$P(xx0GwP5|)R^4p%7+66G4cePCO& zZrp*DQq@7-(Hr=@4?5C4`?zZg4TTNwdOFP2n7i3X06T$=c(Gt{tYHv3;cmO35A*4P zeYWhy6m5jk)~Jp*RfDyQCFvw|H#d7-5XdnCHI!|4m0(TCpdrKbg*?t(tPBX8=O0J7 zt!=qQK4rg!y(DRE(|C$l-@pRo_HCmpz=elE7!S0N53=e#a%j7nvJASj?frPSu+!3* zlY<8lJifsHtL5_vrB6qNZ-fUQd^XFnwaA{%CX5{u;5u6uHn_J!zqg%I=8)sD45?7- zeeVpExub4%YtJWbzV~_z8qu3k1BA;L|2+F>l0&sLI(0cL@{-mOAC1rUQg3W^I=+mk zT*eTQm+~&?X!2|O)2FCqlq?;&;pAtjbu7N^;YExbQ*!3J3{tX_hAeeIX;~`Cy_@T1 za?V}uTq-?@R znS{H#*aIDbKM%kRpR6jjbaIu)DWquK)8I6QLBF5kHF+^j&}3-fSCEs3_DTlw3q$;f z@e$zX2x9Om7a{%SmtG-L?E zD_#o;zkEvlZ>7?K0mDO2!O(qtS-ylk1Q~JMK71|ymPBy+{#dFsm3X>}`qdlSK1LG zqahBq35OBe?3#ZGTk<%$rfd*2U5aJ!v~FVF=p&gZNwlhQEn@^W=CYb|a#EYoROcOD z@kC8Ve|~DWQA0sZP%+J|ovd5$Df!!pR7yMfdcv)ru2q5_#I`?0uXWD)RPElVHZQ|B z0)7_@$5FS~k12IxbcnEbPc)L?{~dvO-(qi7E-8Fw6>V6djny*RWMx#M?zPM4z;0pWA} znkx*#ea~K-Xu`@R0}*wOA8Pv5NI66nuabqC6xlbClJ8ECtF2$Mh{2s=@=6dy(#*w; zETm&ES|Lr{7E<}h^#B@O41ENsQm=C}$@-$9BykT2;dL~;U)27=m}RcCWeyGQMXuZxa}st9jWM>Ocjf*qeu+2h^{rDP^%KU2U(TDnf0&JC z`V7|lWE?FF><^0=F^F;G*SdxDpy{67)Aeo=^8mWWv>+8im#LV(p}4)7Wr}&*vDF*5 z`D4z=)Lb%`r~#QZNzyQ?bnA<}kR3G+gV@GvFbFn9Px8rNVz$LPGQP`_`(Tj!d&J*W zZ0!)pL*v+_YM)d?p?DaCJL;=~T1i~!yizV|Aq`C&MXzSl_aIAn6p8taG3E=VdJ+%9 zM3zNbn~9f_A82_00K!YL=7xFh@8Lu|at$1T9I1j~%C(&h!*9v1i`8P>mEfmb$wp3c zhN8qlEmRh6mID7JYGD?LN{~Vi$i}wGXOf*zNc@ko7wBKCl|(!%(tokS=tHOl?cA zqmxd?r8LjhQJeDE_auWr%nS~i+mIG=me*q$@o;nbGkHN>en{48WbPLCkFbb0)|f{b zSdQ7@Yt-hW7qY(yxbB4>=J%}R`N1h)r5gk{3@YtEf}c5iZN0J5(U)e($E{Lc4zSEa zuR*aXL6|S8;9)R`8wKAPm}E zy-r3hrxk%?w?RJ)vxvfI3AKqcH0;~O^dbxnorh%XzD-psC!5lB^1yYl5gxo5W$BUKR2~k8%Uv zP!yL&(5T~slR5IGnxA_ARY48@7x?7;&%h`2(f>E_$^3F0`#%Fdjs7bg1n1`rfK*M0 z3)oEF=YO%8pa%=s%p1UF7>q8>Ilz&YkpEF@ufg<~cpd&bd)`{{TQ*_(5LmnlP#Xb$ zm_79c=E^BC0DC=npvLs|X3IaoCK3qk@yEgilxP8`>E+-$Cjs0!3Jqb0ppC8C9Sr9E zJh*xA0slfiE)F{d(3<@rZ&16Mh^>1B0G^ks@*lP75$2Z?=fw*)jG5x5`@!Ux2!W{Lpvw0H1e^wekDCw&2uOjT}W4*f}oGqOKl zvVcg3B&*@>@OJZfpMGJZ_EETCKU}-tCdF<5r8oX-g}cKWD}h>hQ4mZbT|r1^oeW53 zDBz=+NcRsHHZ=}$vkHwSJ{4f+}XozrBkI#dQKKJX$Q zuf%oK;V*ur!=715M4_B59#n(@ntc=Wrh8zMgzI<}zyq|?99U2D@ibu5Zw}T_M!>=I z4XARd@K0_s-_$5~F~@pk`D)ClU;c$-x$I9@h)4iI=@bA{crTAB&;M!}w=j|5b}WN4 z1>=CAq+;gy3*@{S^I!MAyPho|!L_E4=fhrr9Fm7GS1mlzg@A|3cW6535mzgMKzwYi zcn>K0R#iEm=mX4PYt^Ynt879$8@0{EPv|3qc!y>?L3K25VP(QlX+M0!$IMqYBykQ; zagN7j;!jwn$2IvpB#gCyrhg4+I^gwyrcdBZRfPHkWD64hKHxe9xBiFZVeoWgYISM; zP5c*V1)U^3^l58};BUh!Yo$gAbKYgK8gm;Dx$*(ajGBbAJ}9MtIj$)MaseQsC>`#T zfbTWC zB^~g$*I_xj*yDM`DoX&o;SOiv{lrhzbJ%Ks{LjL6=6ornb=Rul>Sz#5z)09y_$0GvC9KS$nqbj< zCAJ&Fl;N?-DF6HA0+b!zt-pEjZjrMV-Yr-M0=T|iq`xw8Q6LY=#0Bj6vnY2jaaadR zOg0iUgZ;{x+P!d7qy&|xXep?OAEK}lHw&a@s#}d#Ago@J12Jlvn9&A!VOmjm&w?`L zZUA5tUplC%gbT|H|u z&8b4O0QvjOV*2*}*+(%>=1d8BCA!s|Z9=-UQEu)LJ`eG8MR2-^MPUw?UU?91{gYl?Yh0vfWerwK z*ViS;Kg$(dyNQG*&`TaKiG=`M$4oB*rg!-#6`);m@! zm_QmPSccz=Hr`9ZDnL$5y~>}(j5Ys0-WE&6j91dXkxMsnTwBEL|(u28$$1a=f9vT(E` z#N$nY7z4&{%i>iZY5{#T8Lo0v;4mU->Y4IU|-vA|9JNBxfJ zCndk{p;`UsXNz*Tbx-bOo9=?w45lct7w|qv=b5?f?0}G)bX=OR%w?_a$!z24F}BnF z<<7-GYSZ;tab}z}w3Cvaap8fKvxbrZb&GB0p8duLduB}nubL$*PYhUDl!&tw*GWHs zn8gOj3bq9w=gV>n>7gnX-Q|f-NX=li!;(xHGo?D?&Wo!QiiJXWFn*v0@pkJjMwFqc3b>MBS5)zhA3=i2_ks z9kkbiTY)R&t~Z=cD=FB)I;8z}m5x8(bVF5dy*<*wuNk~%_2QiKqaxujrO_C1zRMZg z`F7}x$1>5!`9}#kRNkN5+JxhGvWXk;Kfp!UV~VQOLE~DpwyJ(%>_++Gfg)LhX@`sa zmut&D&KIOg{Y`exjvF1#fWV{^`4f%n>icadNmRoFgIjud)wBKq{(j6ffa* z%|4<5NnArjPFtr!T2g%&U~$x``*K1H5p3sZ(Z>;XYCS0=8w}K&z*)wY*4SC{(ePUP zP0ToFqI=9?B*i^{BF(P$H-q`tsZlYxp_fKO{Nks65fdL&f$04$=~>i*c1Wg5n6);f zkrk&^kL!bw6uz@Vf7MiU3vSYDQU8t9WSQ~|WOVgTeaQ^l)aFOHHLD_eWi|Uua@}1I z*7S00>)dpVhJDkIC+_dQ_fn&q6wCr!LQF4HQHFY#(O03c-+H4n?cnHH?;e>SfU=rc zxu_xEA-r+kG-nC;>r9+P9h=46_NHB47G`IU&)?46#qHr%E6h zjlK!3&sc6ZUlacq!l-=m);fNMs}wBvZ{Jc@eD3ks;E!t8z+1Xqp$FUf4G<5kMnAWa zxF4o!5Ru{`c1q0I ztGd!PUlL(BOqCQD3v~U*ukF?R+^Pfz8#w#KIiiYoJ{9$Z^Nu=oPzH?b<%5BJm|K{5 zBF4Olv9+ga>YT6JiKm^pEF;>vmMA03Qmi^D`f~ml^kpYQFOx$HI+PwK`@*NM?_6*c z++hu_>=cxE7^NsZ@E+V2Eft#(AY`(P-|Rh4z3Jrmn7Gp4cf`kcd{7an1D>3l`lm}Q ztvQc-GrbMR3S*v4yXn=Wy$|}4aN`MZgnBtJjr0YeRWLb()HJgfGqf3lfzC)2cuWf4 z`p{gPp9N>B$(@XrJh~ix>51LL!cgb0kpx}|zhK|xNYWdd-Y48U55xZmD)bi)wd&NW z(bA_DAgkpI#bY=PGo~uN=?pou&D=V1=4q!`wad2(8BNLsuQ#D^YY}rvio}w@pFiDJ z7^VN9v~E1TI^7}Guf=l1^W>o{do}dc_07rK&C<4QJo%|}wCNkdF_~p z7-^k=TXZ>Fgv|F3#$r)OVc@_QrcS_kX3mM6qDsa&0?7KgIJve3B;0WMyB*3@Y$tMh88E5tf-NFh|= zoHU=)CK9^Hh_{`0U?+O3VC+9nO0liWbTeiVJ~B+7zi8;L0zg5yxZrLsEy&|p-wf9z zBrF2dYRO^Ir~0|d_;la?NaKlYLgOTeZx@6H0S;{fj~dnU#tlanrNep2P}y4uFNUmIFST6+K3d|MqMwh4rv6&b3>jo&tbC*%+4 zpp3Vcex-uVH}tb_Y3t{eJGn|SS&L&HVrJHHed7EKB z$P8AQ32j=op66xS1by1Uh~Hs|6=Ili@yhYwIKIKW)!iV;u+GNO4)?BE)lUAg zUW=&`%dn{~jafH*<8pXbtuWu3L1j=_SjQ2usgD|;dS#kzV8BPjx5DLEw^5D3pqF8W z9RRe^RYqJ(yZ135wDxsTWQDz5OY%TlbK8J^b1JhiBPN4N|3p%lcc16I?GP~bWjc>=BYhkl02sM;%(^NniyL>Sez(@a4>+vC7 zU}0aqfX32^DEI`#T{m$Qw#rk1s47WRHXJk+@3ZGE6Q@j&51w*X3NM&!ijn2-6qUJS zh9co{k0Khs)p=*ndU<*hog6JAub{=bMF)5u?Zx&z9iJQBkm%XZ=+x??!kqb(H3Y1H z(t2GR1dEu;uG}lU3FDx1jkZqDeu@ujvu0jQ_|J`9vW>N=$Y-K|L>?S)jXX8I(#g zXA(`VdaF7$qe8Fl}2zT zO-{Ly62E+=meQigJ+_(FE6{VN*{*WkyX$Kjq8J6ow#Kvz_T<48s)T2$fq*%gS^(F*ghlWTx>QvhPmFXV+7tanl*@{y)k$ato z&R?>vN_X%oJV&eePU?7bBtvap2_*gnE19M<4BG&DB+7!~Z&>x%hAji5e7IxdNYMNEEY#VsPd682q7j;4kRc}ozi&`@&g9EqE5_5(yyjU$ z;peL;1=W}J)uP$$1k_>%)0^9`+LTzOQ~6jk{4jNdEu`Chn{wh%ogt|6`B1%Z*? zYEZWTI?0NKPK|XPlAvd=aL&se10X@8aXl*FUx_k0#*-43qQ?mFY{SQhYV`i9F`;2n zojs*^tQ^jR_*0_}FSCuDgY$*oHQ77`@EuIX+2vS;0h{A5lS# zHQ3CI7uKOnzdD3^Jpl84f);yM*P_PAy}=gT2-y^J9Bn^|%rq5=QOj_(YThKG>#{d#V(||w)h#-?nIjhI869f zl*=K8<)Fdc80Pu2XQ@SQdD_-fHs$3G*Pm0&y>4kx6>xIKJ_GkY-bIfDD%justSWN4 zBsZ7bo}YdobrWfN>pCw zR37GixM#+4FUfJCQ2F>laRjQh|3FQZ&F0oj{*4SOZud9^DIKw`NDU~(#u{xAQlc{m zyW8*5cqbA2TNYuFWG%Edn)DU@TY1~va|N`@L-pK&2#SA0sO5h`YV1CT`dwge{$sHk z%^Dm$@TCxC!fe`w>jv{o85Zb$F{g!>5QUq;ShAs%PLz|9zi45G#i6m^NP4A~_U+DE zh~;E8W8-sWz=PD$8wS;BHCMt+OKkdIR795(S{8s<<*@1(rua8Vmn)2CUV!g!YJ!k4 zJ!^^KOVN^#_*dN)^i4>VG_VqUTqsCyGt$H&pu2nr&Z-_YZW&(O6%iM5wjg)_4z!C- z-{kVoN-{MoDtp3aJJblR;C|OWc>{K^DD+4vF2L>J&ZppAeuOEvY|6w_Py!#3{-dTw zOC4^4@wrz{)Yj4x+5wu?hnGBU?}Kt`l0`{`%HxFmbWL0kdDWPMb7Fp|^R{}^mAw(A z+cf4_V$K5jUiTK%CC$VHKhwMLQ5N8JEIZPT=}1!TA&faNMvk{gjuGpOHBEE*BqV z8%D5%9I09R9tRR&dX+ON^_pd^*701+lY=e7W9(>T;$YVdND!N?btitF07JibZTXjh zq^UGHUGG}(_DJdYk^vc5()b0lkzg}SkLuYSk?M95?Nt(KmrwNBbZAe23NSS5TlB@% zqG0=)D-|A*xuJ&F44cBd54>pX`EYkKeA*_)^JLHaEUnGGU$lo0CBHvTcqJO%gfCSp zF9J)IOMD4&l6V zPWvP?`e-6wVgJ*F&^b}Reo$eQ^(f}@e&4fr`54P7^CD0Z%jWE z-i+8Hx;g497eQ`s8)=v&RkiauE3xHhx7g#BWlqR^p2Ox!7dSd-t6PHH(dZ~dI4pTz zDhc>Lh(_NdF@9w1-DL}+y6PH3`ya>se%72-{?GNpzw9&_NX(8PbNDiSe5b$8B^0O$3Fhy z>;*N|lK;QoeotPL3KNe_ZLovH%Vt(in0_r6+;KWsg4VQ|n#*-&=dj1_*_sRKJlzL@f3*vsTMpGLOk&IjM!I z#n?LL5YEZ%j)9xq2NQL1bqXKHJqShNmhoG&VrQxI8pyEJcyk)HS2fi1o$HOXbZNp@blE1&{t;ffo-*?FxcS*9EeXTN!NVfz#!FHl zQ=CYy2*M1trS_8|kS)Mq-)t&KEE*y!nDRna-ii?3YRtth|1o_pR0ozHx_~Ih(nKAg zz*(~ac{lKz#Vm}nPhD{sh8|7H&@LC|bO34TfRU^aPzFC+>5HntZ^oJy&+ zFi{}_a~XGrAWO5R0;=^^c03t!^&ocaE$Y^E@`%)&qc@QjMO@^((w|n@j4yp&UJVDF zg35m1GnBWwl>Iz^38rzdlZia8YeIbKz4mA9It(FtIsmb;)Bvpjh?D>f<^rwk~p9E zjNROzEl!8nPJVlqI;4C?r=FDY19bw3FB`Y^RuQI3_s95qR(BW5S@9s82+0T=D2lvV zWU}M+CzG9)`{(CBX&)}5hX3`OM(00j6e7CaY>Kb^=_6wWB7kJ_1E8^(OTe_AhWdSo z$MzHrLAuqvNkYw2q5qpR6NW|}aS;SvpV0Xdudi*>9zK~k9%_HhGJl#AmfLYo_eIH; zkD4t26lp>Z@@=@q;bo;z)rqTk6%jR2U;DLu#=8uPEdT6oGCm_>bP$avOG+R7b>4H& zyLT-HY2|V6&zun~Us2q#V+)8S1JszC19$Q&fMf33oK3ad=P_h3-_-OxaeIte)66Y_ zex{DN^lg&Ov9!Og{&}yhX>Gt-5K{`(aNsvwFxlS(Hj-3y+mHSeJHvTDkd}@qvd=WT zcdF8xNpEg&EdImC=Rb8vC>1ZNSrJ{@#+5s^RsWW)`Q^o4;$I6J?3XNU7HXk6^~aX_ zZVw~O4Vhey{C@9oXBg7{vU>3AwEZvLsF#*ke{MU579=0eTlR50o5<=@R~5r!diePz zPoGIO_R84uB2zN=GD%XibA6VNWxcE1$}uVTCi@#{tXuJ^jDsid?)g)DgIC7aFNvd_ zYRucDsejZiQKOVJeAxcVeLh%*D1nY?Z!l>0DQ{rT;4}RP`ENf97Ijy;TI_7=*nG0> z&wHE9H|ei0*Zh6!9n>PihoFK4sepx5X4Z`_T`HQ}e5ehP{e;ac03S8P5XT=oY-@{HYO zk+kOC)i0$6-=5w7&c)3l{^Yhn>rFpD)%q3^n$7=9HvF%Aw5R*9QQN&EYC7Mw+@q;! zKY4a!65hJGwF$xDXOpF)K4q5E8}9_0B^*dwoAW@U)#7B%5BIjOd?35K_HK9F_Vsal zi7gz=l;Wh>L@rjR%$H*Suw4 zOwGRk*9}+znpJ?5H)YRx0d$Lal2W=oE-hLm0_xT3(uA@9~pL8adk1bwB_A_rf zzOR(KHrdoAuUPl+JU95f|F5#Qh|Ni@{d$)G?wx|gOG5U#?{8+JJLa%;Ysv$KM+c!7 zEU00AUvY-Np}rzTk*X?csj0rPE;HqD=hV;l-mgG%>bc8hf)twOmxjGWsYFQ?EFPT7 zR|bywb?SF=1ye5a`RM3dn~!8?X*HUo&kh$P_;k2hoNRj~|NZvwADe!8u)a&PqfZ?N zf1RMCi2dX_<*8wdZreXeRbNUz4JWM-=ou21)$5zo_Z%Snde>u1=_{8_+l~Z|M(*!i zegm?XNycY}htOTEgHf0Hln{y}mGORj{QareQQ@f0j%nY*tPtPt6V{wNbkFyP^M|{A zM`B}7X<&O6S~A$x;V$_U#*)#^QT*Q@XKde6jke3H&3~2MHFdj6Y%Pem6X9jKx_(vb z&b%AGkMHeP+cpmDg@vBwXl->BoX^k_HMwl5dmcMrrb;4(`k%Td4Hl=CQ5j3o(O0iV z0uP-InVzv)U&Xyi+$nue%T5aPJ+Vy#`*GXimj`afw}54I$A?`fwAv@*_;}7nfk4#UU!`u)8d!^?~d>a3>`S8F`qxZuQo8wB zAlyD8w>tll?Xy=2QsZb&9BMWmqpEo5)-5j5bAM|bJP=36d7W~69b=3*XCrg_g z&t5fMR*)T zAY30MxMXv@g=2B+7j@hL4h7A5NT*#96f48Eb}5(ItUl<_ju`U04tM>2Z->WXMWG(@ zVt>>5gj2!qQp@GPm)zDcU##{wjF>!Yt83shEg5er{@pn}l#arogErmmTi(4|sQ(vn z{6_mt9+a8+JG#|XC#?UZP4Q29bu0UBmj?C(HiQN4IV2<7Rk>4qkb%lPS_)rSmXk9v zzohVNY4EktyH_t2hSoY)1dLy zSad5ReM(cxHkx1}=`=4H-);_CH;DB=z3bCn zk9yZ~)$ksYTGhWZz2{wc-9Nk3#suWrr=F?GH0A0XQ(-QX*e zRB_CUtYHdF^&$n6@yh)o{1u=mnoc^-r7{ugnst(sOD>wSuNMnaj1cxA%*0uy=6J2^ zEzC7W=GI~S`RbzddGCa(PVIdIwwB&L0io8~kOlRU*vE!aPaF@92w{{a+`Ky%*3Cz{ zul#66O~RMXZ@`S#ohhrnFPT}{fTu@>)*3WC6=H`6rz1Lzy+aPMpN1OD8`SK1klHf$ z^_bBwcP#;*{xBogei>+iddp_k(Z7~YKTyTV*a`U8a_j5$Avvl#_kL0ZWUZ<~=lG)= zdw5l|lulRIK4v91)!~z1O6ZjnQ!w&3DY&A(mV0meTMbq=Vb~hooQSPlEepc*llf9X z3T6(+H*<008qBP&r{4amjIcT+D$A-L$ex|wui+l9x6MV}4)@I#Ke4b85T^T~QiqB> zzTVqVX!`K+n~9z?6E&u5)l`0HC2T3z@LmQ14UsYRv7O+cu!im@pG$PwT|@~qM8C(? z`|0Y|EZaSHOX@t9y`i7^W)H^v8oZh(Mj)33poz|uk4R(lkZKvxBt}}v| zOq{-*9A)i zitC_F%9ItKAGl6Vil@$SJN=;1Q?gbL`2=4Iz3($I16Hor8NDEc^q5+Wn28NrA){GW zRm3o&`wid*sJX9Pg3_EcN@%*WmReGRH3voXnc1wWeu0x zGVa8~?8Pv2)mVmI!(&cDM5*cc4CHTr=(7s9)d`n#uP%917$RgmVwS1J>{5on3GkFx z6*QRxEq9`d_`zRz*n+K@7mFa`Qvte%WD&F4ZC*PX6v@&OAF!MfN^GQ78awrZ>Guy78VEH zMoJ}}WyLY|>nWTgV$pJ%SqpAomnpr99Li+^{Tjq|-d&icjH&wuWjK=bf*k~LHgCR6 zqM4plolwJ1V`+iwsLMk$P|bygz>g=s1I!v$Jg>sFg_t5*3uy$VPO2(~0ex$~4bg-) zA)dQKcb5~7R&u*8=GFGRlfS-8m06q6GWQNfa+aM5RrKntl4@)JQI%2}ICr?=dH4dQcEd*VezebMwrl~Ve|&Iv@GX*4kc@IkLAUBm#5bm-@`TdF1+I~ z@!^Rd7@0MbP0&OsGnQeGS)&|I0BS@qpar?tB3C#P_N@U$`nA^dgh-CoL`FgWrOS}# z%^!%WO-qyp5Kr}4dVM%BI4fA?OAG7e8=9J{;dgQ1fi`I^G>W6^P!iI+pNU$4raP9J zkDBubf+S!C~dav)i3Nh%=w6J0};%mTCdb6!?a-HN4rk-{Z-XFeVtTBK;v9xnkpihPsVlzZU;Rqv_VPawIqmZ;zl)7(d|6G z2fEe5p)l6zw0yP9y&zLYh$pSe?w_1xEk;?A2`Kh-jkt+gHqj+?RxEr0XGx0qFL zA>sLVo;VForu(RVeVCK1r3>>lyiPlaYctRBl08~_v$-OLbUaD&>tEg|{meKId2mR$ zBlM?_2b}`N#A+hA0k;w6op}j7^oM#lGzCkplYX@p62-iQY>q{Zsw$vPFg~}{CSG?Y z;jaU~t~4UGkmDIU>Uyc0;sTM;M^dIHt+zRFV|Vz}7k4Xy0ZM3fuzR8Zpe4;=H1mAf zksLIE@6o>=vRQxBRpO>_OfQC#Gw%jI$U_6hEwWtKEKhfpX`UUCyQE5HzDhI1kA}v3w#*-<@LCg-0Y^)(K*8b z;iH;3akrCo(S%W2t!)V1EC>BnW8PT~p2L;6wmXMr-e$CpiJTE6k6PXLMQU!@Ji)e0 zSu3zB-SxK8s6Ly_x&xp%h)f~h%GzApQ?)0gz$m=5$e$J!_`JOQ#q-+4gZ-P6EW*`D zg2DE{We^ttChqN$!PE%BBzY}Oqcv~?;++z~tPYuHDLj(u&5Hv*`T>b5CvQYF<_&|r z{)<+njG+Q@;MiQCH~W0l<%{w^#p_Ddmoi?p~M(n~40X&rk%^8TKp_I)V_y??r*rkXckW9i5@ zV&3|pRvyqEuSUAa3}vDlTrmS?3$e1M&15GNHJ@n_2>nK|mBj&Xv9CZUjqgCId{9-F zR<#72{Lp0RCrE1CObJ|5RX0#N`BzQP>Q;jvY2SbLu`_Vq)Q^!Kza5u}dRDEGKkl}RFmIEi-+RF6wCa)6-M=-X?O|Ba*r;(;zpcNvs2Mdb8OyS&EAMmiD5P7WEGc`j z4`p!vjJ<&{X1X{id;E&sT>6zOb5Sc?F7LGISYBZHPG5ho1N$};+5{LN?nk#yNI30e z;jkhBtwRH2bQsUX$qv?RLI^#~N(0q;hWiYE;HVmjN{ms2u#Z>{-x`0Wl4 zgP{{9YNWGp0z4!mVS;uj=mnGQxs3jk^hm_;B~J|%wK#9>X;|3==HY%L@`Ad_Dvs3N}sn-K^EkdD_Shdi?gILqe7M8#rV3!za8E zu)rjIvQj1z-ao2(%rM2myKsY!yijK+fVjm|o)Vl>i0(Sx5U_QwMR-tADGdv6_p9yx z;y<$P49X0P9~nrGWX7D0yc}J)FE4cODrS>2N&JSz64j6_qmW2y$}j z)6m962#{*xxE{BR8s=gzPQQLwfU>S9_V{~EM2CdPdGMzujF!(6l@ zpx0@Wgrhg?PifrTF9UVVC7C;QFBq*Cj_uUBe2h)*jr5uA7+E&l;*b`rj{c2+lt@E) z2^TPyR-qJRm!d|MgRjK)lsER-niCe&_n=&v$1*e!MhIT)DZfV}1xa}ngEWKQal->| zN1eXj@cemHRzT11(=XSt+gZazMY-xp3EeRt?-o@>wXyH`1>Fo^$llT(6X^kAwXm() zVX>lEhufGNo$p5xKKjz|VB}rHqpHb?xDmFwAEsXB2jv)%z<%l1&a$HK$wmu?B3at);1zmA9)7nVU_5GcU2V^oJ%Bqb-}W)&2{YIkvw(>F5K3R=P^9Rj)M_B;sAX;G)@ zt6%VXZ97RVX-12x8~+zsZy(R}{{N3Fl}ef-idfg`h@FZQV_lM@bxFupNz9rZ2U*P4 zA=k-CC*E-wxh!VMX2lrh;*=xTlZjn8m5Y|wig_KoIKOA-^LhWizl(oDH(s8eujj?% z;(oFIrQ(3zME7m`s{uq~FJ*1dleQ&$io?o}pFLy=@P?ot#L)vC$&4mPaw{J&OzFY+ zdt>_sdDc)_lLNzu7kgV~5%T39o!01CU+)C94MB!9o7N8~dgo6Ry=l*geLA_|Sv-{K zFTOl-WJjJ3rj86??alfCDIh||qqjbJ4OV^^TrE9lM_(1}=m&cXTsFb-N-$7t*`!xi zh7|Pnr{?i%cuwKZf^+sAi;!ENdkGlK&T|Qz2H=VLcpEcNNADYG<1M`m;$?sE@I%_= z+EH;$M+*z=QzRKf>`tX~MnA;?PeT3i>|`FfqLd@U8aD=UGmOBSIT^|05q><5Y2t?OT_OO$^l?hfE5X3r^MziTj7qAy^TLiVm5}VkH-M#tb3OF3cI@d=_QoT z^RdLU1JtucA;JY#qP=)UhzOA~ikg*s2u$7F89vZ&+KmcJ;PO}UoeTEA<+(-Q^+lHe z9zmR|It2Xtf_e&mAO+~1eLxs{W)al_8cS!Y-QiqWxw&7)i2)worJ5i?=?809_j9Zb zV({SFLnWa-Ddep&C`52HCei-(TzZJuyAG<2mt8@OVhricxp?I6S&Psa0j8GQ;ve3c zRp(j~Ce;{u!53vS2!e0>tEIp95dcz-H3GBnjH3YdF~?=|gqwRfuh^Z0;s)@G^OwH( zT^F8KWZdN)l^{$h6;1gzpg8%0>Mdcdna*FU*#AgbZw;7P-G(svd>zO@F>+C0*T=lu zN}H2!KaR+wV?T^O>d5}&d%0V;xN6bcGkSIE6&jF#28)O3mxe$``Jg%+u7E&L=aRcD zL*{&58U#eefIwN42dB=ow$_X%TB=D4F%fdFy!Ug9xNh>5$6PJgo!jL6Q0AaEW@uNg zP|%w#x4=Y&hq50yU~HngC?b>l(dQ-ICh8!?N3N7_IQfJUdY@4_X)5|l7@eNhP1f%u zL>m9<@qBl6or$ZCW-WLJr@*#Y;^IJWzMutcPI#n{dJV!hBNJ)*3clJpcB{8y*)K8U z{(fzZd!P5R`G#;-yZA^S;8%~k0t)u`V&C4{r%x~69JGyO)DVQ<=t1~S9la4ehoLr$ zRUM^u0akN6BBRknQ>Hbi;_G22Njh0(%aW|;tpJ6Gd2fhuxAKEfeKo9)f)Jr?c zffsVkpna~VfQ_&O1IBy?jM;+@rm_O4&ggv}QPF{&{07(! z7|3Iv1V=mkqQ;JoqA5+-e>CRJ-oJ^h`mwG^Y6mouOdsR;wwhDt^*L5Ilz zquu$@P&{vg#oIC&a>FEH9FLsVt`~AL77;ozsosYN*0#)_0YLu?aLD*s#dR~oB9 z;0||?)#fC{C+$j<3Kfjx1W;ojMAOV-c)HbcSHFw-WHdj)9%(sE`=J+rKn<|Xb%4}M z-rCD^d(tMYzoZT-_zo>H3oQs7Bx9;=Io=#jAImO=c|x}k3_g|K{6dVxT*(+Fwu?E= z8Nl~THBL`**)SHIZ3#4ya-{~?CbRYjQk$T2Nk_<_5C)>($a>G8{hclkMEJ!pSMCXl zu}RPf3xjO_th@cZPDkYc>d9c@!@<*;RIDb|#X@})sQC054{>F}LMA-l#Lz?Q z%jhX)CBJKt1Gph#0q^HN6=;&>3E;vJ%mHzB_UlBib2UY%@Q>(!Lkjjx$$qQ_)017{ug-(XUGItJR14 z>`+L`NUIlabE2xv>|=%`&y(unmKsr}aCsbS@+emNK*sbF6d+$8|6j?}{~EiBXW;!# zm--?==>gfaY;exg$;02RnQv|v>&!8qCa&WTa*zo$t0ZjS=dM4i^_i{<_)(vC&5a+Y zu$k-g+JPe4sd8QQ6TW0TFW?05M6LlU6D40#o)JWCznf(=jam{LY!^n()FydDGoQPl z0D*Q>--rA@pKBRsd*>gK7Ju{4AWs8__^!TO3UAZ9yY zpS}YRaHCBJ+abjCd|Upe9;%fTZ$v3;a~uO!@2kLS0n$fkHyOq-#4CD#yi}v- z+FuoE5#T4BOLrxbFu)Uw=^~Q-qjPS6^<`5{bxjqqtjC zI`rPG@v%d41Z%{jJ!kA`K^vX8qo#DCaTCDEE)yIcoS9`mVpYy|g{lNDTWk7IUpCzz zI#&m2x#XbT{RHpV05)!<9f@v#SU_v%a}rrqQ#rTNN_@0$k>&9* zL}MeD@xkB3o&{4TXQr|qocuQV4R`7C;aH-@`!KXJ{CKz_CD5$^F$moI-Zm7%pmG30 z`Ltyq23oe7sJkO;~S4m)M+^EzV4!_emJ1DaihZ6G4x5+Q09cMHm#!ArfjO#!Bod`9t=+i z=%MD?6D!%J0OqXF3kgL$grK5hQhSV6A8Vz}I3W;?#nyQK- zqVN6+X~SOCsX=z$>2|*~8@E#fH_PI_wrJnpmXmF6{CDMI9`Ru(m4B}d<~nxR=y09T zu7VVPR#SO(C_-a0eukao)!2)6hC14_qP5jQW$r(W`4fD%e@a$66DmUtk0^6UZySmZ z$L!uam9^`D-duG_NFA=eWU}sK3h>5uw5_=s1VDX_k8YF83DH&AvDc z{u18T(_k>~1G{hJ{YdA=o;Qj*(6n4jhi>Or7xNS8S3DQldmiMB#6~F-gtm4U8)`|u z20OlCVYfiSGtaUWo`3m94t_|VE8$Ex)%L$19zAT#vf=%42ibD*-$P>xi2ozOc?Kk- z5udHfr=v^riU`w+p<`^K#!ESJ`h{Nu_SE2P*Z#q(st<{azE6BsPUOAq>XG{C3Zh3u zmW)z#1u-WB1d${qPci=s5V)3;;T=77in6=%?nFQ(_9r*FY%zi}+>$3;92MDd{LuYS zQruj@d};*Wgz}UcBe?;IlTf_$fXny|@L6`JQ;EXlJ{jN)rgXGNSm>)#aK7tBPCh^y zQzB0CUyM_niyX&tw&6`)L)R7*XxrCHrl1u|*cRpS#L#|6ZN%@sf1j#qLJom~(Qg>_ zMp9L9<0i1LyJ@2Ah1IO@T*rm7W`or+qg&6g2bi0LynQM=wHYK%u}cZvU6QkY4vT=( z+z0Ip!m&}N(Wg13Q)d3^GkBl(hYj^)&N_5I0Gp_-E`FySrL%8j$2=nlqqAPAZ16_` zP0+hmI4^1yVvK1>y!d#D$m560_}4)J#@y_)BDZ~hBDd|W&70jKb$}ei4?5bzAgdAs z7M~L+o5xkto2k-1w2zeOMP3gYMOzh&yVv?7M{O6d#xMr^ZzylfSKyP@3a7k=&<@wN zHqbNw>6|OC`fUL33+Yq!+q7fwOC4<^fIIkLZps5rC=l`ll{e_7J}o2WtbV0Bsg4xd zd6L~4Yk`Vaz=;9vPPla5bI`8g@krd^rDQ3sw&-PwxsD0?wKbuFECM8y~{m;#x_VN5sl~*xF zq2t+oGeL1qq$8h>3OBK{2oWO#cXSte;YU@$`Rn2Eallo9voH0jou%CfiglasM?Pyh zC!zEqh{JX~EX3od>e{QHi95*wrG)6fbl!YjVqpy0B+KhpkMQu_Kec`-Kz3%UH+-uk zfEc6M(ZwYgcsxXizC2ciP{fx8*q2zO;z72?>N0Gu!VCx3l_f#B^^A7ogXT+TETj5t)C*SfKa6)D=+0*L-8(_RF z#{57@Jul0g@>hMsD{f7#+XLU2iewv^fnE8%McN++zIW5h!5QiMF%2Yh>*?1!4XnNU z=B#H{g)rE*Niq)`7j56y8G&f91N2~`T7M;&eQ4nIb(MrX1X5&<%lHNf3x1=opG-%) zmjBARUlJI0K<_zN8{j|&DOlW5?MQkBI3Kg>vIWl8ZvFR*Vo29fbEKmkIOF>Qj~5Pm zU4bCr@S^*DZw%%gZ0oP4|L5u*8+!9H@bj0z&%4qDGsWQNpXJb7a?>p}vm+zK57mQg z;Kq_8KHE2ACh|F6TXglnkGSDe=ROLrT+8429=I8RvVVLwXVeJ5=Hm9yguXOp<)kmD zkf++?ho_G~U0H?YA|If<>BrTS(AK~?Q_?&=l~m8U(lx@vmWqbuadSn98T`3;hQ67b5v7!J$@^epgNeBNs=)Z z!B(wf8-x+8HNgFYwgwuj2R_OZzUld_!s$9oRuQm2I$cu~BBBnNn3X^0| zZ&gOsh6}<;cF*73Z$kbtueNlkB>bp-&3NJRi|y00<5g2gHa6Kl&A`NC z*)nlFvcmJ(Wc8)vPGY~^1;`;F2a+RJTfAmX_;#q*={4otdH_P1(n2X76l|qlzsS-o zoX*w&%jB{8Fa>HnDAi`xpC%F!f_mo&fZ>Z>4X1){@F2d9u!%W7*k{r|=V6|92l3(U z|A`m?^(GQ5qdjw)Wg~5T=nPB$GBNs?rInC+P_>u1;Fh#wpi(x(@z)dhGCD}WP-iT+ z7~NTpCzvfOl=1a1O-Das@@k_meoBeZ_nRzW`1EH=bXBjSPCWSQf$+e%*X?b$mM14W z?5p3u?&1ZL8v%Egod$?F#TiXyr02fNnx}kVPJ3+NrhU5kCHNm5#ls7I_Mk8+KGSdS zx$g{|CUdGca68RsXA(W8^{0KFysF{VX8FbB-VVswVCmppfcS6RbAk-pSzqc4{cf&p z8sQXs{xrj{kAD-HY?1N|6i8BsV;4)lXvg zlBr!czj7Y^k*qsU)UI@{BsiL?jXo)51Cb5qPxrFtG@10^n0>L&#zqLrYu_#c_3JLw zDYk?6&jOuS101IjXUCSsTG9v2D5s>(BMg1^`M+W$!{9kU%mpd+&&mVQ?0h2<25PMNL5vAPcOQqnE0HK)4*H%+BUb`vvAtcrDk28Oc1^U*th1y9lJThyz!gvEMNhA ztD4BS0IL?@Ru4r24FpH8gsWky?bNUy=1gRsYyT-W!%yLMhr?I}2e~GGR9?E8eNkbW zVjpWQSTOnhe&}2*`FuY+&t=!l3`z2v?sx{gKjTuLJqF&Y_xV9!=~UgV!rRhO4<(R< zI7|qLB}Ii7PJPq%19`T?s~=S!Uh=}DG-O|}TR`-$mtPkX&2YsJ%$<+)-EMM5 zYFFzE?eeU?lYj_S6(DjB2pL-m-O2!%dvH)k+W1b(GJYxM^vx>5ci;GS_SO9{*@@ju zb?4*E6}|@^2mD-M0s3(Lm--@E@HXye8Ml$J0t1RZy^ZmA z8C`o}c9FUc$mw9NVN?0gN%4q-AFN+aJq0X;cfoX20?^yt3v(q_gJ_EiTyXY0wgNV7 z->>{>%E6a)M;!!mWM4ycPa+D$QOOV+@V>*56UR}&hUExt{_O3-G(tcUQVew(?_)`nfA5G zNI@T8maZu(3$Z+?xK+fW)VQY(ovc$VOSShHb@l5y-)csxbja@2p`R8jmd zZU%@&s~YfkYSp1)k{ zJBl?fKpv*VE7h8Ms!zc*%|wBfSEzj9Y=PzWzDOJ0R@z2|p4>u_=F=0Y(Z5uadwOuz zpL}9~8W2`CRd>1q&|EKp^kvp)SnShn&-rt&z`DH&c?(1cY$rQE7VY?7I?Tb+Pz19*A6wmo?GX(Hyj0w1Wzh zGKengZe_%~ICA5?A(wg}B9o6%VDH#>j_$Cz_BIxK=;aV^^RwWEM_&@cD}#Lj+a}L$ zCLsKF$s{+^@9}4Oak6d?I#Ly?6(NnOR+SWeS&%$Rg;gH|Y}3LxaVuZ|4Rwe?rya_# zCzGPcbkeeFso0QG1@wRhq+C9m(-3DpaY3%B%KPX85=IL>7=H3}{n$1b0?DGBk)Y0na| z5`nZto`>te>D&s?n_&J@U-O^gDKD>Vq1Jzpy|+Mb7Sy$u*nf`@wL^FgSXbXb`xGnH zQycrqygzT2uuLc-)DoXb@ls$~RzVaS*=mtHN>OtN3qKe$j%tqabnI-I$8E_PYSF(P zCF{&Bs)VxwlLS3?Nk8d+=EFek%Uq#~0vvxkcu+xX8PgZM{sMUkrF2nsYd>q|; zw|3_Q&2)f=f`*O^@;qdDboU0J)U-hn<@&I#aU(REUr`?HWr|zJC3z-vB{Xu&-gmDA zFbzxb3Oa!yIx1OL%DDBZrdrrbSm z7D>Jr`Q~1$4Cu|}bVCdgH5?$Bky|PBm2Y}nR`^p%P-ud}x}|YZ4{h0Hsy`8>LlL-F z5Aikt1D{>lz`OnciK7W{ZHzy|hcx*~bn=s8rLb>0Dysdf@DJTjpaOtYo2QMl;2lAq zM@XvE6G+bLUsQ)FRwosq8B#oQ(m}l93xF>s3Fy#oW4@F5>jop%o66kU^id|h{vmx` z0nBFSPl+tMK$7FsTZ^CdnaA085Fej_2y21dgskPuA3=;3M>hdbIN)=*atCOriH7tW z=s_Dv5E=M&XmhE>nn(-R8Seqp6QCkMy;v433K35Y%`cQp)YRPZ4{_i|)g--omF05? z-6`X?ci#gB7b=tq^(Xk1!f70p8qsBmKoEcd3_z-`qaGca)hrePyzIH!p3fYHX-HjT z5wU1w3P_^RmpxwEdh0B1%3=O&Q_a|A_nmrG6gTB(4VQ+5gN{Ppcm9PwR5ZI7KyUNN z#T0T2ZjGjpW(yjKF33KTX<$`$;e?|@+Ra&})#+Mn=GdDSp?K!%Kjvuw=Ti4|!!Tb!fXa`Ktac03LWi;Zz+ z#*G4)5X-hCbz4k|PZL`9Jv`N&Kn!~;e^&72k*ed=O`06qM%+X+Ii0qb(tR4b5&sm< znUZxM>bFIC`Fydr@s;%n7e4#EyENkO-)T`&(l($y!L3dC_z1}GQ5lNW-D+cNZI-d7 z1_S;69kbk953IJj<)AXV#9zZiZwIBg1UNvoXd21h&rlp4HOqMPxIRHuRJxx%Kl zri!v-xyu%id|=k9iR5x(<95aAyz#E3E`}8CM^An2+R>;p19Sf7olaSDZ0S`;%Id|&A)e#pRq6`^_D*?0=M`>=jXOp|{xdZ9lrH^YDALoaEya1X zEOm~1!oI!$u^XtSgMMxE5&nc4bF*805&^#YUw`@zV1q!fz>7obm~$V?o`pnZJ0_Mq z4Kdw^6Hl4W*En~5n4A01`8q7>c2wBzhwtBaZ+nQ{$9qxlRP*CfN1bW4o1sSQ-a)g-{7BMmZ`4)T)<_X16XCdbqlE0t3GG&B?hB)mkm*0k<<`Y^4&x5> zzZ)I(J&7BW1;5vw1m`BalPv%H*}JjH9EQ9@b!Eyiw1^OPwVVJ-qwJ3M+)i_q`5(Bk zFK?B7X2^>+yjWR zpk}^Fh@*W+l0Cvm{e$9DD$SCTs1giii*A)g6^s#aYX&2LaDZ#w06W&|%*(!yuQJ<- za@6Vi%w_p+#!lREKH9;r?Up}Qc(?d=+;Q1S8$z-_wPq})B-mUVQm+HRb^1E$RjJL- zPUb5AbM4Lng8a>Z{#i6d2zvT%IAs6Rm>|ay@vvyLb**f9V6tv^qY;$`&p@kHmj+#y zQi(7Z@;k3EDB&dX%hf>ocpRDJAo`I0dv_Qiu+krPy43r{-pRu+Wm~>Tq;xNg3%Y&n z_^lfStEXisIYV_7tc|Q92{H}Cq00fFyeX$OYhQ?VC7h-hl4Ydxwmz=}qoeDpo<%xPdcc;UFq>YD+ zU2JIm40d)$rw!5*o>mLxN zHs03)+*V!IPXbsOL5rciH9{p84usP5sOHb%2Bz8pCPy*fj}oJ3NMLePOdG(gnq?ld zd0S-jk$Jm}vi0Z-#^t(!ziQ3yZMP@(AX*VH%2d79TglMGyEQZJV?d-%qMK;n1KI7k z8}Rw73WO{ZTsCmFL>}a7xmgj(h~L_e4pbmz16<2xneEe;gtHZLOna81nT4msjZF*8 z!m;FM(|3?>19hU1t3WE1CKI^#f2EtnOs?R8C++VUXUri6HM3i_2g%wUpkjs3&tHF}xd*AeX_VYvW zBl{Qu(vPc4zL*1<&`xNSq| zz;W_gqIQpho-*gr72eutt?;TYhm(Lti;%M+|I-;@2@~%1iZ?JvI=ArA?Imvj^`*-8 zOKDUV!GHMioga7V&H#+U(B}=~0Ev#22THKRTzkSQo2B?A z@U`_Uv_~aDYgt?0AE6{mwJaQKsA&GRrI1o5O;u40t~Y-Ra*> zgO%`fLKpf36r>H~$Hi@+#hK9tZqMz?3Lp-VZ>T^t(N}kY;M%)QMRJ^Pt+U~`q@^K} z9LqpJDP=6TIC{8oh<8|~k4$4AZc!Yk@x6Yh>IOOM?QL$YXZuw=J%kD4%k;$yudTEM zFh`G^b!Zl}b~8?of;`fwv-f>W@$K;wkl_6GHn4Wyu*!YrIp<2%dz>Sd#Fz$VewNCoOc6@l} zhmwzF7A2jN!fb#RIwm3|3@)_D38a|v5pCZBvn`=`e(`}S@|C{1UCgUN`PTX!$GN}h zegz#7pQiwvn;cQ1`&~3V}3lK4p%+fk1Pp1Hz!F zq&<`<=^!fsjfc9uKz(~H4nTmIjm%2-aTLPGk8(@wV6t#el-Kz1fZi-dVWc(Sfip55 zW=Q|hxzDplsZBGK`EZ(PGSxyxT8#x>cy_4tTVsnA1JSi6>Xw%zv3<9Nr-8_F_e_dzd=yzKcQq_=5lXGXiU#3 z(z$Zo@m)N7Qu*78o1zxj@3P<4EuGu%RqO2pW>Y89jWf&xB0~Ro_gARv4?o4+OVtSk zCbA6Z2nyahFZWl4Qn$meRL7_wyp6j10w{p#EI7cm4}I~O zA0?}!S!_yM?G{)-iSBIA*k*=R=;^U|=0seH8Ub-Cyc^*C5DN5NpNwC|9dq|ZY#tSBBhaTybVT*p=k zBUx+xP+;cz06Hg@fp!2Y#RC%kql53A;gFLJL85Ne9AmM!Uy$>*=-DOMact^a8~0&V ziOi`6kwp6V3#SQD4sO9R21K5h4?;5_SLY}mw$ULk>^(+pR3dF@@X$Xx8_>rg{CGlH zn*l=52ZuK;?5K{FVb@W9{?-ZtUR1k{8QzQ%CHOk^^Q4I>&a5AQ_QlJ=N+$JaV5HrC zAU5-5%Z}D&gP2*o zzHzhqSPy&jmbp`xqeY0|1n^spjCJYuO2ZCB>aU-(j-kw~eXIRCe8X_`^!-JeYhXoN zfxkZvfM({~G4HriaR+uW_)o9KJ+wBQAv_)#s{n4ldRo0<^=V;*M}yT_cE6n;!VIn& zZvlV1UNh5D+%A$=#T(?!-E*-N2C{qD8LKl>4>nL}rNI7FiLe@HquTJQ!S!Vo}phR}zJk}^3Gq!S}YkHC9kfqsqU zdibeStP_l1R0cn<3;G3p`w2m71$mA!Wv^&A^D&Z1EbyiKch7A^C8*sB;S^90 zi;=qB<(;te^y<8zZC0FqL|}S?RL)yv{0*=fi8(jDYcuF2qklWp#(hMx4{vyIREO?( zVUY#^JWBz=DCsU;QobP1PUW2iw}8PI<&)AM&Xw?&`4Q_H5+7kw)%dV+@m<^|F2qkN zYRq3IL>~?-grjhS1%11h%zq;+>@SHlHZaH&lMD7{R+B@`cmrdV|o1B3Ixm&7z z9!da2zLk$-nM9G6ODC30h-@=T{XA}$PvumV#OyY?Wfq%Wy!ZX+n7^m73FNlzMctEX z(CIUUQ=*7;g>Hwp(7|wY7zcN>&fm(u)wfMJzn|+kIxCzv^xUwg6R0(~8z-N1-1{Q4 z*i2{qUzR+kAEh-o0%&J*ba*=(XVRxW{v^a@2b9zS7PO3ocsG^h?K_m%-)b}&wV*$p zbA6xlm#mz@-;72H*qBxNbn=ou(c^O)HXQnHM$U8rFY_0^imNGcO8&NmHrr&nD{-NwaCUG6CBIpdxPW zzLNhjBBcq_5&A!^RD^O3*f)nLL(N)8@ClyQ?5)*TV3X87frYd;_{F?+%u!1CrRPG@ zY{q(uwUm{}a{xO{Kji1hZ*Q|KH$xrCcGGfmT<@2A-V0tO1n0cZhTTTnb!cRI^8n5k zssiMJB@6{W(LQ5su&;Ltny4U3m$Kq#4US%{t`SnthZR!VJN$%FNc$N5`gWyzaj^2+ zRCLa}qX(;>r&m?+$`3^YHmJvV8Cc?U~Y?c8Th1JE6@T)Y%&}0$%v<5 zagi-BVO%qn`yx$4`d%qiLW3y-%Q2XH6_fJ5mi zo8W#Z9>lKkFEElk(WCh`WRfyE>JBC_Hb&}wpZoCm%LY75=(lHx4w%2CBd9!h3o z)aoG>C-PTNcd=?yp815NJ%Jg=K}o6-P&$aaKAAX1so_LPvl!+KZogW{|V8N-K#|eo=;ZK6=xT#e8(vX~D zubIFAI*VYEZRF-scr)`3{TzKIl^_9$nb-iWH_p=e*XbGZPUTq@5^)>A*0GN$^?+FQn>d-S`p(%M?C~xq%wq*Myb%ub_s7 zE5>~kvHbYAOt48-*p02LhGS*UX5U&LOwj1chrwQ=#-f0du4ifF=cR>FOACL$>{tSP z!(w$}Q7RLll>lBZaI%4G+I-(WmUgcK8v5Dr8X0O0oaEP7+iyqGKio^(Me#l-w9mtz z<7JwAVovqVs)%XRK(66hEt?bP^Xb8s^sze_kz~` zQVw@CYZt@A&)}LCp(7_@C>g!46z2NC;LlvnQ8pAGb`npTG;T3l_ENNM2s)ILLvV~A zX1^xxI)spY{4a6>^8^9gkF@2RLS^c86;iHxyXBT%v?JOBV#&Jis?m`R=Q=&iKBd~H zn{}$T3hK@_Tn)E#72bEy`=SiK`AyUIK=KX&J=u_G zY0-cE)nLTm00p01MLIj{e4NyTVF5g$6J3&tU#DG96hmRA>UAw7BN;M|lT6z(89mCn zv!fr4Ey5okkI68gbhnsVZjNvH*$;Oze{=RRZhz#8xf8Rcb;R|%LqEP+(?PN>*Hdl? z3nK@Sok!n8B9Ta7)7I71`gCbS18;d>7y8?Y&W%3`%-EToTYqp(ef!yXZ2|HRE46vA zB3z13#sCjz2h;O0y))=_*%OSM!3V@R9@nc{JqfbGq@Al zA3!f6V4SQshMV@?)X*@;q8NS8jbwV2Yx=oJ|o9%+6D((tDBdsdA<9^maTj5&^LjYk0@>>>k!3A z5WTXZLsQ1dE2l&m_W-$v!c=7#gAIsV4)8cYPYIrwzg6Lo%&~ShKim`HzjyS@ zoQ1ddVB`ws=Loq=yJ(kHU432t&P^$1?%OUd>CuCuM_+ITJ9n*OhV8S~{q@cDYs-sHuB5vpBNe~Ck{4J&5OLB!)=2#t!aH)> z8=j0%l1_2+J-RIY@yB?aK{k*RelI3`>xd$KY#EvO4u8=%0xekzQoABtbsJCVZnH!} zz%N#1qb5MOlt`HkPT&@QNkPBY2pA=nPiTiEWK03bJ}F5I!X9Q!1z{SDQ;&@YZbkK`CPmr9vP= zCTq7$BNwSL5(7Oi5Y}n}+UvfgtB$L;De_sx6hF_pm#N=y0}=cc6f#g-7$DjG5gJs} z!zN)bygX6)`P1V=E>qxzoB}vCoCEkIfY#)j5l{6jfS(E6G#T_D3DhYze+u39Ly60}JKDic6#KaLr5q-0OA-`H&9XvPuPO zliqAAfvU=67N%H*LmCaE-jm%jX4x?CS283_`p}O? zJR$hr^IAx_+b`;}Nh}MvU50DV%;q$*U$OH7@;3w=LVVh%MXUfEGC82bBZudunvN9q zdTL}P?HvxGxc^8p8A&u&xRgjS25J*{?gwXCHk5C(kk>vBMKY3=CpYgyff23HQdelO=O$3={ ztsAzYy#klS9!-rM=1w%ZZ4nf64C2N|ito7`RCL`ljiT8+hqB{=>&8SkL85=a#L1l( zSWZc5qgmFzcr`d@VSTEX-9E@W1o%)9xhMl-6Y^b|`bYvK)@E9(uKlAU)_~e@X2&;m zxh{N4!v(}jyrH%g&q?Y)4tuv*0-9A=g?fYHA$$`S9Ik2TkC<6Yh|0OakO4PMGhAWg zCpSK?$3A_(e}JpX>nXWtN*-J4LyVT^F&k@Wi@q}dO;~35YC}NLFb@NN$}zz4=m6>; z@ZYkGJ7X(h-2%#5gP`;9ZF7FBGtebi7mB`uJ{VBaiFr^gqTG7hDV<5NQdqPNj^H>0 zbxTVU8DZ-N=f31D(rg4JBdviQ)c#aLHzhQF=j$cV`sTH}8ljIhB&`2oa$zw=kxXg5;&K1n*{F^bjWGA1`^PM@{NmwFm1OJo8JJ)9{r za>*;BfZU^{)`7OQZ`RrMgjNt!IU%}LO)uKsp{z7B;R%D>8g#TPgF&b|t*c!Q+B-{D zXDGFr|4^~2({QJf2|t3Ijt;5mF3=ptt$CQoIZkK+j-o)NZ7Wh&O1r5ygvLHo`UI z{@^=Y_W%7e#i12v1YecP&$ns!RkL69@Y$%{XkRL-Wams$|ce}iIboJU9twACjuDYphN3Q%{dAUbtcc=5#JW~S#MXQS`#1LQ&CVcNR zp1_WROZCu~ywwfml+sb#P^w3Gd9a8%H#MoS%_atgmx&JB{MGY*^v$9UFzU;6i6~<+ zy&1y~K$vTXFnW9f!bE)}Ug}aWp{VInL<-KLm;IE3Tm{d!Yb{_>nLq3TCR^FJl~_kk zL;Dw8#!W8tiQZj?DbuDug#e}R$gIbecVa=hwB#?4+yq>K#VRu(8?I1TDM#QosNWw} zNazS^U_TWB9oU95gy!$kHc#XPB~#4_PjSQ!EN?)4U29g?ABy%vn~_Q%mrZ^1C?K4l z&N8#-r-1U|Z{Rww#Xw{ktr?WU^OEFeB@9uw4BIY8HUml@&j#FpYQi0tvSo~M)wF@R z+aOQ(>Ezx~k>ECwDIl6AN2Gg+gcjJ7FVoI)gf>;P%V*Pn0=|DB*TL|xad;?*)k2Dq zA_Wam<4CaZOh#n1da3hPKSfEl09>vi_(hX-Z<#mi?$&uHd{LiYOi2QkyZ@=W)*m2@ zBzoG;_+<(`{n^fo$mfqg2NK!f^}h!Ido~%~UKZE5lIjQJ6+`C%MfaO5Dg7__Jd`Y?yF4nCmV?dlrS{T-REr#u|A+`9*~jbtScB7Cn7)>4=Nf&tons*5&hmLjSl( zbzXvp_r*&dLqQ4Mt=%K~vj4z$MJ738cG$%*ro~h8j(1X6y}w#7;jbZizu(IqNyEOQ zrK2I$e)v&Qag>ACt)Jfrf7PLZbGaGEm&Eg-8Pq-e3#(sfhH4VV2yL(OhwI1*W2UbF z&Jlqi=7U@%NQPWK(SMoP2L}Ky)HcX}z{MH*tiVkwvwc4lXGQn9Jfu8neXm2Cm_R}| zW*h??8QNt2({&U?RnrMkJm9R1Mtw!~Q)W+nRA znB!KL`RUI^>iI_{W4fX5_ZC=msBJN6{MifItpIKwCZlV8&gkjhz8zIQIvQe@FxWMx znfr4-n>+NdGZMf>BCWf^BO}9|UfZ{IwZ8q@jeh&J)0&s*QVBfG0$3F{0S2nZ+{C0Ip?nL(DVIH#`cPDS z$6fE%(zjbHJETQbM|>123qy8o>9hYFa1e-xK8nS1sqQti=zl~5m`|KG>Ea@n+%@>>cCq1Nh&UtrPGLV9o z`UhbLw$x>cNN&dlB5csXCcYghI+#`Ex>J~UWy;Hd>s6t;s77eAs3=*wd9!0jJKfwD z_QIGF0SYAXv25<51qr($*xI)|PVrF~Z>WXJJ`FT+l6T<9$Q$pZP z%1|F}R@wZc(<`OYz|O%)K_hg10ip?&j)4wijnwvFzT5w8cj1B=8)q%f3ouBqJx$OE zMZm?r$mRBr{oSqU`-ddm84+T{JAzsIP$Jqb%<8T^kgSA? zqd(S^!XSX2*KU%0w|Jd^8ZmS!5}E@5q*O6i8(-2L^Zi3ZCV% zT!9M;vbrAuhq67x0MUcFA6VEJJ-igRJ;6?FN-!BR90|#sm|y#De<49rSlYcLyo6bx z*MWpyYC+Q2d;YtzQ9=g@hyM;dw9z>q;Pn^oIkqL1&;u$)-fLcnFV^s0)(%{$e~*`+ z5g*=ixh=dQ?eEDSI$vguW_kc*!}sUV|DTV!fnj_&gnY(;j-lWk&cNVsT3GUgnIQkeWTZ7yXExowdyM z>}{e&SN(gxzke-O>`OX>ki?HW2te5KM$C3-C&zK~ciqQWs+YnwihYu?1{q`VR8HG{ z2E9ssP!Y~wO1{1KaFQ+Fq9h_SA5=hXfv zUs|EUs$)oLD(oK?UmBxyw0e6*Wo7R{JM8Cr^-mcXv~gN(71$+zC_IdKKX2W-_qm>Z zA)L`_xxRtqA@KcDBb6VPWDc@sozJP<8;As)L*{5zr4{%0=B;f;uUCYd^Ul`n+L>_3 z(dMWuyR{4V=F_P`AjdXyzMp(87-gq9+BUYS;YIx~TD8OeY=^Ih(ViJO2haA_$Ae1V zFBnM@&YMe6btlyUNwye_Q z|6=UTqnbL~yxn$`ln96)}z=AjBC$#MA;3A!H-SET+~+s8EQC zfCv}|5P^^YVQ5R083dFJRAmb6{Rm-~4E-+pzUQ3hTkCvlegCl9wOl)UxbN$}hTkBc z`v|6Oz;}QwBGPZ00ooG4P(~>Iy9c?XW!=!`7z* zBc@LYQ#iWnErAx+813et*qgDhKhAg=uJO{-MbN~5zSLw18rAIsaGl>zo|)-StVQxyvY~sjuEUvJwL3^ z-FzR$CC55S3~64Nwy^MR0K;~&U(nskLiV)4@Ap+=;;~8w2woa zZCvs5Afn5XH*eaXO}uqI5$~*bB6H8nu1nbFWZkb6wGl;Y_yi@Ym*^`uqhv)0hA4l; z5b>aJ6GOOzs*AhI$`V5Tj(IqWUB1CYi9$2l2fRX6ryR`j>wpMESmfvZez584S*C0t z=fm44?oTBCL%pZ;k3(oV0AKT*(4138dy!C2=*j=)v=#u*~Zt6_%@pU4#ncqc~ zJExv9R}={Lf4#^up-B*BMbTzRhJ|x*%O###vsNeTCqI{H=GitC2l;wWQkH$F$#d>1 z3u9ai&i8S%y`2)U{&B`}hQp8gcOgT%h)6$v(Ig}1X-1qbp|0kca2=c8<^oo-weFaH z4bSYWG7tSyM_q<9WWw|UwWFZ*?$OsSE1_AM?6gDk;_=AQp=-qcW$7n76qAEt9&;LZ zg1%!L4W&|XyVdOjlu;(CKagI>>_Y~q;?zh`5JTp%gI=I+HPmm(bMWw5aP6SvvBWi9 zM0-=&bAb@^0aLoR$Jpi8Q@%r8858Llhtpm7`9rU#wa;`hFaUvSY#q?YRLTozy24&M z-up`K)o4!GhsT|lQi!bc1KX^IyVf)=3fR@jHoTr&)%nXMtB(sm6O2y<(HY7n?~DC9G$x&FrIckI1@oyB9Vl-KN?XPezRUs+&LCY;k|` z;q-w89;-IDHvAsoefbW-`9xiyRS4;QT>72}#G;PT1|(f$!L}L3iwnn6&IPeobqAxP2!cC;ilIE%1}7 zMw)(5iv7{5$=`?kS3ggtUu+F~LoV^y<2yWk<>AFZUA6Lv{siP1a|+m}0Df4eozJ!A z)iF)ML-aV%60+G*C{H%PN}hJ!)>lt6g2A?hdc%ywO|gS=Ao%BQ^{(@>)Jx9aG<=S{ zZd>JP;HQ63$qw_@Sb5k$R$E>P-EK@~S;AD8q^x1 zmSEpoi1QmPHerUN4p$On&4z<^+lPG`UOknZ-X><8&wteq#@33*BEoa-s~gD@1F*9BLPQ#}3J(6IUBlxr)1d zChOzx6iRyP9Rf4r`44>=den6PR<9#E+E>Eu|12t!jK+1@!aj@T#iCzjyl5xKC_XM~ za9JlX@j0BDR~@sCZboqas8D?9>IG=*d)Kf+Mnu)8+o?G{w4n4Z&yZ*&@e{aMXwwjs zE~*phuqB1!4zRTg(v`KjY9UU1dzarOa}2-wrL;iI{EAOMg-P6{K%M-Y>=7VMqv6NPnes!$}g zNO1lc9y1_zSy?KNviCFvYiYwPR*^SduRb1exRi6bt*5{5cc}%mxb+=CatVZ7@U{91 zf|R)guiVq3K99xv<7;Hxco$QlVE-h~jFG(z*Pw-RLSj*g)og-uODix>g2VkfHz&IV zTnr9a;YHjS9+mI=F*@oVB2-QPUV ztaGT23$0omb$-`#p&<4MHYbVkLJj+HD_nHgtDpyw(f}ad1a!Q? zMGxHP6P)~HQM=7SvOK5sCD*+pxbB6yG9x>+@cpoLBqjOsX5-0_iB9fKI#d3EtKtjR~hpXe+@!<=Q_^u>aU3wOf=8JRka~Fs0gh5 zdL^`Z;RihZh==k14%?-rDSjQ7ODltoRr{z5M+V$=wB;`5Y~aAvH-E!&gAhnr8Yr~K zqXwD@L)jbA@DzdLb&H44))r6K7lmhB%g_I~Xa7%TiEED?t@W4{vw!pIWcfyDlmEF! zyD$bHLQ|l~D5`?TfJR`b=UAlK~H?7BI;jW z#A_9oa$w5omV&%W|BX?JJs4i!)_-SAX0=YFiN~&SAO3xWQlA_@zC85Llq54#q{uN6>0aL4o%scIK;*MLA-XiiD`4C znp8tmLlGY!<0o^rYU?F))%F8^dZ+QbXl5;Mg)KiRo6t6X4AjhQxplt@4gFXPSoc>- z7T!bTzJ&PFa7rB>eg_hRP!@S^V4pWnV(^yUZ^7Sjg{U2G~wyAfYgix_cM z;JkfUezYJ=25R6ig$PLCpX&$(=yv$&S1Cu@N~5+Zjar-Ok#wiy`@{?MF5fw}H}nW$ z09+!1@JTeh7J1#RUfgt`8&cMYCQsZ9`+5YdIYJJ)Cy~P_I&8a6;d)LpF~TcZ?RxgC zVdux(+xZMs)!^m8Hkeg5^~is9~t&jytTK9^cN0LFz9bG|N%yd~c%$Zw; z9rFCVeWUXZ#7u z`(`<8RRtrHImhSRq64^Q9f z>h({b#Ydj{^$aPyiAQx1KFA>uVa4+lM^z4|AfCS`z0gm7Cfv$0B^j_SaAGm1`EMO# z^`YK6H*x=5Fze#Z)^?kk+(lKit`5L%`uT;+%c4u1y*V9S*%n=sT|jjY$sP9bENA3J zBZ2VkJtgQ5`E@*9_Dl-&h8@)b5dK$tvG^=mu%4_;ELubxv`<1V=HilS;o=u9P8Ve< zDNo9FhA|f~y2>Ipmjy5RDU0IyO3?mnIoW#i%0xZ%+{|Lsn{I)3z1w9iZUAQ~rI9j= z#jDTQ94l{57U^7Q<*W#F$d&uh@OE#sJH$O zp7~GQ4EpHZFL&x{lO6I3@&Y3+m&)&3SKf>A0sNu zHID`1Ql2rrKU|3emOP zHUH4$kq12W@;;0vD-pc}`Qsg^Et?^m#6=geu>rY2v@o}0b!`1HwLM6lv2D>fQR-!z z0zvsCh0urd+{#-f{ago0u9h=|X8fJg3qdHahOMo6@E-oC5# zGQTwHV8icHJ@v7f(aA(Ve43L&NbY8az};fX0ti&Jg(IXzp!Vy5r}2!`#sLL8wHweA z97n7yk?kfeR7CE*|D_Hu2;cAm~d9#># zlZU$V`hvd7y;4I7b6f``Jra3yEi!9!89S}p#Tvhh8CP4ZX<=8Xi>GQ1WiH5to6>j-gy#Ywc?6%8k(d>(GL~}G*0%I9s5?CQg5iSN?rI~sw zwq2ZNpS1pkv^cy@M1qIfg=%hBp1al@7{;H27w+mVX-Z9{U#CYLo7exjO$o*n&?Amb z)~;h2RFlyClHQ#C7%#B+`2pZc#Q_HV@=YaKu=%2>=Hl{E973^Qco zh9lD-TB^CqU@Nx{=GcO3T`zxqV9r-Ba+HTda*v<~Bt74dbznq@T>z4Z(A<~36;r%U zMV5Y)#0pp9LHjDY1SGlp-ql~+x{Hczqez~r*%L>aLLQCy*p&rZwuMF6{adQ{1(_)q zBx0H#QnOme_cH{SG%P96TT9 z_kYykfJsnttN%AQsjiC^z3hLPmecL&!+;$-;H`Q$I%!H#lON_=YU|lh08#al7w(m-6uZ z)P0}(#R}s(T}?9#Rz;`>d3rOc%wD@f6pd&Wfc%6%QsWQfcTmunSD?DW`l<{>^KVa5 z6SMVW_nY=TyFSO^tI_^SW7sw6QfJ#)-_4Va)#rC6B9g|D~Q5%OTt{iI|C{t7gM<~BqXVpvU$k9`;pZnQdw*FlHDna?f^R%)4jmb`3tu*fg zPC>2fKEC?yz78d89A7u3UKCy{6Wc?JBaG6fSqZ66_@wep{9C)>>eHo<6I$NNCxzlk(6R z(Jq*R$7h{}G*c8O3MBQpx?wichjR!CVNpl}MNjSiknS`3I%NHwCnNsm?Ls})@gv<6 zndZBS$HL4_8tX001f84Y84Z23*3VhCX5SOOe9_LGgyp*m*dF1W_P=$?C@?$hwH(mD zrFND!zegHSa5&LZn?xDAVH7Sq%c(uh>mwqR9u@HS=S#`THcEnIU)y7@-+nIb)%A2#S&L{kc224ALL6KW#I^>L&wgvAxZ|59!{?UGZC82#U&_H~Ba{EVQO zo|BXpIK3}D$46p2`K&N9vY?*1G&i?JfB1_6LcyG$we>thP~W-@O(t-d0I7E2Mkx1# zO>e%)ld9_{-09pvK zppHdUxw7YIcs5x<&Eg1tBNGZ`1EJwi_Uew{&z0>H=B{$&wheh!&xlu7+8<>^&j`PF z>tFmEO92D)~^~h7GFSwon01WmYe$msLmmitWnv!~^=YJfY zH{beG=k5C;DktD^gk{zLn&#_kmg;*Vqs_$qdYD=FzVd(R{j~%3*JpTmYxO+1+B3}) zfjL?!qt;POAOzDeS6Nm97!UIjY44|C!Yi+NDq;DaZdJP%;Q)+pPADC)E6?#CuJC*a zqp#hI=st1J?uB2xdOhF05RQ!GP~9oA+L)%a2k%sZq6#%u?sM5jA{1$@N3z$OcBj7c%GsUSaa-nHM(RDAptJG?o~;QL`+%^x%5rhfu3 z%$)u+0(-#&oTi+q0+L&$>U*}k=4Z9_Kq^&Eh$$%~5b*ZujPXPuPO6XV$FhUz>x&-@ za;)zje*K~?jBFg}DZ(}ZUycc$8;CJTan7C6;RLqm{+G76S%iJO0NkYS zIZ+-kB1ZcD;zSJgp48^G{PnoVKd5KdR_-5XJe^hHKkojw%NfEeHKdBOAhg7!vif$l zvyaLM?`cXtMozFz2^_a6X70ra*c#C6GVnfO6K;}zt>65o<&VNv7UsNs{-?2eB>}Tk z8-3Nzr$`mXM=^4;=q$CjQQQxj68+=HyTXuYiP7cI5re=6|H#5ZF2QZV)|InfRnFEc zSaC$ZKBGBay;s&9PhJ%0&=t3?F;mH!%-XCvbX^@&X#Z3C^PO$itygcV)VqGM!^OZ_ z=iL81Rapi+b{2@k>@!~+Y{TB2!$hEw=`1~5D zuv}j}->tR8!>oZchaD+j8ccNU1hM?%9b~op54KUB?^GvBU$$Kyx1K26NoWF6E_EhC zxk0x?>Q)ObKFV;2P|W&%A{Jg>NsiHO0YQ`$=+uB7PYMmwj}=@U;1Y1I*k}BP+5lA@ zE^Adxj3@N=-!B?+$m2#vbeC0}{FL_ijQzD2;MG_%6GJT0QJM@0frSIaS|m2ZCHG=Z zki-N|dSh93dg1T^CkVGs@UQg_UU5aGR=DEi*oBub$~(_hRJey;@1Ohl)XB%+g6QBJ zwKGJ!NTkN8xWsT_Up-Pn1Xva;PvX|Taz?#j8~~AM!;L})=Dj*roDjukG{*^?WrJanh78!a*aszK+-gz)e>fxUelZJi&)g5>ecsuk2ko22 z;J11IpTA9g587-yAj$5v($2rH^5WF;mIM7YjTA3>Ia`#-ck+{u$D3YCmx)%e_myc| zp~_yHz2a_t1d>`pn)3&<^dtEcwzy-0YqGMWeSE^&etVFKc4d1t<)Ok|y8s%jF+aHH z)9Q275t`SR!|0oy<+P2Lk}cTtp#$Q)S9<6275+pet>??W@{Xe7u|vl`p7I?rJu&s; z+B9_7bKCwhSRlN7g&E2`s>z}R(zv8opI;R9u;5fO#xmszHC4y!9D)VPjB8a-)LUSR zD$V?OskUn{Gm~L`?2BvKbXULhzAT0{@bmqpsXDB;|JXO}1X!?sEg5vFb3s}tbD*)F zkO<;UA?QBB?7<%ck-1~+?KyZK4P$aCWy})AZssDqv6i!=rAXkIGb1^4wyOQE_;`@d zJ#B8j`<(oI-=V3kv}nW?u%{wvGxPnyFV$MBSQ%9@PvtYnGkV-ox)il5#0=_1`hug< zNV0KM*1NmphKZ(QcNjT)^GBql5`-j@wlu%b*^gUe+r4pf@H^%o~>sb3fk2tWzerwFGux3ny)m)(Y<(Okf~d;^_5Vo%t70M zd{(@*t=h3LnzIk7(HBW?@N`7de9Eilx*1-VG6f`(Vrw(z#9~m=$Vf5jb*R@-l$Kx{ z*6Cy`wIR!TtzEz1G)J=-yURWhd{Aq8zUfc5Ggg4c@VYHf~?6 z)^M*T)nx(aH%bYyff)^BB!vFPWu4jr%s45@y?aIV%|>i1jCrFpl91qIIF&M~F*w#H zfW=hpp!SOBO2`>Dy~Zu*#pBMl+cHudoB9m*z1yK!p$EPxt7&SpT$KYd@7>1hYkzrWzMzoS9oc>~jzx{1e))qWMN zS~~{MP;gc%S%SM(J1hi?hNY~axv*Y%vaYg6EMUGJdP!$fo*gI%n!XU;RhT>Mu%WE; zaM`11WWhgktp7P~!Qme0`7w*wA-IRi9)j5^Aw)vxcP>Un0U@ZN4CSboc1hrIq^@>8QTc+PQj(nE2bsT z3@0UvS;vMZoxdTO;)kuzvJZe;>Cw81ORsyKx?6nwX@luLx6{%`!p@WxN1l&lUeU#k zXHq6zjT2H+fRMBXGl^x)4fYZ40J)AgpS?Xr9zNPBXMTf|j$mnzGb~byS+6B>qL{l( z7$4Tx>0x%L@~$|W*d1nYsf%2Klg+3Ms{@xhEK5p$(bFurf-stUcDMD*$G#{tHN10W zrSZNgNxlPQ@uL^6^KE|mwT8X()Xh}y+jGAE4noN)5$a0m3s-u)f|~%HNSbz9&Mp|NLv9X#KLU=+lw7WA7LI;|E_3>~CcBRX*o~2tC!O2p>Rh2-clf9Z+9y z6K-KIs>0r?wR*2WX5`}VZ=s>Xzq>u~ENMzN`nh77meecpzBAD3 ztFrXbzJYQmF=<_%`>5=%!0IpBMBGNb)K>*!~p=uN_* zHrq&r@6ik^kX|YZZL(@om45?3J{WYbIHarHE_CcDD&?(UHJxg|q&l4S6zA9Zr17qP zdQ0ft#4n9(D)bg)Cd#wPcJt2PZOYj2*`x15>WXbo^f&G#NygQtUMf%RTM+>{hj2x+ zge%p-_*E$(*^X37P7-cxl2OcAJUFB48>88NXu7oQW(nW~BNCDzAD{d=03$}LkLLCF z44I~m<>xr37EQlR<1vZ`?+))IwGb5_K`^ng-%xXl66DA5)8q-~8tmZAVjpe^e%LBp z%c9jymnuu;qIp5tCg|9`yg-X(qRz>W$5fu(ld1L|rDg3CBLz+)xj*gQZ{&SMPaXX~ zLlD9wY~PqY+7F}`0FHztCj)tLn3u0@L*|T@(h`tY36R|1?6+@GdknnQui>mjo9jsN zJc3|{45oD9C!q0jYjcT5&~lr%jKk&b9(zs+@9^o(0nhM=zjaQulv+DN8mNW}E;bub zHO)Pd5N~5HuwrFki{knuW^1QO1l+!cw#>;~PFunV@uqvGPv`#WI~Oq23ZLjG;2ZjHt|Qb1bQM*li>F^_7El-7d*WYuEBr?>$NGxzZx0Bceu}2}gS#s4uP-`|=A*rVlMqIXbdrql{ZXLqa?74U@5SD1Y-6nQrsd+=a3Afn3 zGR@|%K2`f|{O0#hPVyE7nR>>Ze=_X(fHcem5<6XG1t`EKZLOtLskE=krjX ztOkiGcWhicKNkzAr_2#V!r_~!U5-j(I4jsrodP!*BfZ0BEtqG8dILGuc*w-GqOOAT z(k4=AXYX)3^(vBU`=^&li+_`CY3&|e-1+~MoBCan7tNzPREM*eGsVr}))-cIv&phQSAJO)<;b_9-{> zcr+2f9wJ3f0kGM3_9> zR$qAmZ?q^LV7||<52w8f=dSxYmM$9;Ol5DtDNh{whA`h#Lr*_bm#{qczY^TZ)3$a$ zOq)7!{Zq0T0i(a_Nu{_$6K7J$m5ZW;IX@ML$hP;`SB`h z*=A)!pi>|HZ1xfGdBPo!E1zmIwW~OFh>p-e?Sl8A5+HdPk3)NlyMTQpr8avuwl8zQ z?1_`UjC7M{D%i+pB^(o?e^#X3&8Z2NlPrL1S6b4^A1yWa@z{O6B*lFs*z!~+6?Q%4 zA4WXkA>{&h1@0^3D!f(O*axwO0!@;XpCBr+tU*@bVjq=BP62X(@z0?J*NsCvki5@P#vy)W&1%%&K?&@!F z304M&GO=bn#brL8HKWBl2j^eMO#7;P1uj6|{Z_O%jFdpkCX@;>i=WC|%rZ}OUKVUG zlpM%8HQenZym#(3u>zp3jN-w&P&BOU(4F>%j}->2m1ZeHy`)>|1bH&IemWqUV~V@V zd6{zFvJpnOCRL^iJ}-7zg-+hn@@=z9JJKA^4Of)j44;0CZO(e^^V{b9ryqLg*V5C$ zMxu!Hzo%rYD5YeDM76RB;>*Dqk(K4vLKl|NR~fkmx#1wHrX`7tMmO`v)dn(Jvd{{p zDrh-@S~Fg~=QZQNiqt2a!Yyj&_EPSW!0wEMn{4~YqW(S>*RL%X?M^=Ks-vwE$%-i# zk?9>sj|yNJ0QuDh0>yt$D-sNYoUMp-7+K8rVsc${sd2sBnnCDWj!+WEpEQ$htjEjs z#R`I$EZYSV_u860E~}qcv^>HVq>sqxr5D`ihLvx=6t@n%MUemXH;grRC`{&iB@=hB zQV!woI4)pYDYI;V-~DnBH$-2_{d4VdALFEY$r3eD#_KWm=w>_9wJy}U=|Hu z2i-^2dJjnJp$w17V((5LXpRn*=yF=Y*mslAfYs;UaS8o$Q@ips*biyVx@<4BIl1zC zhk6fvYm=qt)lu?Wsa4^!un{xMik1r<4_UC$Mub4wtkvALX)^nG5dJe;!?P|5Rk^2( z^rhoX=xAWj+TsuaYZOtd1zw8K3 zyK1P&E&bhnz79KUC#iQzZE6l1KnFh%paMGsJRgg!Mhw-A0e8qV`nRKbcL(^3yjjOU z;2_(m1ncL@9Y5N8cUv^MzR5fYUcC?ZUd`M5$kvm4qp+# z&YyJutJl%jz^bs|{EceLJqm7922YuxwcxxZl}Ku6iHI4;ob7`D0+L3ClKM!R0jPOh z^IwpD3`dG#oH})LRN$I3%vl$_eQ#F|x6Z|)qeMZawuPC!_1$wm?*qfm4yaB^&ba@~PXhy?Vx_$f_coze5@o=$eJYVh{ zc@hMgDnU%4wZxfxq_fmfRS4pHjw$zZK&~WHAc9z-h_6c&La5?7rs)+Tf zQA7~zl8(;{%(xUMgH+w8s-~F5YnZIzi!Qz; zXy+x!s5d!laTQuvOEJ)hO`F%Lw#j1C z5Zh<`hc3B!29^li(J;Q%>2>jn@-NTgBFB}uF3n4JHi<6_x3;NI`DYVCp5fbOoZeP zMqJBz0+YzQ|4>~2yV*|qyG;u;S81B)MZ*mhqV1QwY975O7cCFs=%Quy&ddg)%R0<1 zB^NqTe^`+4Fto1$S!!Esx&L)V)r->7PA6&}Ztf*DqR^*uSP;55D>Y1|WAk zbX737on;4a@0xn%@6Xx~i`esVk$l{U zM1Q3}AbcKBZiL@5QR~ml>St$ZijgzqWXci&0xA}~m+3br`J}y10+kNTcs2tgxC_hi zs{w#48m_3Ss#m1l2!F({Q|*|{3U_b&LS6I$4>}%@ga6RyYc~z3;aKP#%y|Tnc;mXV zH_82t%7oJF{TFW=}S{h()duUA^II-2qe6<-G}f02}^ zfLJtpkw)~l&ZTB9$=E59&)%$w17S>Z!nLk`LdgsCk_huXidf)Zt20Q6w!!pgVFNN> z=WhwKnu|UT`uJr(bSW$dGC68^t?_U>dDCPyVNm1^@r^2y-s_%rDLns}+iSVIlWEg8 z;CcCG&hKjB=3;RQfvBGknu-x_+NW{Jxs>u35bp(asH4 zIcs~6QYt8?LIZmZh%mXVK?6qZvDNL;{;UA${s-vN@w%vRPpr8fa`0UPDT>|h%|^|# zLPrByY?+t4%#o>x4WT5jHo_C#9^C7ME-RCW)Z~-ULx#S!!ZmH`HIb^FEZ2I6_@sg! zJfz->Wb_bQCrYrbOxf|MZ4!n#euH*)y?>ep54eWg-L;+kCF15)+d{v->yIw#Xc0tu z13_KKd+uuI8|P@-p`D4)`6lW;ouSlNno(m7&j7ct_Uh`NPxL9Wd)@nS>b5O&(YBHF zvN??18J;{@Wu!a9mt;4s{CceOV#e>zPI^-S4pN(5gk{vz%`wND4&dkITw{14851+nIns9#s+gT3;o%^O>fA8k$dZy942WwS0%W__!8Ad1#Q zL2Y9UmY*5tcOH6-(9=9XP>?_soGag}wlK%L3)DtvdbC=_5QIr_?)MXx)bho0jy^|(RoqdfbzRe3SH96py9cEhW|R4N>b-Q7TZ4un`NO7 zUoRJ5R?moXemf141Dtgt=;7y@|M64v!gOY??|YwKpWe4t{rw+wqTa$XUkuCgEI>mD zH%TwZ3R~=zl#(d4VHwN4s`;^`#ajEpUSM4K66(zLPQS)IV5;00!H*5I{w9({aM~zW zIJ&-RQIwAOd0cU_kGjryW==U>_JZfy3AliU{}?ULV6u|&e=0qs#!iawf-hjgO>}W$ z@5K|Y%FIo{RlvfgO&t;}^|PXN+d7`;U~Vqq>-P-#d~h*ulSlGi(ksr7M09pmr3$_Q zDQj|S{e+YhkD%XssTVrft6>1z$0 z;Mq?N^;)Fnk5E;6SSj8w+F&d~t0Q1OeeO{q$4g(un`zEYG!YC8%y*PJKF*jdx*T0n z0u+-EbI>iN2u(KubxuqlIItl^{!PsIlQDE;cVqv1esy z<|(e6QcF&l7TpR}1e4V+{afJeX{~*Nl>JLZuUZ4znQF(VxtQ~(G!cU-YatB&-l#Q% zsYcB4z8>$I>kQQ%+Q%9O)38&;_IJNjRepQzfwA6xg2ARmwtqZrUv~Cn@@mx@-KBd$ zK}m$tMEH7Y4G&*$uQ-mXswuio2jq(5GEqJ6rV?3fBUy-dF`~S@(Xh$eRZm&&C)(5N zS~OXcD(hn-dxNj&SG0{@vFR3OQooW{#?M=%ho(hSc2T7F{?>WXoIqhV_l+pSHFurP z%N1VL{YH3hF{|NAT|Z#WMxv-b+?_DZX>Z^ywT)VHAbxnPt+)Ls=9~~?iPmSAe$Rvw zs^Nu&Xg&8?UPiAqu0K@@q3$tKV<{S3s&YkSpP1TL>ya8NLVvqPnjRZQ!uJRW4}ELo{9B08#TUW*$a=Vu z)%)US0neX*U72zmvwN{U)m|KNddlUA{rHtQoSA%h_9SME+eX0R7b` zrBzo4>?2%?FRrWeK2}qHHv?w{*gq=##mp(7$yd`l;p$b9L3OSpPkF)%H4zeRsni zy?sQ8@6K@Uztwv!msymbXbtH4gLn6_Y`-V;iEdI*dNTl7-SM+hUviRm6$n?5aiJ``(PMl(!CFl9f)!R9a4erfZ|wR{cr2@7sf)?PsQT7 z^0=}#X31xFKf7S!v6d?~nuA>vcDyIv{q*BLv|WWjy7;d)udXs&ZQQ8zfe#*xfWY%L zO`5<}{?Wa04?Y)+V>n6Cl9C@X3QJ6wv)!x1sE!s);>_Vxr{d!^EUm7r@aZv13u z+U@o;n<_iMIn528)F=>u`iT2Pa*cJ6{XA?wAVVUxH)thrs=zk<3>seXRD4swsI!!c zTc#^8jG+vIJS8~1o85 zK&o&N-(Rgats@66I#w3-7(BBni9~s^@M*6} zL0Eu|Dd%N{-&FkUw8x=MW$_cI{7EnSm$@`wO}qe0JGCEwu9&@42d8sH66@+B^tU!q-=ww2HB z7xm*lBW`VHFvG0W)?`Mp`J~^L;YFL$etYu%*P$>SeSk@tN#I&LHR*7#k4ba24s4bt z-|0AJsyYb7BYoUnMoG}>c7T(Ux$t2=E1*`mwL?uWVm*A+MGo^4zQ*?ECFS89$>BCd z=nEJc5YwkWRAl|jbeaAbxe@%i0hRWKvq0^UDZ3&9(4wTyku+x{BmbsTz*E-ezD6Ja zxZ)v`AA-{E4ht7mwS@+a?8xXs?Vicg8RmhJJ>DzClwypWX?y zPNmOdsdheLLas^t*QCkiw)=c_2pu5ipfzN}sTZ!^uHk}~A0e4H$CjVkKM(jv>C!KC zgkX4?`In-jrhI*-2L)+K&4Ci)iXjdseU@oEf0B ziYD9?N#jH)vw4)E3enb8gaZ@h5YCmCB|<1RXtdbU3a|pE9v;(IonapnO4AwXO^+K} zlQHJ#d_`4nnxDuoe%yJ5IkxZ~J>O?DFV}nM5`&@fJ)tw*r=9Nsh3K!^YRPO0B-y>L zV8})`Vh5n4I{posfl~mMen@~(lNH)ktjIb~lA(IXQ&x@_dMW=Q^}Ot_V6S=;Q+97~ ze&VLmz2)fGBV|P8(DbLQ;{Lb-9Ym+(KaMYuz5Bae4Sj-`wVXC&9_0cJ|CT(Oi>U_q ziR-|tia(dZGqRyA#UD8uq#h&@EbDkN5+fJl;B;4RaG>d6Us&}%s7yRUe+FCiler425V_9`zUlLHH07PKcHu_N6Bm7L7Y#j5OQbDA z0wm@`G-D@S8KTQFma((HLq}4Y=QH}(p$CJM+Es7R+hu0X>B&y^hMxvY+u0`{AGrDe zzj5a7F~EuCK6-n;pg)?k3`%%IcyS%tE<{IkfrM{Ru_~3f@Yu&{Yt*eybR*jy-|-my zBieEkVoNB?S?LXkq2Rzn(Jl2Z$Hw#EqT#2u~|+p+u$ zy4pk@Iza&W4b56d<#K>)mg4$=GXN#pH6j$?TKGo(=uz0Y`Ucv5UP(YA#|{s#{#!>E z)*F&g0ggPzY;QcCCi`3G#-zu2@f1y;d~B)?fTE!91X=?r{2sL}wxplb!9HzsHf(LL zbKIx{C;#cLOAkn0of7p@XzFAIFhZ__M*Vi>DpoPT*Y80xmppsjW#{tU zn<~MZi@R5qyei8cmhXPvC*Jffv6}Q-fO;`mJKs-b!rHE#4^evdDcEUsumgy=J4|zx zjd%fIn@QiOqcHz5XerL^vWc>kBlT2M4R^)?V^fzY@y2BEaj}cl3lV6V`$j(Q8&1DA zWjedG2V3`!`#1=0a`ViEp<4m)G3M?rfh!n5@1~qnr5NfT=m^UkiPDEA;4V4fP@Olw zWAyzTN;AOE*H?&=*lvXf7A(xT7AsR&p6IrXY)GI&8PcapiKOqO{ELa(zXA(Zkk@k2 zycS7|O{eOcZ(GaVPL&3Gaor^sLNJ z|0FiMpDPvhZkG|j*y^e8klk_rrhNQ-O}`1oDUef)N4itym9>>v8y^M&f@61l+q+)x z+&yOqpZHqCD0mGwwGEP4f~~3mnEsBacB*Z0V>!(nn5?NT3oF5>@1ve9ALIJ-o7Wh?sWVtvaTrI!szbF6k0jh3U`KPn!6uvD^;ZecPtdIgx7onu76g& z5x+QqB!XF1w`vosp}E$dBZH4nNJ0J~HOa=Q$l)Q;!9 zeIS6U$(FnYfBW&nZPw&4vm=Dh&00e^u48U+8hAPgjO_sHHq|)ZOP1MJa}x_x2OyI( z$s{&#VSFY-5(Imtd?J+1yQGC1o$TdY)1Ya)q#}l==@+qr9+vt|@96MrPkU4UN3Xy7 zoc?eiNLTfnDh#Rdf@GFsp-C0dn=%P~h|&*4HJ-AK@FbZBp++>HzELwmwlz(V` zaoGs8!u(MgEWySbY(w-sKy`R2{x)3#22fqglAR1?KRVe;X~qnPth88q!N`_=Fp}{? z`-U9s?lG_0Op#eiJFhdOPa*%W0jmD=R}N^1dLdB3)*&}U^96LeDiriI7kDsgObt9_ zlw^^)%QCe!o*-*dnQfSOOWg+w!0Hfrs&QS#!utv!ibnueN zzi9+5X(6G*mTAz7r-MNAzZm5CdHF7)g_!|%#ZTYUE5ekqSBs*NqZSbBV&1?j+R9ml zThtw{#PZBxLebC+bGG(ytWYj2N4@GQ z2zBEN0y#z^uggX|U|7n`1}dldBAV}t&r zuS|r03(#L;y^DDZo0|_a z+nJtPJlOKM|AYTzH}(AENvd<>@1Xa|4U06^y#!4SpwDL7G;Gf_zzqjEp*SAN&J$Yl zIh=a&++n`OIm%+}QL#g<5J9jTh`*XrueS{7T2*Q8>0!rBstJ2=bXeB@Ab)TBK``R^ z;t%J&n~V)PB;*sU_y6N8&dSg&-#oon2b@Xhe8g7l)huJQDFby>X}KzzvCfTVi^U$Q zMJkg#w<&!#1VR2D+p_y^#735nDz9_zPZ7j*9ih+KblX0s$upOHyqEi?H`4lhe9|GK z{hvSC=KKfJx6PL91-8-i$&(M#{TDUcyf-;pJ!_d4B@zlAsu)`}WAy^%z-5-9o@QQy zD`p;d#%aPDUTxWnWuXH@WBZ;~psSd&qf+&=O5|i|-27D^<7nIpfAhOX1}CjEDk^NU zn3u;WH!K65`5Eai-o-WJuuTNrVqOqj^rWFNliNr$`p`WkZX8Pv-uM*rLDGE=_J3rN z?qY=j9m1&7pBkn{zI^KShYgPF;vBYLT4c77npS3&9eQ?+U)jDPV;#T=b!$jUHLox2 zleP=nj^t{~u#9N5Sqa9gcxxs{6*}&2SlUsn^H#PlWIoh|8>~=5Vjfa)E6lmcby`o9 z1w9cbs=MPIj+7iEuP;jXhkM@{3({uYUt;A`1ww1Z);UTTeKHOA1%TvgPm&4I#?U(8 z)Gw2X%}k>^#d^{MU!6pj6?nv7yiXLx?!k)M>BGxY(b^jK-qy~qS9JeZnGaI5%rS%Jf++Brl<|Of^$xM!Bb}j>;1*bA{B(C5W` zV%};H&Fd0oTkq@|6SgK%ZNjIPyp<1SXn%;zyjXFqDdT2q;&H8(1+|AR<P`POI3Vp1D{tZP?4^blF>*lAyIIrP6n|PHp_z&7bUrrRRL`AJc|$hRaO%E7 zRey}6**|M<$M@A;n?}lqvx5i5_WQWK*6L5KdY<0Vq*YmT@5fDi4HL+_oH`fr;7Y}l zR>NAs3zC6RVDk5BeLiHu;7i@|;qF}6&5CkE3YxoJ2Fw!PMZRe?rT3HCx+%(tbBg7~ zZ4x;mywNSHKf7HBivq}A8A&xJxpcgZZfz9Y;~skI+^A7ky+4_MsC|4o{#%{N)EoF% zKKva2|IgLQ#7ZSk?hCHLP?^k{sclF~$JBF*B=Ld~=rvg+z}&A4N2>%2>A}W`FoWy4 zJzf=0b-XEUAliBe^ZuZcl3iYgwNFg%mL6&^5u7W`a_YN?Ym789Byp6z1i41b(f(Ef^B%3^#|m8^k(#r~TP@9R2pysPMr%4_!I zUM#K{Dzf^Wg+2>zg+6{0@zapMn_tXk8ucRQ9!2M6@-HdMo(1|u4(iN?y5Up&R{UGQ z;AZW{-Xj`qI;_pB>~680XyMSL{R%7$b<)hP7GQ*+DkQd*-7e4&{Fk3vmFgGK8_TDzs@$pR>z<% z67j4F3U|N>%MwHcn_)2*-0LHVn_hu&WB{s2BAoKvqUt~SPTE{`SO(c7)h$l(XGdah zU%lPi)s&SV36zwKWR9{>f$U|>W1gj>%&(fz7X3oYBRVB3?a65tIZ(NGbJ0>)@ z=S`%wpWO6Hjg$ISNTdC_?)=OB>W-Q%s@m0uWy0!rmC|$ZZqxgpS5|;>@enNVAE0iy zFD|WwqJbwye77*J^X+sdhzCw?tJx`N5?sxcH3=-kN%J8s6Z@{Ek#Ct-U7FPx?A!&z zjR0oJ*Pa*d9&J>mr0$K3sZhwz4z>q1K2X#c;X5nAZ^((Sv$RyTUiLh!sU>SC^fy^M zLsZUY2V|$gR|@DVVEd6|@7|p&9Ub6|i05HW_3TYuEN^wt`M+;Om5(~^dGhS}^Jh1# z=Ptb&aFLWDz+7pDd2_+_y%pY&vC*}NwFc}S<{}a$iRVg5v!*R`nakdcHA@upYu3Wp zaY!^5u}(PRLa@Ok2Rc}rFQ|Rok09f(;#DU*@5)X{8BxNZP0a_TBNHvDOHHQs=pR6C z79ZIw;PQ=$n=sLP?I3`_CtU=_%w*>pN2E}bFCkpxEUfV^7&>n;XyeCIF^nZ9+&Dc{t=m=oR1rn=Mf2{7*_wlR3i)qVx}o`7ynq+0JrXXzgnDx)Hu!}dr^ z@;c8IoRDt%7KO;895^V zZQS_pY}hfoICZ-@$9RcA$UbZnYo2j#yQyT+LZrexn%q1K6DMYs#esQ>cOAZMXx zU@?{}iEU00QtXGO>~CwFOrKsDbv=hnyq$pU#?|alN`sY0sHj*qTztahW9!Vzvc%Ug zw-=|g(%tvdmp~0FX-0{@-WkKb?jzKm7MJXn>Eoa}9If zBBq9Ugy~aQAzXFTDYO`D^JD7ymj_m2Wd%$#?`|P)ew}7?7k)8w`wRK$tHszY2{nhO z&c=H`&1&K{e6>ea>+jPEG0*fZ)}m|mYV9{$NtL#(m|LM?PGxjhbd>2xe009gnJCYN zLspz}(&+BVbc#&w@0?D+

) : null} diff --git a/src/renderer/src/components/settings/AppearancePane.test.tsx b/src/renderer/src/components/settings/AppearancePane.test.tsx index ca68ff7e47c0..0550e9704a62 100644 --- a/src/renderer/src/components/settings/AppearancePane.test.tsx +++ b/src/renderer/src/components/settings/AppearancePane.test.tsx @@ -10,6 +10,7 @@ import type { GlobalSettings, StatusBarItem } from '../../../../shared/types' const mocks = vi.hoisted(() => ({ state: { + appPlatform: 'linux' as NodeJS.Platform, availableStatusBarToggles: [] as { description: string id: StatusBarItem @@ -179,12 +180,16 @@ describe('AppearancePane', () => { beforeEach(() => { vi.clearAllMocks() mocks.state.availableStatusBarToggles = [] + mocks.state.appPlatform = 'linux' mocks.state.settingsSearchQuery = 'automations' mocks.state.usagePercentageDisplay = 'used' // UIZoomControl reads window.api.ui on mount; the inline-expansion pane can // render the full Interface section, so provide a minimal renderer bridge // without clobbering happy-dom's window.location. ;(window as unknown as { api: unknown }).api = { + platform: { + get: () => ({ platform: mocks.state.appPlatform }) + }, ui: { getZoomLevel: () => 0, onTerminalZoom: () => () => {}, @@ -359,6 +364,23 @@ describe('AppearancePane', () => { ).not.toBeNull() }) + it('shows and updates the menu bar icon preference only on desktop macOS', async () => { + mocks.state.appPlatform = 'darwin' + mocks.state.settingsSearchQuery = 'menu bar' + const updateSettings = vi.fn() + const container = await renderAppearancePane(getDefaultSettings('/tmp'), updateSettings) + const toggle = container.querySelector( + 'button[role="switch"][aria-label="Show Menu Bar Icon"]' + ) + + expect(toggle).not.toBeNull() + expect(container.textContent).not.toContain('Minimize to Tray on Close') + await act(async () => { + toggle?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + expect(updateSettings).toHaveBeenCalledWith({ showMenuBarIcon: false }) + }) + it('keeps description-only search matches visible after helper text is hidden', async () => { mocks.state.settingsSearchQuery = 'app window' const container = await renderAppearancePane(getDefaultSettings('/tmp')) diff --git a/src/renderer/src/components/settings/AppearancePane.tsx b/src/renderer/src/components/settings/AppearancePane.tsx index 631059d6d3ab..58954d325655 100644 --- a/src/renderer/src/components/settings/AppearancePane.tsx +++ b/src/renderer/src/components/settings/AppearancePane.tsx @@ -16,6 +16,7 @@ import { getAppearancePaneSearchEntries, getLanguageEntries, getLayoutEntries, + getMenuBarIconEntries, getSidebarEntries, getStatusBarEntries, getSystemTrayEntries, @@ -85,6 +86,7 @@ export function AppearancePane({ // Why: the system tray behavior is desktop-Electron Windows-only; a Windows // browser web client has no local tray to control. const isDesktopWindows = getRendererAppPlatform() === 'win32' && !isWebClient + const isDesktopMac = getRendererAppPlatform() === 'darwin' && !isWebClient const [manuallyOpenSection, setManuallyOpenSection] = useState( 'interface' @@ -134,7 +136,8 @@ export function AppearancePane({ ...getTypographyEntries(), ...(SHOW_UI_LANGUAGE_SETTING ? getLanguageEntries() : []), ...getTitlebarEntries(), - ...getSystemTrayEntries({ showSystemTray: isDesktopWindows }) + ...getSystemTrayEntries({ showSystemTray: isDesktopWindows }), + ...getMenuBarIconEntries({ showMenuBarIcon: isDesktopMac }) ] const terminalSearchEntries = [ { title: terminalTitle }, @@ -207,6 +210,7 @@ export function AppearancePane({ applyTheme={applyTheme} fontSuggestions={fontSuggestions} onRequestFontSuggestions={onRequestFontSuggestions} + isDesktopMac={isDesktopMac} isDesktopWindows={isDesktopWindows} forceVisiblePrimary={interfaceLabelMatches} /> diff --git a/src/renderer/src/components/settings/appearance-search.ts b/src/renderer/src/components/settings/appearance-search.ts index 3ceaf412d134..3e15d266221f 100644 --- a/src/renderer/src/components/settings/appearance-search.ts +++ b/src/renderer/src/components/settings/appearance-search.ts @@ -2,15 +2,19 @@ import type { SettingsSearchEntry } from './settings-search' import { getTerminalAppearanceSearchEntries } from './terminal-search' import { getLeftSidebarAppearanceEntry, getSidebarEntries } from './appearance-sidebar-search' import { createLocalizedCatalog } from '@/i18n/localized-catalog' -import { getRendererAppPlatform } from '@/lib/renderer-app-platform' -import { isWebClientLocation } from '@/lib/web-client-location' import { translate } from '@/i18n/i18n' import { translateSearchKeyword } from './settings-search-keywords' import { SHOW_UI_LANGUAGE_SETTING } from '@/i18n/supported-languages' import { getStatusBarToggles } from './appearance-status-bar-search' import { getUsagePercentageDisplayEntry } from './appearance-usage-percentage-search' +import { getMenuBarIconEntries, getSystemTrayEntries } from './appearance-system-presence-search' -export { getStatusBarToggles, getUsagePercentageDisplayEntry } +export { + getMenuBarIconEntries, + getStatusBarToggles, + getSystemTrayEntries, + getUsagePercentageDisplayEntry +} export const getThemeEntries = createLocalizedCatalog((): SettingsSearchEntry[] => [ { @@ -188,65 +192,6 @@ export const getAppIconEntries = createLocalizedCatalog((): SettingsSearchEntry[ } ]) -const getSystemTrayEntryCatalog = createLocalizedCatalog((): SettingsSearchEntry[] => [ - { - title: translate( - 'auto.components.settings.appearance.search.9a115966d3', - 'Minimize to Tray on Close' - ), - description: translate( - 'auto.components.settings.appearance.search.4d5b9427b5', - 'When enabled, closing the window keeps Orca running in the system tray instead of quitting.' - ), - keywords: [ - ...translateSearchKeyword('auto.components.settings.appearance.search.tray.tray', 'tray', { - englishOnly: true - }), - ...translateSearchKeyword( - 'auto.components.settings.appearance.search.tray.system', - 'system tray', - { englishOnly: true } - ), - ...translateSearchKeyword( - 'auto.components.settings.appearance.search.tray.minimize', - 'minimize', - { englishOnly: true } - ), - ...translateSearchKeyword('auto.components.settings.appearance.search.tray.close', 'close', { - englishOnly: true - }), - ...translateSearchKeyword('auto.components.settings.appearance.search.e5bc35d59e', 'window'), - ...translateSearchKeyword( - 'auto.components.settings.appearance.search.tray.notification', - 'notification area', - { englishOnly: true } - ), - ...translateSearchKeyword( - 'auto.components.settings.appearance.search.tray.background', - 'background', - { englishOnly: true } - ) - ] - } -]) - -type SystemTraySearchOptions = { - showSystemTray?: boolean -} - -function shouldShowSystemTrayEntries(options: SystemTraySearchOptions): boolean { - return ( - options.showSystemTray ?? - // Why: this setting controls Electron's Windows tray only. A Windows web - // browser can report win32, but it has no local tray to affect. - (getRendererAppPlatform() === 'win32' && !isWebClientLocation()) - ) -} - -export function getSystemTrayEntries(options: SystemTraySearchOptions = {}): SettingsSearchEntry[] { - return shouldShowSystemTrayEntries(options) ? getSystemTrayEntryCatalog() : [] -} - const getAppearanceSectionEntries = createLocalizedCatalog((): SettingsSearchEntry[] => [ { title: translate('auto.components.settings.AppearancePane.interfaceTitle', 'Interface') @@ -269,6 +214,7 @@ const getAppearanceSectionEntries = createLocalizedCatalog((): SettingsSearchEnt type AppearancePaneSearchOptions = { showWarpImport?: boolean showSystemTray?: boolean + showMenuBarIcon?: boolean } function buildAppearancePaneSearchEntries( @@ -286,7 +232,8 @@ function buildAppearancePaneSearchEntries( ...getStatusBarEntries(), ...getSidebarEntries(), ...getAppIconEntries(), - ...getSystemTrayEntries(options) + ...getSystemTrayEntries(options), + ...getMenuBarIconEntries(options) ] } @@ -295,6 +242,7 @@ export function getAppearancePaneSearchEntries( ): SettingsSearchEntry[] { return buildAppearancePaneSearchEntries({ showWarpImport: options.showWarpImport ?? true, - showSystemTray: options.showSystemTray + showSystemTray: options.showSystemTray, + showMenuBarIcon: options.showMenuBarIcon }) } diff --git a/src/renderer/src/components/settings/appearance-system-presence-search.ts b/src/renderer/src/components/settings/appearance-system-presence-search.ts new file mode 100644 index 000000000000..f392da6c271c --- /dev/null +++ b/src/renderer/src/components/settings/appearance-system-presence-search.ts @@ -0,0 +1,103 @@ +import { translate } from '@/i18n/i18n' +import { createLocalizedCatalog } from '@/i18n/localized-catalog' +import { getRendererAppPlatform } from '@/lib/renderer-app-platform' +import { isWebClientLocation } from '@/lib/web-client-location' +import type { SettingsSearchEntry } from './settings-search' +import { translateSearchKeyword } from './settings-search-keywords' + +const getSystemTrayEntryCatalog = createLocalizedCatalog((): SettingsSearchEntry[] => [ + { + title: translate( + 'auto.components.settings.appearance.search.9a115966d3', + 'Minimize to Tray on Close' + ), + description: translate( + 'auto.components.settings.appearance.search.4d5b9427b5', + 'When enabled, closing the window keeps Orca running in the system tray instead of quitting.' + ), + keywords: [ + ...translateSearchKeyword('auto.components.settings.appearance.search.tray.tray', 'tray', { + englishOnly: true + }), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.tray.system', + 'system tray', + { englishOnly: true } + ), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.tray.minimize', + 'minimize', + { englishOnly: true } + ), + ...translateSearchKeyword('auto.components.settings.appearance.search.tray.close', 'close', { + englishOnly: true + }), + ...translateSearchKeyword('auto.components.settings.appearance.search.e5bc35d59e', 'window'), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.tray.notification', + 'notification area', + { englishOnly: true } + ), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.tray.background', + 'background', + { englishOnly: true } + ) + ] + } +]) + +const getMenuBarIconEntryCatalog = createLocalizedCatalog((): SettingsSearchEntry[] => [ + { + title: translate('settings.appearance.menuBarIcon.title', 'Show Menu Bar Icon'), + description: translate( + 'settings.appearance.menuBarIcon.description', + 'Keep an Orca shortcut and activity indicator in the macOS menu bar.' + ), + keywords: [ + ...translateSearchKeyword('settings.appearance.menuBarIcon.keyword.menuBar', 'menu bar', { + englishOnly: true + }), + ...translateSearchKeyword('auto.components.settings.appearance.search.tray.tray', 'tray', { + englishOnly: true + }), + ...translateSearchKeyword('auto.components.settings.appearance.search.1f2880a9d5', 'orca'), + ...translateSearchKeyword( + 'settings.appearance.menuBarIcon.keyword.statusItem', + 'status item', + { englishOnly: true } + ), + ...translateSearchKeyword('settings.appearance.menuBarIcon.keyword.activity', 'activity', { + englishOnly: true + }), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.tray.background', + 'background', + { englishOnly: true } + ) + ] + } +]) + +type SystemPresenceSearchOptions = { + showMenuBarIcon?: boolean + showSystemTray?: boolean +} + +export function getSystemTrayEntries( + options: SystemPresenceSearchOptions = {} +): SettingsSearchEntry[] { + const show = + options.showSystemTray ?? + // Why: a Windows web client can report win32, but it has no local tray. + (getRendererAppPlatform() === 'win32' && !isWebClientLocation()) + return show ? getSystemTrayEntryCatalog() : [] +} + +export function getMenuBarIconEntries( + options: SystemPresenceSearchOptions = {} +): SettingsSearchEntry[] { + const show = + options.showMenuBarIcon ?? (getRendererAppPlatform() === 'darwin' && !isWebClientLocation()) + return show ? getMenuBarIconEntryCatalog() : [] +} diff --git a/src/renderer/src/components/settings/terminal-search.test.ts b/src/renderer/src/components/settings/terminal-search.test.ts index 5811f5dcf024..bf642e83735a 100644 --- a/src/renderer/src/components/settings/terminal-search.test.ts +++ b/src/renderer/src/components/settings/terminal-search.test.ts @@ -172,6 +172,15 @@ describe('getTerminalPaneSearchEntries', () => { expect(matchesSettingsSearch('tray', webEntries)).toBe(false) }) + it('includes the macOS menu bar entry only when its desktop control is shown', () => { + const macEntries = getAppearancePaneSearchEntries({ showMenuBarIcon: true }) + const otherEntries = getAppearancePaneSearchEntries({ showMenuBarIcon: false }) + + expect(macEntries.some((entry) => entry.title === 'Show Menu Bar Icon')).toBe(true) + expect(otherEntries.some((entry) => entry.title === 'Show Menu Bar Icon')).toBe(false) + expect(matchesSettingsSearch('status item', macEntries)).toBe(true) + }) + it('keeps sidebar shortcut restore settings in the Appearance search index', () => { const automationsEntry = getSidebarEntries().find( (entry) => entry.title === 'Show Automations Button' diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 86791576c1cb..16f5deaddd8c 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -313,6 +313,7 @@ describe('useIpcEvents zoom routing', () => { }, agentStatus: { onSet: () => () => {} }, ui: makeEvents({ + consumePendingOpenSettings: () => Promise.resolve(false), onTerminalZoom: (listener: (direction: 'in' | 'out' | 'reset') => void) => { terminalZoomListenerRef.current = listener return () => {} @@ -455,6 +456,7 @@ describe('useIpcEvents zoom routing', () => { }, agentStatus: { onSet: () => () => {} }, ui: makeEvents({ + consumePendingOpenSettings: () => Promise.resolve(false), onTerminalZoom: (listener: (direction: 'in' | 'out' | 'reset') => void) => { terminalZoomListenerRef.current = listener return () => {} @@ -666,6 +668,7 @@ describe('useIpcEvents rate-limit hydration', () => { }, agentStatus: { onSet: () => () => {} }, ui: makeEvents({ + consumePendingOpenSettings: () => Promise.resolve(false), getZoomLevel: vi.fn(() => 0) }) } @@ -954,6 +957,7 @@ describe('useIpcEvents browser tab create routing', () => { ui: { onStateChanged: () => () => {}, onOpenSettings: () => () => {}, + consumePendingOpenSettings: () => Promise.resolve(false), onOpenFeatureTour: () => () => {}, onToggleLeftSidebar: () => () => {}, onToggleRightSidebar: () => () => {}, @@ -1176,6 +1180,7 @@ describe('useIpcEvents updater integration', () => { ui: { onStateChanged: () => () => {}, onOpenSettings: () => () => {}, + consumePendingOpenSettings: () => Promise.resolve(false), onOpenFeatureTour: () => () => {}, onToggleLeftSidebar: () => () => {}, onToggleRightSidebar: () => () => {}, @@ -1295,6 +1300,130 @@ describe('useIpcEvents updater integration', () => { expect(removeSshCredentialRequest).toHaveBeenCalledWith('req-1') }) + it('opens Settings from a Settings intent queued before the listener attached', async () => { + const openSettingsPage = vi.fn() + let onOpenSettingsRegistered = false + + vi.doMock('react', async () => { + const actual = await vi.importActual('react') + return { + ...actual, + useEffect: (effect: () => void | (() => void)) => { + effect() + } + } + }) + + vi.doMock('../store', () => ({ + useAppStore: { + subscribe: vi.fn(() => () => {}), + getState: () => ({ + openSettingsPage, + fetchRepos: vi.fn(), + fetchWorktrees: vi.fn(), + setActiveView: vi.fn(), + activeModal: null, + closeModal: vi.fn(), + openModal: vi.fn(), + activeWorktreeId: 'wt-1', + activeView: 'terminal', + setActiveRepo: vi.fn(), + setActiveWorktree: vi.fn(), + revealWorktreeInSidebar: vi.fn(), + setIsFullScreen: vi.fn(), + setUpdateStatus: vi.fn(), + setRateLimitsFromPush: vi.fn(), + settings: { terminalFontSize: 13 } + }) + } + })) + + vi.doMock('@/lib/ui-zoom', () => ({ applyUIZoom: vi.fn() })) + vi.doMock('@/lib/worktree-activation', () => ({ + activateAndRevealWorktree: vi.fn(), + ensureWorktreeHasInitialTerminal: vi.fn() + })) + vi.doMock('@/components/sidebar/visible-worktrees', () => ({ + getVisibleWorktreeIds: () => [] + })) + vi.doMock('@/lib/editor-font-zoom', () => ({ + nextEditorFontZoomLevel: vi.fn(() => 0), + computeEditorFontSize: vi.fn(() => 13) + })) + vi.doMock('@/components/settings/SettingsConstants', () => ({ + zoomLevelToPercent: vi.fn(() => 100), + ZOOM_MIN: -3, + ZOOM_MAX: 3 + })) + vi.doMock('@/lib/zoom-events', () => ({ dispatchZoomLevelChanged: vi.fn() })) + + const makeEvents = (target: Record = {}): Record => + new Proxy(target, { + get: (namespace, prop) => + prop in namespace ? Reflect.get(namespace, prop) : () => () => {} + }) + + vi.stubGlobal('window', { + api: { + repos: makeEvents(), + worktrees: makeEvents(), + keybindings: makeEvents(), + settings: makeEvents(), + browser: makeEvents(), + updater: { + getStatus: () => Promise.resolve({ state: 'idle' }), + onStatus: () => () => {}, + onClearDismissal: () => () => {} + }, + rateLimits: { + get: () => Promise.resolve({ limits: {}, lastUpdatedAt: Date.now() }), + onUpdate: () => () => {} + }, + runtime: { + getTerminalFitOverrides: () => Promise.resolve([]), + getTerminalDrivers: () => Promise.resolve([]), + getBrowserDrivers: () => Promise.resolve([]), + onTerminalFitOverrideChanged: () => () => {}, + onTerminalDriverChanged: () => () => {}, + onBrowserDriverChanged: () => () => {} + }, + ssh: { + listTargets: () => Promise.resolve([]), + listPortForwards: () => Promise.resolve([]), + listDetectedPorts: () => Promise.resolve([]), + getState: () => Promise.resolve(null), + onStateChanged: () => () => {}, + onCredentialRequest: () => () => {}, + onCredentialResolved: () => () => {}, + onPortForwardsChanged: () => () => {}, + onDetectedPortsChanged: () => () => {} + }, + agentStatus: { onSet: () => () => {} }, + ui: makeEvents({ + onOpenSettings: () => { + onOpenSettingsRegistered = true + return () => {} + }, + // Why: exercise the positive branch — an intent queued before mount is + // pulled once the renderer's onOpenSettings listener is attached. + consumePendingOpenSettings: () => Promise.resolve(true), + getZoomLevel: () => 0, + set: vi.fn() + }) + } + }) + + const { useIpcEvents } = await import('./useIpcEvents') + + useIpcEvents() + // Why: the pending-intent pull is a Promise; flush microtasks before asserting. + await Promise.resolve() + await Promise.resolve() + + expect(onOpenSettingsRegistered).toBe(true) + expect(openSettingsPage).toHaveBeenCalledTimes(1) + }) + it('clears stale remote PTYs when an SSH connection fully disconnects', async () => { const clearTabPtyId = vi.fn() const setSshConnectionState = vi.fn() @@ -1421,6 +1550,7 @@ describe('useIpcEvents updater integration', () => { ui: { onStateChanged: () => () => {}, onOpenSettings: () => () => {}, + consumePendingOpenSettings: () => Promise.resolve(false), onOpenFeatureTour: () => () => {}, onToggleLeftSidebar: () => () => {}, onToggleRightSidebar: () => () => {}, @@ -1862,6 +1992,7 @@ describe('useIpcEvents updater integration', () => { ui: { onStateChanged: () => () => {}, onOpenSettings: () => () => {}, + consumePendingOpenSettings: () => Promise.resolve(false), onOpenFeatureTour: () => () => {}, onToggleLeftSidebar: () => () => {}, onToggleRightSidebar: () => () => {}, @@ -2809,6 +2940,7 @@ describe('useIpcEvents browser tab close routing', () => { ui: { onStateChanged: () => () => {}, onOpenSettings: () => () => {}, + consumePendingOpenSettings: () => Promise.resolve(false), onOpenFeatureTour: () => () => {}, onToggleLeftSidebar: () => () => {}, onToggleRightSidebar: () => () => {}, @@ -3365,6 +3497,7 @@ describe('useIpcEvents browser tab close routing', () => { ui: { onStateChanged: () => () => {}, onOpenSettings: () => () => {}, + consumePendingOpenSettings: () => Promise.resolve(false), onOpenFeatureTour: () => () => {}, onToggleLeftSidebar: () => () => {}, onToggleRightSidebar: () => () => {}, @@ -3583,6 +3716,7 @@ describe('useIpcEvents browser tab close routing', () => { ui: { onStateChanged: () => () => {}, onOpenSettings: () => () => {}, + consumePendingOpenSettings: () => Promise.resolve(false), onOpenFeatureTour: () => () => {}, onToggleLeftSidebar: () => () => {}, onToggleRightSidebar: () => () => {}, @@ -3796,6 +3930,7 @@ describe('useIpcEvents browser tab close routing', () => { ui: { onStateChanged: () => () => {}, onOpenSettings: () => () => {}, + consumePendingOpenSettings: () => Promise.resolve(false), onOpenFeatureTour: () => () => {}, onToggleLeftSidebar: () => () => {}, onToggleRightSidebar: () => () => {}, @@ -4027,6 +4162,7 @@ describe('useIpcEvents CLI-created worktree activation', () => { ui: { onStateChanged: () => () => {}, onOpenSettings: () => () => {}, + consumePendingOpenSettings: () => Promise.resolve(false), onOpenFeatureTour: () => () => {}, onToggleLeftSidebar: () => () => {}, onToggleRightSidebar: () => () => {}, @@ -4284,6 +4420,7 @@ describe('useIpcEvents CLI-created worktree activation', () => { ui: { onStateChanged: () => () => {}, onOpenSettings: () => () => {}, + consumePendingOpenSettings: () => Promise.resolve(false), onOpenFeatureTour: () => () => {}, onToggleLeftSidebar: () => () => {}, onToggleRightSidebar: () => () => {}, @@ -4518,6 +4655,7 @@ describe('useIpcEvents agent status snapshot integration', () => { ui: { onStateChanged: () => () => {}, onOpenSettings: () => () => {}, + consumePendingOpenSettings: () => Promise.resolve(false), onOpenFeatureTour: () => () => {}, onToggleLeftSidebar: () => () => {}, onToggleRightSidebar: () => () => {}, diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index e84df727e4d6..838cf50720e0 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -1215,6 +1215,18 @@ export function useIpcEvents(): void { }) ) + // Why: a tray/menu-bar "Settings…" click can fire before this listener + // attaches on a fresh window; consume any intent queued for us. Guarded + // with `?.` so a stale preload bundle doesn't crash the listener set. + void window.api.ui + .consumePendingOpenSettings?.() + .then((open) => { + if (open) { + useAppStore.getState().openSettingsPage() + } + }) + .catch(() => {}) + unsubs.push( window.api.ui.onOpenSetupGuide?.(() => { useAppStore.getState().openModal('setup-guide', { telemetrySource: 'help_menu' }) diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts index e39c75db2bfc..60ab54ac4c4a 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts @@ -382,7 +382,8 @@ export function buildSettingsNavigationMetadata({ icon: Palette, searchEntries: getAppearancePaneSearchEntries({ showWarpImport: showDesktopOnlySettings, - showSystemTray: showDesktopOnlySettings && isWindows + showSystemTray: showDesktopOnlySettings && isWindows, + showMenuBarIcon: showDesktopOnlySettings && isMac }), group: 'interface' }, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 5fa8b146a617..c2f095e2aad5 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -31,6 +31,10 @@ "portsToggleDescription": "Show live workspace ports. Click it for workspace-scoped ports and external listeners.", "antigravityToggleDescription": "Show Antigravity subscription usage for the active workspace.", "grokToggleDescription": "Show Grok subscription credit usage when signed in via Grok CLI." + }, + "menuBarIcon": { + "title": "Show Menu Bar Icon", + "description": "Keep an Orca shortcut and activity indicator in the macOS menu bar." } } }, @@ -67,7 +71,8 @@ "quit": "Quit", "minimizeNotice": { "body": "Orca is still running in the system tray" - } + }, + "activityWaiting": "Orca - activity waiting" }, "worktreeJumpPalette": { "matchLabel": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 45e1afec7a48..d4dc50167517 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -31,6 +31,10 @@ "portsToggleDescription": "Muestra los puertos activos de los espacios de trabajo. Haz clic para ver los puertos de cada espacio de trabajo y los puertos externos.", "antigravityToggleDescription": "Muestra el uso de suscripción de Antigravity para el espacio de trabajo activo.", "grokToggleDescription": "Muestra el uso de créditos de suscripción de Grok cuando has iniciado sesión con Grok CLI." + }, + "menuBarIcon": { + "title": "Mostrar icono en la barra de menús", + "description": "Mantén un acceso directo de Orca e indicador de actividad en la barra de menús de macOS." } } }, @@ -67,7 +71,8 @@ "quit": "Salir", "minimizeNotice": { "body": "Orca sigue ejecutándose en la bandeja del sistema" - } + }, + "activityWaiting": "Orca: actividad en espera" }, "worktreeJumpPalette": { "matchLabel": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index c454ec9ce475..313c6bfb2381 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -31,6 +31,10 @@ "portsToggleDescription": "ライブワークスペースポートを表示します。ワークスペーススコープのポートと外部リスナーの場合はこれをクリックします。", "antigravityToggleDescription": "アクティブなワークスペースの Antigravity サブスクリプション使用量を表示します。", "grokToggleDescription": "Grok CLI でサインインしている場合に Grok サブスクリプションのクレジット使用量を表示します。" + }, + "menuBarIcon": { + "title": "メニューバーアイコンを表示", + "description": "macOS メニューバーに Orca のショートカットとアクティビティインジケーターを表示します。" } } }, @@ -67,7 +71,8 @@ "quit": "終了", "minimizeNotice": { "body": "Orcaはシステムトレイで実行中です" - } + }, + "activityWaiting": "Orca - アクティビティ待機中" }, "worktreeJumpPalette": { "matchLabel": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index b1f7cca34884..e7d7fc7bac1e 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -31,6 +31,10 @@ "portsToggleDescription": "라이브 워크스페이스 포트를 표시합니다. 워크스페이스 범위 포트 및 외부 수신기를 보려면 클릭하세요.", "antigravityToggleDescription": "활성 워크스페이스에 대한 Antigravity 구독 사용량을 표시합니다.", "grokToggleDescription": "Grok CLI로 로그인한 경우 Grok 구독 크레딧 사용량을 표시합니다." + }, + "menuBarIcon": { + "title": "메뉴 바 아이콘 표시", + "description": "macOS 메뉴 바에 Orca 바로가기 및 활동 표시기를 유지합니다." } } }, @@ -67,7 +71,8 @@ "quit": "종료", "minimizeNotice": { "body": "Orca가 시스템 트레이에서 계속 실행 중입니다" - } + }, + "activityWaiting": "Orca - 활동 대기 중" }, "worktreeJumpPalette": { "matchLabel": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 1e59503c12ce..2f0f5bdbf475 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -31,6 +31,10 @@ "portsToggleDescription": "显示实时工作区端口。单击可查看工作区范围的端口和外部监听器。", "antigravityToggleDescription": "显示当前工作区的 Antigravity 订阅使用量。", "grokToggleDescription": "通过 Grok CLI 登录后显示 Grok 订阅额度使用量。" + }, + "menuBarIcon": { + "title": "显示菜单栏图标", + "description": "在 macOS 菜单栏中保留 Orca 快捷方式和活动指示器。" } } }, @@ -67,7 +71,8 @@ "quit": "退出", "minimizeNotice": { "body": "Orca 仍在系统托盘中运行" - } + }, + "activityWaiting": "Orca - 活动等待中" }, "worktreeJumpPalette": { "matchLabel": { diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 61c0cb560666..925b91ab2846 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -2378,6 +2378,9 @@ function createWebUiApi(): NonNullable['ui']> { }, isMaximized: () => Promise.resolve(false), onOpenSettings: () => noopUnsubscribe, + // Why: the web client has no native tray/menu bar, so there is never a + // queued open-settings intent to consume. + consumePendingOpenSettings: () => Promise.resolve(false), onOpenSetupGuide: () => noopUnsubscribe, onOpenFeatureTour: () => noopUnsubscribe, onOpenCrashReport: () => noopUnsubscribe, diff --git a/src/shared/constants.test.ts b/src/shared/constants.test.ts index 343e0ffe1734..8a167b08d41d 100644 --- a/src/shared/constants.test.ts +++ b/src/shared/constants.test.ts @@ -48,6 +48,10 @@ describe('getDefaultSettings', () => { expect(getDefaultSettings('/tmp').uiLanguage).toBe('system') }) + it('defaults the menu bar icon on so the value round-trips across platforms', () => { + expect(getDefaultSettings('/tmp').showMenuBarIcon).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 22064cbe6ca7..0573c89ec932 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -266,6 +266,9 @@ export function getDefaultSettings(homedir: string): GlobalSettings { terminalFocusFollowsMouse: false, windowBackgroundBlur: false, minimizeToTrayOnClose: false, + // Why: default-on everywhere so the value round-trips across platforms; + // only the darwin consumers act on it. + showMenuBarIcon: true, terminalClipboardOnSelect: false, // Why: OSC 52 is a classic data-exfiltration vector (any process piping // untrusted output into the terminal can rewrite the clipboard). Keep the diff --git a/src/shared/types.ts b/src/shared/types.ts index ab975939e253..91b7785f0263 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2599,6 +2599,9 @@ export type GlobalSettings = { * system tray instead of quitting Orca; off keeps the default quit-on-close. * The tray icon itself is always present on Windows regardless of this flag. */ minimizeToTrayOnClose?: boolean + /** Why: macOS keeps Orca running after its last window closes, so this + * controls the additive menu-bar entry without changing Dock behavior. */ + showMenuBarIcon?: boolean /** Why: Windows terminals conventionally use right-click as a paste gesture, * while macOS/Linux default to their existing context menu behavior. */ terminalRightClickToPaste: boolean From e8a1674a75902680fe1f007615589d38bf0e19e9 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:07:44 -0700 Subject: [PATCH 081/351] fix(terminal): keep divider resize active after capture loss (#9063) --- .../pane-divider-capture-loss.test.ts | 154 ++++++++++++++ .../src/lib/pane-manager/pane-divider-drag.ts | 10 +- ...terminal-pane-divider-capture-loss.spec.ts | 190 ++++++++++++++++++ 3 files changed, 346 insertions(+), 8 deletions(-) create mode 100644 src/renderer/src/lib/pane-manager/pane-divider-capture-loss.test.ts create mode 100644 tests/e2e/terminal-pane-divider-capture-loss.spec.ts diff --git a/src/renderer/src/lib/pane-manager/pane-divider-capture-loss.test.ts b/src/renderer/src/lib/pane-manager/pane-divider-capture-loss.test.ts new file mode 100644 index 000000000000..a863b503fe6f --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-divider-capture-loss.test.ts @@ -0,0 +1,154 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createDivider } from './pane-divider' + +type PaneElement = HTMLElement & { style: Record } + +type DividerDragHarness = { + divider: HTMLElement + dividerListeners: Map + windowListeners: Map + capturedPointerIds: Set + previousPane: PaneElement + nextPane: PaneElement + onLayoutChanged: ReturnType + flushAnimationFrames: () => void +} + +function createPaneElement(width: number): PaneElement { + return { + style: {}, + classList: { contains: vi.fn(() => false) }, + dispatchEvent: vi.fn(() => true), + getBoundingClientRect: vi.fn(() => ({ + left: 0, + top: 0, + right: width, + bottom: 200, + width, + height: 200 + })), + querySelectorAll: vi.fn(() => []) + } as unknown as PaneElement +} + +function createPointerEvent(args: Partial): PointerEvent { + return { + preventDefault: vi.fn(), + pointerId: 1, + clientX: 0, + clientY: 0, + ...args + } as unknown as PointerEvent +} + +function createDividerDragHarness(): DividerDragHarness { + const dividerListeners = new Map() + const windowListeners = new Map() + const capturedPointerIds = new Set() + const animationFrames = new Map() + const previousPane = createPaneElement(100) + const nextPane = createPaneElement(300) + const divider = { + style: { setProperty: vi.fn() }, + classList: { add: vi.fn(), remove: vi.fn() }, + addEventListener: vi.fn((event: string, listener: EventListener) => { + dividerListeners.set(event, listener) + }), + removeEventListener: vi.fn((event: string, listener: EventListener) => { + if (dividerListeners.get(event) === listener) { + dividerListeners.delete(event) + } + }), + setPointerCapture: vi.fn((pointerId: number) => capturedPointerIds.add(pointerId)), + hasPointerCapture: vi.fn((pointerId: number) => capturedPointerIds.has(pointerId)), + releasePointerCapture: vi.fn((pointerId: number) => capturedPointerIds.delete(pointerId)), + previousElementSibling: previousPane, + nextElementSibling: nextPane + } as unknown as HTMLElement + vi.stubGlobal('document', { createElement: vi.fn(() => divider) }) + vi.stubGlobal('window', { + addEventListener: vi.fn((event: string, listener: EventListener) => { + windowListeners.set(event, listener) + }), + removeEventListener: vi.fn((event: string, listener: EventListener) => { + if (windowListeners.get(event) === listener) { + windowListeners.delete(event) + } + }) + }) + let nextFrameId = 0 + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn((callback: FrameRequestCallback) => { + nextFrameId += 1 + animationFrames.set(nextFrameId, callback) + return nextFrameId + }) + ) + vi.stubGlobal( + 'cancelAnimationFrame', + vi.fn((frameId: number) => animationFrames.delete(frameId)) + ) + const onLayoutChanged = vi.fn() + createDivider(true, {}, { refitPanesUnder: vi.fn(), onLayoutChanged }) + + return { + divider, + dividerListeners, + windowListeners, + capturedPointerIds, + previousPane, + nextPane, + onLayoutChanged, + flushAnimationFrames: () => { + for (const [frameId, callback] of animationFrames) { + animationFrames.delete(frameId) + callback(16) + } + } + } +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('divider pointer capture loss', () => { + it('continues through window events and commits the final drag position', () => { + const harness = createDividerDragHarness() + harness.dividerListeners.get('pointerdown')?.( + createPointerEvent({ pointerId: 9, clientX: 100 }) + ) + harness.windowListeners.get('pointermove')?.(createPointerEvent({ pointerId: 9, clientX: 180 })) + harness.flushAnimationFrames() + + expect(harness.previousPane.style.flex).toBe('180 1 0%') + expect(harness.dividerListeners.has('lostpointercapture')).toBe(false) + + harness.capturedPointerIds.delete(9) + harness.windowListeners.get('pointermove')?.(createPointerEvent({ pointerId: 9, clientX: 220 })) + harness.flushAnimationFrames() + harness.windowListeners.get('pointerup')?.(createPointerEvent({ pointerId: 9, clientX: 220 })) + + expect(harness.previousPane.style.flex).toBe('220 1 0%') + expect(harness.nextPane.style.flex).toBe('180 1 0%') + expect(harness.onLayoutChanged).toHaveBeenCalledTimes(1) + }) + + it('still restores the original layout when the window loses focus', () => { + const harness = createDividerDragHarness() + harness.previousPane.style.flex = '2 1 0%' + harness.nextPane.style.flex = '3 1 0%' + harness.dividerListeners.get('pointerdown')?.( + createPointerEvent({ pointerId: 9, clientX: 100 }) + ) + harness.windowListeners.get('pointermove')?.(createPointerEvent({ pointerId: 9, clientX: 180 })) + harness.flushAnimationFrames() + + harness.windowListeners.get('blur')?.({} as Event) + + expect(harness.previousPane.style.flex).toBe('2 1 0%') + expect(harness.nextPane.style.flex).toBe('3 1 0%') + expect(harness.onLayoutChanged).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/pane-manager/pane-divider-drag.ts b/src/renderer/src/lib/pane-manager/pane-divider-drag.ts index 0b552ccc4ca9..8fd5d3443cae 100644 --- a/src/renderer/src/lib/pane-manager/pane-divider-drag.ts +++ b/src/renderer/src/lib/pane-manager/pane-divider-drag.ts @@ -93,6 +93,8 @@ export function attachDividerDrag( if (windowListenersAttached || typeof window === 'undefined') { return } + // Why: Chromium can transiently lose capture while the button remains held, + // so window events keep ownership until pointerup, pointercancel, or blur. windowListenersAttached = true window.addEventListener('pointermove', onPointerMove, true) window.addEventListener('pointerup', onPointerUp, true) @@ -264,12 +266,6 @@ export function attachDividerDrag( } } - const onLostPointerCapture = (): void => { - if (dragging) { - finishActiveDrag(false) - } - } - const onWindowBlur = (): void => { finishActiveDrag(false) } @@ -278,7 +274,6 @@ export function attachDividerDrag( divider.addEventListener('pointermove', onPointerMove) divider.addEventListener('pointerup', onPointerUp) divider.addEventListener('pointercancel', onPointerCancel) - divider.addEventListener('lostpointercapture', onLostPointerCapture) divider.addEventListener('dblclick', onDoubleClick) dividerDragCleanups.set(divider, () => { finishActiveDrag(false) @@ -286,7 +281,6 @@ export function attachDividerDrag( divider.removeEventListener('pointermove', onPointerMove) divider.removeEventListener('pointerup', onPointerUp) divider.removeEventListener('pointercancel', onPointerCancel) - divider.removeEventListener('lostpointercapture', onLostPointerCapture) divider.removeEventListener('dblclick', onDoubleClick) }) } diff --git a/tests/e2e/terminal-pane-divider-capture-loss.spec.ts b/tests/e2e/terminal-pane-divider-capture-loss.spec.ts new file mode 100644 index 000000000000..5f3bfca177bd --- /dev/null +++ b/tests/e2e/terminal-pane-divider-capture-loss.spec.ts @@ -0,0 +1,190 @@ +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { + splitActiveTerminalPane, + waitForActiveTerminalManager, + waitForPaneCount +} from './helpers/terminal' +import { ensureTerminalVisible } from './helpers/store' + +type PaneGeometry = { + width: number + flex: string + cols: number | null + rows: number | null + proposed: { cols: number; rows: number } | null +} + +type DividerGeometry = { + first: PaneGeometry + second: PaneGeometry +} + +test.use({ seedTestRepo: false }) + +async function setFullscreen(electronApp: ElectronApplication, page: Page): Promise { + await expect + .poll(async () => { + try { + return await electronApp.evaluate(({ BrowserWindow }) => { + const window = BrowserWindow.getAllWindows()[0] + if (!window) { + return false + } + if (window.isMinimized()) { + window.restore() + } + window.show() + window.focus() + window.setFullScreen(true) + return window.isFullScreen() + }) + } catch { + return false + } + }) + .toBe(true) + await expect.poll(() => page.evaluate(() => innerWidth >= 1000 && innerHeight >= 700)).toBe(true) + await page.waitForTimeout(1200) +} + +async function addTestRepo(page: Page, repoPath: string): Promise { + const repoId = await page.evaluate(async (path) => { + const result = await window.api.repos.add({ path }) + if ('error' in result) { + throw new Error(result.error) + } + return result.repo.id + }, repoPath) + await expect + .poll(() => + page.evaluate(async (id) => { + const store = window.__store + if (!store) { + return false + } + await store.getState().fetchRepos() + await store.getState().fetchWorktrees(id) + const worktree = store + .getState() + .worktreesByRepo[id]?.find((candidate) => candidate.isMainWorktree) + if (!worktree) { + return false + } + store.getState().setActiveWorktree(worktree.id) + return true + }, repoId) + ) + .toBe(true) +} + +async function readDividerGeometry(page: Page): Promise { + return page.evaluate(() => { + const divider = document.querySelector('.pane-divider.is-vertical') + const firstElement = divider?.previousElementSibling as HTMLElement | null + const secondElement = divider?.nextElementSibling as HTMLElement | null + if (!divider || !firstElement || !secondElement) { + throw new Error('Divider unavailable') + } + const readPane = (element: HTMLElement): PaneGeometry => { + const paneElement = element.matches('.pane[data-pty-id]') + ? element + : element.querySelector('.pane[data-pty-id]') + const ptyId = paneElement?.dataset.ptyId + const pane = ptyId + ? Array.from(window.__paneManagers?.values() ?? []) + .flatMap((manager) => manager.getPanes()) + .find((candidate) => candidate.container.dataset.ptyId === ptyId) + : null + let proposed = null + try { + proposed = pane?.fitAddon.proposeDimensions() ?? null + } catch { + proposed = null + } + return { + width: element.getBoundingClientRect().width, + flex: element.style.flex, + cols: pane?.terminal.cols ?? null, + rows: pane?.terminal.rows ?? null, + proposed + } + } + return { first: readPane(firstElement), second: readPane(secondElement) } + }) +} + +function gridsMatch(geometry: DividerGeometry): boolean { + return [geometry.first, geometry.second].every( + (pane) => + pane.proposed !== null && pane.cols === pane.proposed.cols && pane.rows === pane.proposed.rows + ) +} + +test('@headful keeps resizing after the divider loses pointer capture', async ({ + electronApp, + orcaPage, + testRepoPath +}, testInfo) => { + await setFullscreen(electronApp, orcaPage) + await addTestRepo(orcaPage, testRepoPath) + await ensureTerminalVisible(orcaPage, 30_000) + await waitForActiveTerminalManager(orcaPage, 30_000) + await splitActiveTerminalPane(orcaPage, 'vertical') + await waitForPaneCount(orcaPage, 2, 30_000) + + const divider = orcaPage.locator('.pane-divider.is-vertical').first() + await expect(divider).toBeVisible() + const box = await divider.boundingBox() + if (!box) { + throw new Error('Divider has no bounding box') + } + await divider.evaluate((element) => { + element.dataset.captureLossCount = '0' + element.addEventListener('pointerdown', (event) => { + element.dataset.captureLossPointerId = String(event.pointerId) + }) + element.addEventListener('lostpointercapture', () => { + element.dataset.captureLossCount = String(Number(element.dataset.captureLossCount ?? '0') + 1) + }) + }) + + const before = await readDividerGeometry(orcaPage) + const startX = box.x + box.width / 2 + const startY = box.y + box.height / 2 + await orcaPage.mouse.move(startX, startY) + await orcaPage.mouse.down() + await orcaPage.mouse.move(startX + 140, startY, { steps: 10 }) + await expect + .poll(async () => + Math.abs((await readDividerGeometry(orcaPage)).first.width - before.first.width) + ) + .toBeGreaterThan(80) + + // Why: this is the real browser event observed during field failures; later + // window-level pointer events must remain authoritative after capture drops. + await divider.evaluate((element) => { + const pointerId = Number(element.dataset.captureLossPointerId) + if (!Number.isInteger(pointerId) || !element.hasPointerCapture(pointerId)) { + throw new Error('Divider did not acquire pointer capture') + } + element.releasePointerCapture(pointerId) + }) + await expect + .poll(() => divider.evaluate((element) => Number(element.dataset.captureLossCount ?? '0'))) + .toBe(1) + + await orcaPage.mouse.move(startX + 260, startY, { steps: 10 }) + await orcaPage.mouse.up() + await expect.poll(async () => gridsMatch(await readDividerGeometry(orcaPage))).toBe(true) + const after = await readDividerGeometry(orcaPage) + await testInfo.attach('divider-capture-loss-geometry', { + body: Buffer.from(JSON.stringify({ before, after }, null, 2)), + contentType: 'application/json' + }) + + expect(Math.abs(after.first.width - before.first.width)).toBeGreaterThan(180) + expect(Math.abs(after.second.width - before.second.width)).toBeGreaterThan(180) + expect(after.first.flex).not.toBe(before.first.flex) + expect(after.second.flex).not.toBe(before.second.flex) +}) From 9716dbd45fc8bde98c5e33865186af9d14d3479b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 20:08:45 +0000 Subject: [PATCH 082/351] release: v1.4.144-rc.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1797c755bb61..6510dbf0de69 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.144-rc.0", + "version": "1.4.144-rc.1", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 64181fdd424d6b6a796e99b8bf023595629b23c0 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 16 Jul 2026 13:26:15 -0700 Subject: [PATCH 083/351] feat(native-chat): native chat view across mobile, desktop, and web (#5824) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(native-chat): add native chat view across mobile * fix(native-chat): address review findings and CodeRabbit threads Correctness: - Restore an independent initial readSession seed and surface initial-drain errors as snapshot frames so the chat view can never strand on 'loading' - Pair mobile tool results to calls by ordinal FIFO (parallel calls no longer misgraft results); clear a pending ask only when its own call resolves - Show a new streaming reply immediately (same-turn suppression, not length) - Delegate mobile noise filtering to the shared harness-injected classifier - Admit soft-leaving mobile clients in beginMobileInputFloor (parity with mobileTookFloor) so grace-window writes aren't dropped - Self-heal a stale 'working' status once this turn's reply lands - Catch RPC rejections in mobile file-open helpers; guard sanitizeToolInput key collisions; settle web/runtime transports on unrecognized first frames and forward snapshot errors Perf: - Throttle the mobile streaming bubble (50ms) so per-part status frames stop re-parsing the whole accumulated markdown - Short-circuit markdown path detection on dot-less or oversized runs (quadratic backtracking guard) UX/minor: - Wire hold-mode dictation through the native chat composer - Allow scoped-package (@) paths in file-path detection - Move caret after mid-text autocomplete insertion; index-prefixed ask option keys; single scroll-to-end effect; bounded wait + toast when image attach races a resubscribe; count-based pending reconciliation; cache-hit search cancels stale debounce; chat-tab toggle wins over in-flight preference load - Share shouldStepNativeChatAskAnswer between desktop and mobile; import block guards/source priority from shared instead of local copies - Defensive non-positive transcript limits; test strengthening (TTL expiry, post-unsubscribe stale frame, lease readiness, filtered console.error) * refactor(native-chat): share desktop/mobile chat logic in src/shared Extract the parity-mirrored native-chat modules into shared implementations both surfaces re-export: ask parsing (registry, parseAskFromStatus, extractPendingAsk, formatAskAnswer), answer stepping offsets/scheduler, diff detection/parsing, harness-noise filtering, tool fold/pair/split, and tool summaries. Removes the hand-synced copies and their stale Metro comments. Divergence reconciliations take the safer side of each: diffs truncate at 120 lines/32KB everywhere (desktop previously unbounded), tool-run summaries cap at 3 parts with bounded-depth previews, nameless tool calls are skipped, and basenames split on both separators. Also: settle and kill every sibling quick-open pass when one reaches maxResults (main rg/git and relay git; relay rg already did) so a capped search cannot leave a scan walking a huge tree; fold window-bounding into the shared merger's applyAppend; localize the web 'Pair a host' snapshot error. * fix(native-chat): address CodeRabbit follow-ups on shared modules - Attachment lease gate re-checks connection/target/tab after the bounded wait, so a tab/host switch or disconnect mid-wait can't send into a stale terminal; a moved-away target drops silently like the pre-wait guard and only an unrecovered lease surfaces the toast. Adds hook tests. - extractPendingAsk parses transcript tool-calls through the same registered-parser + canonical-shape fallback as live status, so a custom question tool that rendered live survives reconnect/replay. - Direct unit tests for the shared ask parser (FIFO ordering, fallback, malformed payloads) and tool-summary bounded preview (depth/collection caps, circular refs, basename/command branches). * fix(native-chat): treat initialLimit 0 as a valid empty window Both engine guards used truthiness, so an explicit zero limit skipped the bounded tail reader and fell back to an unbounded incremental read. Latent only (every caller clamps positive), hardened for consistency with the tail reader's non-positive-limit handling. * fix(mobile): native-chat composer lock UX + send-failure feedback - Distinguish input-lock reasons: transport 'disconnected' shows Reconnecting… instead of mislabeling a reconnect as locked-by-another-client - Guard the composer lock behind a 600ms hold so connState blips / lease hand-offs don't flicker the placeholder; unlock stays instant - Surface a rejected send inline above the composer (a bottom toast hides behind the keyboard); auto-dismisses after 4s - waiting-session hint invites the first message instead of implying the agent is still starting * test(mobile): sync answer-send pacing test to the 500ms advance buffer Missed in merge 8fe3c391c, which carried main's NATIVE_CHAT_ADVANCE_BUFFER_MS 300->500 (#8568) into the shared stepping module that mobile derives from. * fix(mobile): restore terminal stream after chat cold start * fix(native-chat): harden retries, optimistic sends, and file scans * fix(mobile): deliver AskUserQuestion answers by option number (STA-1860) Port #8840's fix to the mobile native chat: the Ask card now tracks per-question option INDICES (+ free text) and the answer-send hook drives Claude's arrow-navigate selector with buildAskAnswerKeys keystroke groups — option numbers, next-tab arrows, Enter — paced one selector step apart, instead of pasting label text that the selector ignores (which silently committed the default option). Non-Claude agents keep the pasted-label path via the selection-based formatAskAnswer. Backcompat: keystrokes are built client-side and written through the EXISTING terminal.send passthrough with enter:false — the same contract the permission card already uses — so an older desktop runtime (SSH/relay included) replays them verbatim; no RPC/contract change in either update order. Free text is newline-sanitized because terminal.send has no paste framing. Drops the now-unused formatCompleteAskAnswer from the shared module. * fix native chat send and runtime races * fix mobile native chat formatting * fix(native-chat): mobile empty state matches desktop copy Mobile showed a single generic line ('Send a message to get started') where desktop shows a titled two-line empty state naming the agent ('Start a chat with Claude' + 'Ask Claude to inspect code, explain output, or make a change.'). Align them from one source of truth so they can't drift again: - Extract the agent-type label map + formatAgentTypeLabel to src/shared/agent-type-label.ts (desktop re-exports; mobile imports). - Add src/shared/native-chat-empty-state.ts with the canonical English copy; desktop uses it as its i18n fallbacks (localization unchanged — en/es/ja/ko/zh keys still win), mobile substitutes the agent label and renders it directly (mobile ships English only). - Mobile: render title + subtitle for waiting-session AND ready-but-empty (both are 'start a chat'), error copy for errors; keep the loading spinner. Live-verified on the iOS sim against a pn-dev of this branch. typecheck node/web + mobile tsc clean; 30 mobile + 428 desktop/shared native-chat tests green. * style: oxfmt the empty-state parity test (line wrap) --------- Co-authored-by: Brennan Benson Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> --- config/reliability-gates.jsonc | 41 +- .../app/h/[hostId]/session/[worktreeId].tsx | 322 ++++++------- .../session/mobile-session-route-types.ts | 2 + mobile/src/components/MobileMarkdown.tsx | 242 ++++------ .../markdown-file-path-detection.test.ts | 190 ++++++++ .../markdown-file-path-detection.ts | 212 +++++++++ .../src/components/mobile-markdown-styles.ts | 150 +++++++ .../session/MobileAgentWorkingIndicator.tsx | 64 +++ .../session/MobileBrowserTabActionSheet.tsx | 79 ++++ mobile/src/session/MobileNativeChatAsk.tsx | 378 ++++++++++++++++ .../session/MobileNativeChatComposer.test.ts | 206 +++++++++ .../src/session/MobileNativeChatComposer.tsx | 285 ++++++++++++ .../src/session/MobileNativeChatMessage.tsx | 371 +++++++++++++++ .../src/session/MobileNativeChatOverlay.tsx | 79 ++++ .../MobileNativeChatPermission.test.ts | 60 +++ .../session/MobileNativeChatPermission.tsx | 123 +++++ .../src/session/MobileNativeChatQuestion.tsx | 268 +++++++++++ mobile/src/session/MobileNativeChatView.tsx | 425 ++++++++++++++++++ .../session/mobile-image-attachment.test.ts | 37 +- mobile/src/session/mobile-image-attachment.ts | 5 +- ...mobile-native-chat-answer-stepping.test.ts | 61 +++ .../mobile-native-chat-answer-stepping.ts | 13 + .../session/mobile-native-chat-ask.test.ts | 160 +++++++ mobile/src/session/mobile-native-chat-ask.ts | 2 + .../mobile-native-chat-autocomplete.test.ts | 59 +++ .../mobile-native-chat-autocomplete.ts | 93 ++++ .../session/mobile-native-chat-blocks.test.ts | 153 +++++++ .../src/session/mobile-native-chat-blocks.ts | 11 + .../session/mobile-native-chat-diff.test.ts | 58 +++ mobile/src/session/mobile-native-chat-diff.ts | 6 + .../mobile-native-chat-eligibility.test.ts | 112 +++++ .../session/mobile-native-chat-eligibility.ts | 66 +++ .../session/mobile-native-chat-merge.test.ts | 49 ++ .../src/session/mobile-native-chat-merge.ts | 9 + .../mobile-native-chat-merger-oracle.test.ts | 77 ++++ .../mobile-native-chat-message-styles.ts | 168 +++++++ .../mobile-native-chat-message-text.test.ts | 46 ++ .../mobile-native-chat-message-text.ts | 24 + .../session/mobile-native-chat-noise.test.ts | 61 +++ .../src/session/mobile-native-chat-noise.ts | 1 + .../mobile-native-chat-open-file.test.ts | 93 ++++ .../session/mobile-native-chat-open-file.ts | 51 +++ ...mobile-native-chat-permission-send.test.ts | 27 ++ .../mobile-native-chat-permission-send.ts | 49 ++ .../mobile-native-chat-permission.test.ts | 144 ++++++ .../session/mobile-native-chat-permission.ts | 179 ++++++++ .../mobile-native-chat-question.test.ts | 158 +++++++ .../session/mobile-native-chat-question.ts | 154 +++++++ .../mobile-native-chat-render-data.test.ts | 104 +++++ .../session/mobile-native-chat-render-data.ts | 115 +++++ .../session/mobile-native-chat-send.test.ts | 79 ++++ mobile/src/session/mobile-native-chat-send.ts | 27 ++ .../mobile-native-chat-stream-frame.test.ts | 112 +++++ .../mobile-native-chat-stream-frame.ts | 63 +++ ...mobile-native-chat-terminal-stream.test.ts | 88 ++++ .../mobile-native-chat-terminal-stream.ts | 37 ++ .../mobile-native-chat-toggle-action.ts | 37 ++ .../mobile-native-chat-tool-summary.test.ts | 64 +++ .../mobile-native-chat-tool-summary.ts | 7 + .../session/mobile-native-chat-view-styles.ts | 109 +++++ .../mobile-terminal-action-sheet-actions.ts | 69 +++ .../mobile-terminal-stream-subscribe.test.ts | 24 + .../mobile-terminal-stream-subscribe.ts | 17 + ...mobile-attachment-input-lease-gate.test.ts | 134 ++++++ .../use-mobile-attachment-input-lease-gate.ts | 75 ++++ ...use-mobile-native-chat-answer-send.test.ts | 243 ++++++++++ .../use-mobile-native-chat-answer-send.ts | 176 ++++++++ ...use-mobile-native-chat-ask-dismiss.test.ts | 60 +++ .../use-mobile-native-chat-ask-dismiss.ts | 26 ++ .../use-mobile-native-chat-controller.ts | 314 +++++++++++++ .../use-mobile-native-chat-drafts.test.ts | 192 ++++++++ .../session/use-mobile-native-chat-drafts.ts | 165 +++++++ ...use-mobile-native-chat-file-search.test.ts | 165 +++++++ .../use-mobile-native-chat-file-search.ts | 146 ++++++ ...use-mobile-native-chat-input-lease.test.ts | 57 +++ .../use-mobile-native-chat-input-lease.ts | 53 +++ .../use-mobile-native-chat-pinch-gesture.ts | 39 ++ .../session/use-mobile-native-chat-prompts.ts | 47 ++ ...use-mobile-native-chat-readability.test.ts | 107 +++++ .../use-mobile-native-chat-readability.ts | 53 +++ .../use-mobile-native-chat-session.test.ts | 220 +++++++++ .../session/use-mobile-native-chat-session.ts | 219 +++++++++ .../use-mobile-native-chat-stop.test.ts | 85 ++++ .../session/use-mobile-native-chat-stop.ts | 76 ++++ ...mobile-native-chat-terminal-stream.test.ts | 115 +++++ .../use-mobile-native-chat-terminal-stream.ts | 76 ++++ .../use-throttled-latest-value.test.ts | 69 +++ .../src/session/use-throttled-latest-value.ts | 52 +++ mobile/src/storage/preferences.test.ts | 27 ++ mobile/src/storage/preferences.ts | 29 ++ mobile/src/theme/mobile-theme.ts | 3 + .../rpc-client-terminal-subscription.ts | 33 ++ mobile/src/transport/rpc-client.ts | 18 +- ...ystem-list-files-git-fallback-real.test.ts | 12 + .../ipc/filesystem-list-files-git-fallback.ts | 87 +++- src/main/ipc/filesystem-list-files.test.ts | 91 ++++ src/main/ipc/filesystem-list-files.ts | 79 +++- .../native-chat-subscribe-lifecycle.test.ts | 55 ++- src/main/ipc/native-chat.test.ts | 23 +- src/main/ipc/native-chat.ts | 75 +++- .../native-chat/session-file-resolver.test.ts | 13 + src/main/native-chat/session-file-resolver.ts | 11 +- .../transcript-incremental-reader.ts | 103 +++++ .../native-chat/transcript-reader.test.ts | 53 +++ src/main/native-chat/transcript-reader.ts | 8 +- .../native-chat/transcript-tail-reader.ts | 200 +++++++++ .../native-chat/transcript-watch-engine.ts | 310 +++++++++++++ .../transcript-watch-error.test.ts | 172 +++++++ .../transcript-watch-resolve-poll.test.ts | 62 +++ src/main/native-chat/transcript-watch.test.ts | 297 +++++++++++- src/main/native-chat/transcript-watch.ts | 286 +++--------- .../providers/ssh-filesystem-provider.test.ts | 8 +- src/main/providers/ssh-filesystem-provider.ts | 5 +- src/main/providers/types.ts | 2 +- src/main/runtime/mobile-presence-lock.test.ts | 81 ++++ src/main/runtime/orca-runtime-files.ts | 65 ++- src/main/runtime/orca-runtime.test.ts | 10 +- src/main/runtime/orca-runtime.ts | 122 ++++- .../rpc/methods/files-path-search.test.ts | 35 ++ src/main/runtime/rpc/methods/files.ts | 11 + .../runtime/rpc/methods/native-chat.test.ts | 286 +++++++++++- src/main/runtime/rpc/methods/native-chat.ts | 133 +++++- src/main/runtime/rpc/methods/terminal.ts | 181 ++++++-- .../runtime/rpc/terminal-multiplex.test.ts | 25 +- .../rpc/terminal-output-batching.test.ts | 74 ++- src/main/runtime/rpc/terminal-send.test.ts | 158 ++++++- .../rpc/terminal-subscribe-lease-only.test.ts | 72 +++ .../runtime-mobile-file-path-search.test.ts | 98 ++++ .../runtime-mobile-file-path-search.ts | 93 ++++ src/main/runtime/runtime-rpc.ts | 1 + src/preload/api-types.ts | 15 +- src/preload/index.ts | 9 +- src/relay/fs-handler-git-fallback.ts | 87 +++- .../fs-handler-list-files-ignored.test.ts | 47 ++ src/relay/fs-handler-list-files.ts | 31 +- src/relay/fs-handler-readdir-fallback.test.ts | 26 ++ src/relay/fs-handler-readdir-fallback.ts | 3 +- src/relay/fs-handler.ts | 19 +- .../native-chat/NativeChatEmptyState.tsx | 26 +- .../native-chat/native-chat-diff.ts | 76 +--- .../native-chat-interactive-prompt.ts | 269 ++--------- .../native-chat/native-chat-live-status.ts | 48 +- .../native-chat/native-chat-noise.ts | 37 +- .../native-chat/native-chat-runtime-send.ts | 30 +- .../native-chat-session-transport.test.ts | 178 +++++++- .../native-chat-session-transport.ts | 118 ++++- .../native-chat/native-chat-tool-fold.ts | 64 +-- .../native-chat/native-chat-tool-summary.ts | 109 +---- .../use-native-chat-interactive-send.ts | 10 +- .../use-native-chat-live-session.test.ts | 266 +++++++++-- .../use-native-chat-live-session.ts | 76 +++- src/renderer/src/i18n/locales/en.json | 1 + src/renderer/src/i18n/locales/es.json | 1 + src/renderer/src/i18n/locales/ja.json | 1 + src/renderer/src/i18n/locales/ko.json | 1 + src/renderer/src/i18n/locales/zh.json | 1 + .../src/lib/activity-thread-display.test.ts | 6 +- src/renderer/src/lib/agent-status.ts | 33 +- .../src/lib/native-chat-supported-agent.ts | 23 +- src/renderer/src/web/web-preload-api.ts | 101 ++++- src/shared/agent-type-label.ts | 34 ++ src/shared/native-chat-agent-support.test.ts | 46 ++ src/shared/native-chat-agent-support.ts | 34 ++ src/shared/native-chat-answer-stepping.ts | 26 ++ src/shared/native-chat-ask-types.ts | 7 +- src/shared/native-chat-ask.test.ts | 82 ++++ src/shared/native-chat-ask.ts | 227 ++++++++++ src/shared/native-chat-diff.ts | 85 ++++ src/shared/native-chat-empty-state.ts | 39 ++ src/shared/native-chat-merge.ts | 35 +- src/shared/native-chat-noise.ts | 25 ++ .../native-chat-stream-unsubscribe.test.ts | 7 + src/shared/native-chat-stream-unsubscribe.ts | 5 +- src/shared/native-chat-tool-fold.ts | 93 ++++ src/shared/native-chat-tool-summary.test.ts | 50 +++ src/shared/native-chat-tool-summary.ts | 140 ++++++ src/shared/quick-open-readdir-budget.ts | 38 ++ src/shared/quick-open-readdir-walk.test.ts | 11 + src/shared/quick-open-readdir-walk.ts | 89 ++-- ...rminal-push-delivery-loss-recovery.spec.ts | 4 +- 180 files changed, 14292 insertions(+), 1543 deletions(-) create mode 100644 mobile/src/components/markdown-file-path-detection.test.ts create mode 100644 mobile/src/components/markdown-file-path-detection.ts create mode 100644 mobile/src/components/mobile-markdown-styles.ts create mode 100644 mobile/src/session/MobileAgentWorkingIndicator.tsx create mode 100644 mobile/src/session/MobileBrowserTabActionSheet.tsx create mode 100644 mobile/src/session/MobileNativeChatAsk.tsx create mode 100644 mobile/src/session/MobileNativeChatComposer.test.ts create mode 100644 mobile/src/session/MobileNativeChatComposer.tsx create mode 100644 mobile/src/session/MobileNativeChatMessage.tsx create mode 100644 mobile/src/session/MobileNativeChatOverlay.tsx create mode 100644 mobile/src/session/MobileNativeChatPermission.test.ts create mode 100644 mobile/src/session/MobileNativeChatPermission.tsx create mode 100644 mobile/src/session/MobileNativeChatQuestion.tsx create mode 100644 mobile/src/session/MobileNativeChatView.tsx create mode 100644 mobile/src/session/mobile-native-chat-answer-stepping.test.ts create mode 100644 mobile/src/session/mobile-native-chat-answer-stepping.ts create mode 100644 mobile/src/session/mobile-native-chat-ask.test.ts create mode 100644 mobile/src/session/mobile-native-chat-ask.ts create mode 100644 mobile/src/session/mobile-native-chat-autocomplete.test.ts create mode 100644 mobile/src/session/mobile-native-chat-autocomplete.ts create mode 100644 mobile/src/session/mobile-native-chat-blocks.test.ts create mode 100644 mobile/src/session/mobile-native-chat-blocks.ts create mode 100644 mobile/src/session/mobile-native-chat-diff.test.ts create mode 100644 mobile/src/session/mobile-native-chat-diff.ts create mode 100644 mobile/src/session/mobile-native-chat-eligibility.test.ts create mode 100644 mobile/src/session/mobile-native-chat-eligibility.ts create mode 100644 mobile/src/session/mobile-native-chat-merge.test.ts create mode 100644 mobile/src/session/mobile-native-chat-merge.ts create mode 100644 mobile/src/session/mobile-native-chat-merger-oracle.test.ts create mode 100644 mobile/src/session/mobile-native-chat-message-styles.ts create mode 100644 mobile/src/session/mobile-native-chat-message-text.test.ts create mode 100644 mobile/src/session/mobile-native-chat-message-text.ts create mode 100644 mobile/src/session/mobile-native-chat-noise.test.ts create mode 100644 mobile/src/session/mobile-native-chat-noise.ts create mode 100644 mobile/src/session/mobile-native-chat-open-file.test.ts create mode 100644 mobile/src/session/mobile-native-chat-open-file.ts create mode 100644 mobile/src/session/mobile-native-chat-permission-send.test.ts create mode 100644 mobile/src/session/mobile-native-chat-permission-send.ts create mode 100644 mobile/src/session/mobile-native-chat-permission.test.ts create mode 100644 mobile/src/session/mobile-native-chat-permission.ts create mode 100644 mobile/src/session/mobile-native-chat-question.test.ts create mode 100644 mobile/src/session/mobile-native-chat-question.ts create mode 100644 mobile/src/session/mobile-native-chat-render-data.test.ts create mode 100644 mobile/src/session/mobile-native-chat-render-data.ts create mode 100644 mobile/src/session/mobile-native-chat-send.test.ts create mode 100644 mobile/src/session/mobile-native-chat-send.ts create mode 100644 mobile/src/session/mobile-native-chat-stream-frame.test.ts create mode 100644 mobile/src/session/mobile-native-chat-stream-frame.ts create mode 100644 mobile/src/session/mobile-native-chat-terminal-stream.test.ts create mode 100644 mobile/src/session/mobile-native-chat-terminal-stream.ts create mode 100644 mobile/src/session/mobile-native-chat-toggle-action.ts create mode 100644 mobile/src/session/mobile-native-chat-tool-summary.test.ts create mode 100644 mobile/src/session/mobile-native-chat-tool-summary.ts create mode 100644 mobile/src/session/mobile-native-chat-view-styles.ts create mode 100644 mobile/src/session/mobile-terminal-action-sheet-actions.ts create mode 100644 mobile/src/session/mobile-terminal-stream-subscribe.test.ts create mode 100644 mobile/src/session/mobile-terminal-stream-subscribe.ts create mode 100644 mobile/src/session/use-mobile-attachment-input-lease-gate.test.ts create mode 100644 mobile/src/session/use-mobile-attachment-input-lease-gate.ts create mode 100644 mobile/src/session/use-mobile-native-chat-answer-send.test.ts create mode 100644 mobile/src/session/use-mobile-native-chat-answer-send.ts create mode 100644 mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts create mode 100644 mobile/src/session/use-mobile-native-chat-ask-dismiss.ts create mode 100644 mobile/src/session/use-mobile-native-chat-controller.ts create mode 100644 mobile/src/session/use-mobile-native-chat-drafts.test.ts create mode 100644 mobile/src/session/use-mobile-native-chat-drafts.ts create mode 100644 mobile/src/session/use-mobile-native-chat-file-search.test.ts create mode 100644 mobile/src/session/use-mobile-native-chat-file-search.ts create mode 100644 mobile/src/session/use-mobile-native-chat-input-lease.test.ts create mode 100644 mobile/src/session/use-mobile-native-chat-input-lease.ts create mode 100644 mobile/src/session/use-mobile-native-chat-pinch-gesture.ts create mode 100644 mobile/src/session/use-mobile-native-chat-prompts.ts create mode 100644 mobile/src/session/use-mobile-native-chat-readability.test.ts create mode 100644 mobile/src/session/use-mobile-native-chat-readability.ts create mode 100644 mobile/src/session/use-mobile-native-chat-session.test.ts create mode 100644 mobile/src/session/use-mobile-native-chat-session.ts create mode 100644 mobile/src/session/use-mobile-native-chat-stop.test.ts create mode 100644 mobile/src/session/use-mobile-native-chat-stop.ts create mode 100644 mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts create mode 100644 mobile/src/session/use-mobile-native-chat-terminal-stream.ts create mode 100644 mobile/src/session/use-throttled-latest-value.test.ts create mode 100644 mobile/src/session/use-throttled-latest-value.ts create mode 100644 src/main/native-chat/transcript-incremental-reader.ts create mode 100644 src/main/native-chat/transcript-tail-reader.ts create mode 100644 src/main/native-chat/transcript-watch-engine.ts create mode 100644 src/main/native-chat/transcript-watch-error.test.ts create mode 100644 src/main/native-chat/transcript-watch-resolve-poll.test.ts create mode 100644 src/main/runtime/rpc/methods/files-path-search.test.ts create mode 100644 src/main/runtime/rpc/terminal-subscribe-lease-only.test.ts create mode 100644 src/main/runtime/runtime-mobile-file-path-search.test.ts create mode 100644 src/main/runtime/runtime-mobile-file-path-search.ts create mode 100644 src/relay/fs-handler-readdir-fallback.test.ts create mode 100644 src/shared/agent-type-label.ts create mode 100644 src/shared/native-chat-agent-support.test.ts create mode 100644 src/shared/native-chat-agent-support.ts create mode 100644 src/shared/native-chat-answer-stepping.ts create mode 100644 src/shared/native-chat-ask.test.ts create mode 100644 src/shared/native-chat-ask.ts create mode 100644 src/shared/native-chat-diff.ts create mode 100644 src/shared/native-chat-empty-state.ts create mode 100644 src/shared/native-chat-noise.ts create mode 100644 src/shared/native-chat-tool-fold.ts create mode 100644 src/shared/native-chat-tool-summary.test.ts create mode 100644 src/shared/native-chat-tool-summary.ts create mode 100644 src/shared/quick-open-readdir-budget.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index ec1cd450561d..054f1748ea65 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -3896,6 +3896,7 @@ "layer": "runtime-rpc-contract", "surfaces": [ "mobile terminal", + "native-chat terminal overlay", "runtime RPC", "binary multiplex", "JSON subscribe fallback", @@ -3917,21 +3918,25 @@ "macos" ], "coveredProviders": [], - "coverageNotes": "Local macOS evidence over the runtime-RPC stream budgets on main@1282f5c2d. The pending stack adds byte-exact 512KB/2MB/256KB/48KB budget assertions; legacy JSON subscribe parity remains undecided.", + "coverageNotes": "Local macOS evidence over the runtime-RPC stream budgets on main@1282f5c2d. PR #5824 adds a platform-neutral mobile decision gate proving chat-covered terminal streams pause and resume only after the mounted WebView is ready; live Android restore evidence remains required. The pending stack adds byte-exact 512KB/2MB/256KB/48KB budget assertions; legacy JSON subscribe parity remains undecided.", "motivatingLinks": [ "https://github.com/stablyai/orca/pull/6951", "https://github.com/stablyai/orca/pull/6955", "https://github.com/stablyai/orca/pull/7009" ], - "invariant": "Runtime and mobile terminal subscriptions must cap initial snapshots, live output buffered while snapshots load, chunk sizes, and batch sizes, while preserving output order, input locks, resize/driver events, and fallback parity or explicit fallback deprecation.", - "oracle": "The current executable slice asserts mobile initial snapshots downgrade until they fit <=512KB, requested binary snapshots downgrade until they fit <=2MB, binary live output queued while the initial snapshot loads stays <=256KB while preserving the newest tail, large binary output is split into <=48KB frames, output bursts are coalesced before emit, aborts do not register stale listeners, and stale mobile resize re-stream completions are dropped. JSON fallback parity/deprecation remains an explicit gap.", + "invariant": "Runtime and mobile terminal subscriptions must cap initial snapshots, live output buffered while snapshots load, chunk sizes, and batch sizes; a terminal covered by native chat must have no live output subscription and must restore from fresh scrollback when revealed, while preserving output order, input locks, resize/driver events, and fallback parity or explicit fallback deprecation.", + "oracle": "The current executable slice asserts mobile initial snapshots downgrade until they fit <=512KB, requested binary snapshots downgrade until they fit <=2MB, binary live output queued while the initial snapshot loads stays <=256KB while preserving the newest tail, large binary output is split into <=48KB frames, output bursts are coalesced before emit, aborts do not register stale listeners, and stale mobile resize re-stream completions are dropped. The mobile native-chat decision test asserts an active stream pauses while covered and resumes only for a ready active terminal. JSON fallback parity and live Android scrollback restoration remain explicit gaps.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/main/runtime/rpc/terminal-output-batching.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts" + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/main/runtime/rpc/terminal-output-batching.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts", + "pnpm --dir mobile exec vitest run --root .. mobile/src/session/mobile-native-chat-terminal-stream.test.ts", + "pnpm --dir mobile exec vitest run --root .. mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts" ], "testFiles": [ "src/main/runtime/rpc/terminal-subscribe-buffer.test.ts", "src/main/runtime/rpc/terminal-output-batching.test.ts", - "src/main/runtime/rpc/terminal-multiplex.test.ts" + "src/main/runtime/rpc/terminal-multiplex.test.ts", + "mobile/src/session/mobile-native-chat-terminal-stream.test.ts", + "mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts" ], "assertionRefs": [ { @@ -3956,6 +3961,21 @@ "oversized live output frames are bounded for subscribed binary streams", "multibyte live output flushes when encoded bytes reach the batch budget" ] + }, + { + "file": "mobile/src/session/mobile-native-chat-terminal-stream.test.ts", + "assertions": [ + "an active terminal stream pauses while native chat covers its WebView", + "the stream resumes only for the ready active terminal after chat closes", + "non-terminal and already-settled stream states perform no lifecycle work" + ] + }, + { + "file": "mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts", + "assertions": [ + "a late WebView-ready transition converts a cold-start lease-only stream back to output", + "ordinary WebView readiness does not rerender the mobile session route" + ] } ], "evidenceRuns": [ @@ -3967,6 +3987,15 @@ "result": "passed", "durationSeconds": 3.8, "summary": "3 test file(s) passed, 28 tests passed on main@1282f5c2d in a clean checkout." + }, + { + "date": "2026-07-12", + "runner": "local", + "platform": "macos", + "command": "pnpm --dir mobile exec vitest run --root .. mobile/src/session/mobile-native-chat-terminal-stream.test.ts", + "result": "passed", + "durationSeconds": 0.2, + "summary": "The focused mobile native-chat suite passed with 3 terminal-stream lifecycle assertions in the staged PR #5824 worktree." } ], "runtimeBudget": { @@ -3991,7 +4020,7 @@ "Record byte counts and order/cursor artifacts on failure." ], "knownGaps": [ - "Current command covers binary runtime/mobile stream contracts only.", + "The pure mobile decision gate does not yet prove live Android WebView scrollback restore after a chat toggle.", "Legacy JSON subscribe parity is undecided." ], "demotionRule": "Cannot promote while a supported stream path has uncapped snapshot or live-output buffering." diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index d4d1b35896fd..0251da704900 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -26,10 +26,8 @@ import { Bot, ChevronDown, ChevronLeft, - ChevronRight, ChevronsRight, Copy, - Eraser, Folder, File, FileText, @@ -174,6 +172,7 @@ import { type MobileNewTabAgentSettings } from '../../../../src/session/mobile-new-tab-agent-options' import { useMobileImageAttachment } from '../../../../src/session/use-mobile-image-attachment' +import { useMobileAttachmentInputLeaseGate } from '../../../../src/session/use-mobile-attachment-input-lease-gate' import { useMobileTerminalPaste } from '../../../../src/session/use-mobile-terminal-paste' import { useTerminalLiveInputModePreference } from '../../../../src/session/use-terminal-live-input-mode-preference' import { MobileTerminalLiveInputStatus } from '../../../../src/session/MobileTerminalLiveInputStatus' @@ -198,6 +197,15 @@ import { isDictationSetupRequiredError } from '../../../../src/dictation/mobile-dictation-setup' import { TerminalPaneView } from '../../../../src/session/TerminalPaneView' +import { MobileNativeChatOverlay } from '../../../../src/session/MobileNativeChatOverlay' +import { MobileBrowserTabActionSheet } from '../../../../src/session/MobileBrowserTabActionSheet' +import { useMobileNativeChatController } from '../../../../src/session/use-mobile-native-chat-controller' +import { useMobileNativeChatReadability } from '../../../../src/session/use-mobile-native-chat-readability' +import { useMobileNativeChatInputLease } from '../../../../src/session/use-mobile-native-chat-input-lease' +import { getMobileTerminalActionSheetActions } from '../../../../src/session/mobile-terminal-action-sheet-actions' +import * as nativeChatTerminalStream from '../../../../src/session/mobile-native-chat-terminal-stream' +import { useMobileNativeChatTerminalStream } from '../../../../src/session/use-mobile-native-chat-terminal-stream' +import { subscribeMobileTerminalSafely } from '../../../../src/session/mobile-terminal-stream-subscribe' import { activateMobileSessionTab, focusMobileTerminal @@ -249,8 +257,6 @@ import type { TerminalGestureInputQueue } from './mobile-session-route-types' -type TerminalLiveAccessoryInput = ReturnType - const TERMINAL_KEYBOARD_DISMISS_ACTION_SHEET_FALLBACK_MS = 450 function MarkdownReader({ @@ -1167,11 +1173,48 @@ export default function SessionScreen() { }, [clearToastHideTimer] ) + const showNativeChatSendError = useCallback( + (message: string) => showToast(message, 1600), + [showToast] + ) + const nativeChatTranscriptIsLocalReadable = useMobileNativeChatReadability(client, worktreeId) + const { + ready: nativeChatInputLeaseReady, + readyRef: nativeChatInputLeaseReadyRef, + lockReason: nativeChatInputLockReason, + markReady: markNativeChatInputLeaseReady, + clear: clearNativeChatInputLease + } = useMobileNativeChatInputLease({ + activeHandle, + connected: connState === 'connected' + }) + const nativeChatController = useMobileNativeChatController({ + client, + hostId, + worktreeId, + activeSessionTab, + activeSessionTabId, + activeHandleRef, + deviceTokenRef, + nativeChatTranscriptIsLocalReadable, + nativeChatInputLeaseReady, + onSendError: showNativeChatSendError + }) + const { chatTabIds, toggleTabChatView, showNativeChat, showNativeChatRef } = nativeChatController const dictation = useMobileDictation({ client, enabled: canSend, onTranscript: (text) => { + // Why: dictation belongs to the visible composer. Native chat consumes it + // locally; terminal mode retains the live-input routing and flush contract. + if (showNativeChatRef.current) { + nativeChatController.setChatComposerText((current) => + appendBufferedDictation(current, text) + ) + showToast('Dictation inserted') + return + } // Live mode inserts the transcript straight into its originating PTY as // text (no Return — the user sends it themselves), matching live keystroke // semantics; buffered mode keeps appending to the command field. @@ -1290,22 +1333,27 @@ export default function SessionScreen() { return handle ? terminalRefs.current.get(handle) : undefined }, []) - const unsubscribeTerminal = useCallback((handle: string) => { - terminalUnsubsRef.current.get(handle)?.() - terminalUnsubsRef.current.delete(handle) - subscribingHandlesRef.current.delete(handle) - terminalDiagnosticsRef.current.terminalUnsubscribed(handle) - subscribeSeqRef.current.set(handle, (subscribeSeqRef.current.get(handle) ?? 0) + 1) - // Why: a fresh subscription will land on a new server-side state machine - // run (or the same one with a higher seq); reset the high-water mark so - // the first scrollback isn't accidentally dropped as stale. - layoutSeqRef.current.delete(handle) - }, []) + const unsubscribeTerminal = useCallback( + (handle: string) => { + terminalUnsubsRef.current.get(handle)?.() + terminalUnsubsRef.current.delete(handle) + subscribingHandlesRef.current.delete(handle) + terminalDiagnosticsRef.current.terminalUnsubscribed(handle) + subscribeSeqRef.current.set(handle, (subscribeSeqRef.current.get(handle) ?? 0) + 1) + // Why: a fresh subscription will land on a new server-side state machine + // run (or the same one with a higher seq); reset the high-water mark so + // the first scrollback isn't accidentally dropped as stale. + layoutSeqRef.current.delete(handle) + clearNativeChatInputLease(handle) + }, + [clearNativeChatInputLease] + ) + const unsubscribeTerminalRef = useRef(unsubscribeTerminal) + unsubscribeTerminalRef.current = unsubscribeTerminal const clearTerminalCache = useCallback(() => { - for (const unsub of terminalUnsubsRef.current.values()) { - unsub() - } + terminalUnsubsRef.current.forEach((unsub) => unsub()) + clearNativeChatInputLease() terminalUnsubsRef.current.clear() subscribingHandlesRef.current.clear() initializedHandlesRef.current.clear() @@ -1318,7 +1366,7 @@ export default function SessionScreen() { for (const term of terminalRefs.current.values()) { term.clear() } - }, []) + }, [clearNativeChatInputLease]) // Why: measures the phone viewport once from the first available TerminalWebView. // The viewport dims are passed with every subscribe call so the server can @@ -1357,13 +1405,22 @@ export default function SessionScreen() { logSkippedGate('subscribe-in-flight') return } - if (!getTerminalRef(handle)) { - logSkippedGate('no-webview-ref') - return - } - if (!webReadyHandlesRef.current.has(handle)) { - logSkippedGate('webview-not-ready') - return + const covered = nativeChatTerminalStream.isTerminalCoveredByNativeChat( + showNativeChatRef.current, + activeHandleRef.current, + handle + ) + // Why: a native-chat-covered terminal subscribes as the input-floor lease + // without a mounted xterm webview, so only gate on the webview when NOT covered. + if (!covered) { + if (!getTerminalRef(handle)) { + logSkippedGate('no-webview-ref') + return + } + if (!webReadyHandlesRef.current.has(handle)) { + logSkippedGate('webview-not-ready') + return + } } subscribingHandlesRef.current.add(handle) @@ -1375,13 +1432,13 @@ export default function SessionScreen() { // The viewport is embedded in the subscribe params so the server resizes // the PTY before serializing scrollback. This eliminates the focus→safeFit // race and the measure→resize→resubscribe pipeline. - const unsub = client.subscribe( - 'terminal.subscribe', + const unsub = subscribeMobileTerminalSafely( + client, { terminal: handle, client: { id: deviceTokenRef.current!, type: 'mobile' as const }, viewport: viewportRef.current ?? undefined, - capabilities: { terminalBinaryStream: 1 } + capabilities: nativeChatTerminalStream.mobileNativeChatTerminalCapabilities(covered) }, (result) => { if (subscribeSeqRef.current.get(handle) !== seq) { @@ -1389,6 +1446,25 @@ export default function SessionScreen() { } const data = result as Record diagnostics.firstStreamEvent(handle, seq, data.type) + if (data.type === 'end' || data.type === 'error') { + unsubscribeTerminalRef.current(handle) + return + } + if (data.type === 'subscribed') { + markNativeChatInputLeaseReady(handle) + return + } + // Why: retain the subscription as the mobile input-floor lease, but + // do not mutate covered xterm state; return-to-terminal resubscribes. + if ( + nativeChatTerminalStream.isTerminalCoveredByNativeChat( + showNativeChatRef.current, + activeHandleRef.current, + handle + ) + ) { + return + } // Why: stale-event filter. Server-side state machine bumps a // monotonic seq on every applyLayout. Drop `resized` events // whose seq is strictly older than what we've already observed @@ -1417,9 +1493,6 @@ export default function SessionScreen() { } else if (eventSeq != null && data.type === 'scrollback') { layoutSeqRef.current.set(handle, eventSeq) } - if (data.type === 'subscribed') { - return - } if (data.type === 'scrollback') { diagnostics.streamScrollback(handle, seq, eventSeq, data) if (initializedHandlesRef.current.has(handle)) { @@ -1572,7 +1645,8 @@ export default function SessionScreen() { } scheduleDelayedAction(() => getTerminalRef(handle)?.resetZoom(), 200) } - } + }, + () => unsubscribeTerminalRef.current(handle) ) if (subscribeSeqRef.current.get(handle) === seq) { @@ -1582,9 +1656,21 @@ export default function SessionScreen() { } subscribingHandlesRef.current.delete(handle) }, - [client, getTerminalRef, scheduleDelayedAction] + [client, getTerminalRef, markNativeChatInputLeaseReady, scheduleDelayedAction] ) + const notifyTerminalWebReady = useMobileNativeChatTerminalStream({ + showNativeChat, + activeHandle, + activeTabType: activeSessionTab?.type ?? null, + subscriptionsRef: terminalUnsubsRef, + subscribingRef: subscribingHandlesRef, + webReadyRef: webReadyHandlesRef, + initializedRef: initializedHandlesRef, + subscribe: subscribeToTerminal, + unsubscribe: unsubscribeTerminal + }) + // Why: toggles between phone and desktop mode via server RPC. The server // handles the actual resize and emits a 'resized' event on the existing // subscription stream — no client-side state tracking needed. @@ -3064,6 +3150,7 @@ export default function SessionScreen() { (handle: string) => { const wasAlreadyReady = webReadyHandlesRef.current.has(handle) webReadyHandlesRef.current.add(handle) + notifyTerminalWebReady(handle, wasAlreadyReady) terminalDiagnosticsRef.current.webViewReady( handle, wasAlreadyReady, @@ -3105,7 +3192,7 @@ export default function SessionScreen() { })() } }, - [getTerminalRef, measureViewportOnce, subscribeToTerminal, unsubscribeTerminal] + [measureViewportOnce, notifyTerminalWebReady, subscribeToTerminal, unsubscribeTerminal] ) useEffect(() => { @@ -3156,7 +3243,7 @@ export default function SessionScreen() { } } - async function handleAccessoryKey(input: TerminalLiveAccessoryInput) { + async function handleAccessoryKey(input: ReturnType) { if (!client || !activeHandle || !canSend) { return } @@ -3643,7 +3730,7 @@ export default function SessionScreen() { } }, []) const startAccessoryRepeat = useCallback( - (input: TerminalLiveAccessoryInput) => { + (input: ReturnType) => { stopAccessoryRepeat() repeatTimeoutRef.current = setTimeout(() => { repeatIntervalRef.current = setInterval(() => { @@ -3815,19 +3902,14 @@ export default function SessionScreen() { showToast }) - const flushPendingLiveInputBeforeAttachmentSend = useCallback( - async (targetHandle: string): Promise => { - const flushedPendingInput = await flushPendingLiveInputBeforeExternalSend(targetHandle) - // Why: image picking/upload and IME flushing can outlive the original tab. - return ( - flushedPendingInput && - connStateRef.current === 'connected' && - targetHandle === activeHandleRef.current && - activeSessionTabTypeRef.current === 'terminal' - ) - }, - [flushPendingLiveInputBeforeExternalSend] - ) + const flushPendingLiveInputBeforeAttachmentSend = useMobileAttachmentInputLeaseGate({ + flushPendingLiveInputBeforeExternalSend, + connStateRef, + activeHandleRef, + activeSessionTabTypeRef, + nativeChatInputLeaseReadyRef, + showToast + }) const { attachImage, isAttaching } = useMobileImageAttachment({ client, @@ -4887,6 +4969,18 @@ export default function SessionScreen() { onOpenUrl={handleTerminalOpenUrl} /> ))} + void attachImage('library')} + isAttaching={isAttaching} + onMicPress={handleDictationToggle} + micActive={dictation.isRecording} + dictationMode={dictationMode} + onMicPressIn={handleDictationPressIn} + onMicPressOut={handleDictationPressOut} + inputLockReason={nativeChatInputLockReason} + keyboardInset={keyboardLift} + /> {toastMessage && ( {toastMessage} @@ -4896,8 +4990,9 @@ export default function SessionScreen() { )} {/* Why: translate instead of resizing so keyboard open/close does not - trigger a server-side PTY viewport change. */} - {!activeMarkdownTab && !activeFileTab && !activeBrowserTab && ( + trigger a server-side PTY viewport change. The dock hides in native + chat because that view supplies its own composer. */} + {!activeMarkdownTab && !activeFileTab && !activeBrowserTab && !showNativeChat && ( { - const target = actionTarget - setActionTarget(null) - if (target) { - void toggleDisplayMode(target.handle) - } - } - } - ] - : []), - { - label: 'Rename', - onPress: () => { - const target = actionTarget - setActionTarget(null) - if (target) { - setRenameTarget(target) - } - } - }, - { - label: 'Clear Terminal', - icon: Eraser, - onPress: () => { - const target = actionTarget - setActionTarget(null) - if (target) { - void handleClearTerminal(target) - } - } - }, - { - label: 'Close', - destructive: true, - onPress: () => { - const target = actionTarget - setActionTarget(null) - if (target) { - void handleCloseTerminal(target) - } - } - } - ]} + actions={getMobileTerminalActionSheetActions({ + target: actionTarget, + tabs: sessionTabs.filter((tab) => tab.type === 'terminal'), + chatTabIds, + nativeChatTranscriptIsLocalReadable, + onDismiss: () => setActionTarget(null), + onToggleChat: toggleTabChatView, + isPhoneMode, + onToggleDisplayMode: (handle) => void toggleDisplayMode(handle), + onRename: setRenameTarget, + onClear: (target) => void handleClearTerminal(target), + onClose: (target) => void handleCloseTerminal(target) + })} onClose={() => setActionTarget(null)} /> setFileActionTarget(null)} /> - { - const target = browserActionTarget - setBrowserActionTarget(null) - if (target) { - void handleBrowserNavigationCommand(target, 'browser.back') - } - } - } - ] - : []), - ...(browserActionTarget?.canGoForward - ? [ - { - label: 'Forward', - icon: ChevronRight, - onPress: () => { - const target = browserActionTarget - setBrowserActionTarget(null) - if (target) { - void handleBrowserNavigationCommand(target, 'browser.forward') - } - } - } - ] - : []), - { - label: 'Reload', - icon: RefreshCw, - onPress: () => { - const target = browserActionTarget - setBrowserActionTarget(null) - if (target) { - void handleBrowserNavigationCommand(target, 'browser.reload') - } - } - }, - { - label: 'Close', - destructive: true, - onPress: () => { - const target = browserActionTarget - setBrowserActionTarget(null) - if (target) { - void handleCloseSessionTab(target) - } - } - } - ]} + setBrowserActionTarget(null)} + onNavigate={handleBrowserNavigationCommand} + onCloseTab={handleCloseSessionTab} /> 1 so agent prose reads larger than the compact base. */ + textScale?: number + /** When provided, detected file-path tokens render as tappable and invoke this + * with the worktree-relative path. Omitted on screens with no file viewer, where + * paths render as plain text (no behavior change). */ + onOpenFile?: (relativePath: string) => void } const MAX_TABLE_ROWS = 40 @@ -19,7 +31,37 @@ function openMarkdownUrl(url: string): void { } } -function renderInline(text: string): ReactNode[] { +// Render a plain (non-token) text run, splitting out tappable file paths when +// onOpenFile is provided. Without it, paths stay plain text. +function renderTextRun( + text: string, + keyPrefix: string, + onOpenFile?: (relativePath: string) => void +): ReactNode { + if (!onOpenFile) { + return text + } + const segments = detectFilePathSegments(text) + if (segments.length === 1 && segments[0]!.type === 'text') { + return text + } + return segments.map((segment, segmentIndex) => { + if (segment.type === 'file') { + return ( + onOpenFile(segment.path)} + > + {segment.value} + + ) + } + return {segment.value} + }) +} + +function renderInline(text: string, onOpenFile?: (relativePath: string) => void): ReactNode[] { const parts: ReactNode[] = [] const pattern = /(!\[[^\]]*\]\([^)]+\)|`[^`]+`|~~[^~]+~~|\*\*[^*]+\*\*|__[^_]+__|\*[^*\n]+\*|_[^_\n]+_|\[[^\]]+\]\([^)]+\)|https?:\/\/[^\s<]+)/g @@ -28,7 +70,7 @@ function renderInline(text: string): ReactNode[] { while ((match = pattern.exec(text))) { if (match.index > lastIndex) { - parts.push(text.slice(lastIndex, match.index)) + parts.push(renderTextRun(text.slice(lastIndex, match.index), `t${lastIndex}`, onOpenFile)) } const token = match[0] const key = `${match.index}:${token}` @@ -53,11 +95,24 @@ function renderInline(text: string): ReactNode[] { ) } else if (token.startsWith('`')) { - parts.push( - - {token.slice(1, -1)} - - ) + const code = token.slice(1, -1) + if (onOpenFile && isFilePathCodeSpan(code)) { + parts.push( + onOpenFile(normalizeFilePath(code.trim()))} + > + {code} + + ) + } else { + parts.push( + + {code} + + ) + } } else if (token.startsWith('~~')) { parts.push( @@ -81,15 +136,20 @@ function renderInline(text: string): ReactNode[] { } if (lastIndex < text.length) { - parts.push(text.slice(lastIndex)) + parts.push(renderTextRun(text.slice(lastIndex), `t${lastIndex}`, onOpenFile)) } return parts } -function MobileMarkdownInner({ content, fallback = '' }: Props) { +function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile }: Props) { const text = content?.trim() ?? '' const previewText = useMemo(() => normalizeMobileMarkdownPreviewHtml(text), [text]) const blocks = useMemo(() => parseMobileMarkdown(previewText), [previewText]) + // Scale prose sizes; inline spans inherit fontSize from the wrapping Text. + const scaled = (size: number): { fontSize: number; lineHeight: number } | null => + textScale !== 1 ? { fontSize: size * textScale, lineHeight: (size + 6) * textScale } : null + const proseScale = scaled(13) + const listScale = scaled(14) if (!text) { return fallback ? {fallback} : null } @@ -103,14 +163,14 @@ function MobileMarkdownInner({ content, fallback = '' }: Props) { key={index} style={[styles.heading, block.level <= 2 ? styles.headingLarge : null]} > - {renderInline(block.text)} + {renderInline(block.text, onOpenFile)} ) } if (block.type === 'quote') { return ( - {renderInline(block.text)} + {renderInline(block.text, onOpenFile)} ) } @@ -147,7 +207,7 @@ function MobileMarkdownInner({ content, fallback = '' }: Props) { {visibleHeaders.map((header, cellIndex) => ( - {renderInline(header)} + {renderInline(header, onOpenFile)} ))} @@ -155,7 +215,7 @@ function MobileMarkdownInner({ content, fallback = '' }: Props) { {visibleHeaders.map((_, cellIndex) => ( - {renderInline(row[cellIndex] ?? '')} + {renderInline(row[cellIndex] ?? '', onOpenFile)} ))} @@ -185,7 +245,9 @@ function MobileMarkdownInner({ content, fallback = '' }: Props) { ? '[x]' : '[ ]'} - {renderInline(item.text)} + + {renderInline(item.text, onOpenFile)} + ))} @@ -195,11 +257,11 @@ function MobileMarkdownInner({ content, fallback = '' }: Props) { return } return ( - + {block.text.split('\n').map((line, lineIndex) => ( {lineIndex > 0 ? '\n' : null} - {renderInline(line)} + {renderInline(line, onOpenFile)} ))} @@ -210,147 +272,3 @@ function MobileMarkdownInner({ content, fallback = '' }: Props) { } export const MobileMarkdown = memo(MobileMarkdownInner) - -const styles = StyleSheet.create({ - root: { - gap: spacing.sm - }, - paragraph: { - fontSize: 13, - lineHeight: 19, - color: colors.textPrimary - }, - heading: { - fontSize: 14, - lineHeight: 20, - fontWeight: '700', - color: colors.textPrimary - }, - headingLarge: { - fontSize: 15, - lineHeight: 21 - }, - bold: { - fontWeight: '700', - color: colors.textPrimary - }, - italic: { - fontStyle: 'italic' - }, - strike: { - textDecorationLine: 'line-through' - }, - link: { - color: colors.accentBlue, - textDecorationLine: 'underline' - }, - inlineCode: { - fontFamily: typography.monoFamily, - fontSize: 12, - color: colors.textPrimary, - backgroundColor: colors.bgRaised, - borderRadius: radii.row, - paddingHorizontal: 4 - }, - quote: { - borderLeftWidth: 2, - borderLeftColor: colors.borderSubtle, - paddingLeft: spacing.sm - }, - quoteText: { - fontSize: 13, - lineHeight: 19, - color: colors.textSecondary - }, - codeBlock: { - backgroundColor: colors.bgRaised, - borderWidth: 1, - borderColor: colors.borderSubtle, - borderRadius: radii.input, - padding: spacing.sm - }, - codeLanguage: { - fontSize: 10, - color: colors.textMuted, - marginBottom: spacing.xs, - textTransform: 'uppercase' - }, - codeText: { - fontFamily: typography.monoFamily, - fontSize: 12, - lineHeight: 17, - color: colors.textPrimary - }, - imageFrame: { - borderWidth: 1, - borderColor: colors.borderSubtle, - borderRadius: radii.input, - backgroundColor: colors.bgRaised, - overflow: 'hidden', - padding: spacing.sm - }, - imageCaption: { - paddingHorizontal: spacing.sm, - paddingVertical: spacing.xs, - fontSize: 11, - color: colors.textSecondary - }, - table: { - borderTopWidth: 1, - borderLeftWidth: 1, - borderColor: colors.borderSubtle, - borderRadius: radii.input, - overflow: 'hidden', - backgroundColor: colors.bgPanel - }, - tableRow: { - flexDirection: 'row' - }, - tableCell: { - minWidth: 112, - maxWidth: 220, - borderRightWidth: 1, - borderBottomWidth: 1, - borderColor: colors.borderSubtle, - paddingHorizontal: spacing.sm, - paddingVertical: spacing.xs, - fontSize: 12, - lineHeight: 17, - color: colors.textPrimary - }, - tableHeader: { - fontWeight: '700', - backgroundColor: colors.bgRaised - }, - tableTruncated: { - padding: spacing.sm, - fontSize: 12, - color: colors.textMuted - }, - list: { - gap: spacing.xs - }, - listItem: { - flexDirection: 'row', - alignItems: 'flex-start', - gap: spacing.sm - }, - listMarker: { - width: 22, - fontSize: 13, - lineHeight: 19, - color: colors.textSecondary, - fontFamily: typography.monoFamily - }, - listText: { - flex: 1, - minWidth: 0, - fontSize: 13, - lineHeight: 19, - color: colors.textPrimary - }, - rule: { - height: StyleSheet.hairlineWidth, - backgroundColor: colors.borderSubtle - } -}) diff --git a/mobile/src/components/markdown-file-path-detection.test.ts b/mobile/src/components/markdown-file-path-detection.test.ts new file mode 100644 index 000000000000..f75da01efdc7 --- /dev/null +++ b/mobile/src/components/markdown-file-path-detection.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it } from 'vitest' +import { + detectFilePathSegments, + isFilePathCodeSpan, + normalizeFilePath +} from './markdown-file-path-detection' + +describe('detectFilePathSegments', () => { + it('returns a single text segment when there is no path', () => { + expect(detectFilePathSegments('just some prose here')).toEqual([ + { type: 'text', value: 'just some prose here' } + ]) + }) + + it('detects a relative source path with surrounding prose', () => { + const segments = detectFilePathSegments('Edit src/app/Main.tsx now') + expect(segments).toEqual([ + { type: 'text', value: 'Edit ' }, + { type: 'file', value: 'src/app/Main.tsx', path: 'src/app/Main.tsx' }, + { type: 'text', value: ' now' } + ]) + }) + + it('strips a leading ./ in the path but keeps the displayed value', () => { + const segments = detectFilePathSegments('see ./lib/x.ts') + expect(segments).toEqual([ + { type: 'text', value: 'see ' }, + { type: 'file', value: './lib/x.ts', path: 'lib/x.ts' } + ]) + }) + + it('keeps ../ parent-relative paths intact', () => { + const segments = detectFilePathSegments('../shared/util.ts') + expect(segments).toEqual([ + { type: 'file', value: '../shared/util.ts', path: '../shared/util.ts' } + ]) + }) + + it('detects multiple paths in one run', () => { + const segments = detectFilePathSegments('a/b.ts and c/d/e.json') + expect(segments.filter((s) => s.type === 'file')).toEqual([ + { type: 'file', value: 'a/b.ts', path: 'a/b.ts' }, + { type: 'file', value: 'c/d/e.json', path: 'c/d/e.json' } + ]) + }) + + it('detects Windows relative, drive, and UNC paths', () => { + const segments = detectFilePathSegments( + String.raw`Edit src\app\Main.tsx, C:\repo\config.json, and \\server\share\docs\readme.md` + ) + + expect(segments.filter((segment) => segment.type === 'file')).toEqual([ + { type: 'file', value: String.raw`src\app\Main.tsx`, path: String.raw`src\app\Main.tsx` }, + { + type: 'file', + value: String.raw`C:\repo\config.json`, + path: String.raw`C:\repo\config.json` + }, + { + type: 'file', + value: String.raw`\\server\share\docs\readme.md`, + path: String.raw`\\server\share\docs\readme.md` + } + ]) + }) + + it('does not match bare filenames without a slash', () => { + expect(detectFilePathSegments('open Main.tsx please')).toEqual([ + { type: 'text', value: 'open Main.tsx please' } + ]) + }) + + it('does not match URLs', () => { + expect(detectFilePathSegments('https://example.com/path/file.ts')).toEqual([ + { type: 'text', value: 'https://example.com/path/file.ts' } + ]) + }) + + it('does not match version numbers', () => { + expect(detectFilePathSegments('upgraded to 1.2.3 today')).toEqual([ + { type: 'text', value: 'upgraded to 1.2.3 today' } + ]) + }) + + it('does not match unknown extensions', () => { + expect(detectFilePathSegments('path/to/thing.whatever')).toEqual([ + { type: 'text', value: 'path/to/thing.whatever' } + ]) + }) + + it('detects scoped-package file paths with a segment-leading @', () => { + expect(detectFilePathSegments('open @types/react/index.d.ts here')).toEqual([ + { type: 'text', value: 'open ' }, + { + type: 'file', + value: '@types/react/index.d.ts', + path: '@types/react/index.d.ts' + }, + { type: 'text', value: ' here' } + ]) + expect( + detectFilePathSegments('node_modules/@scope/pkg/file.ts').filter((s) => s.type === 'file') + ).toEqual([ + { + type: 'file', + value: 'node_modules/@scope/pkg/file.ts', + path: 'node_modules/@scope/pkg/file.ts' + } + ]) + }) + + it('does not match emails or git URLs with a mid-token @', () => { + expect(detectFilePathSegments('clone git@github.com:user/repo.git')).toEqual([ + { type: 'text', value: 'clone git@github.com:user/repo.git' } + ]) + expect(detectFilePathSegments('open user@host.com/path/file.txt')).toEqual([ + { type: 'text', value: 'open user@host.com/path/file.txt' } + ]) + }) + + it('returns a single text segment when the run has no dot', () => { + const text = 'a/'.repeat(8192) + expect(detectFilePathSegments(text)).toEqual([{ type: 'text', value: text }]) + }) + + it('skips detection for runs over the length cap even with dots', () => { + // 'a.b/'-repeats pass the dot precheck, so this exercises the length cap that + // bounds CANDIDATE_PATTERN's worst-case backtracking. + const text = 'a.b/'.repeat(2000) + expect(detectFilePathSegments(text)).toEqual([{ type: 'text', value: text }]) + }) + + it('still detects a path in a long-but-under-cap run', () => { + const prefix = 'context '.repeat(200) + const segments = detectFilePathSegments(`${prefix}src/app/Main.tsx`) + expect(segments.filter((s) => s.type === 'file')).toEqual([ + { type: 'file', value: 'src/app/Main.tsx', path: 'src/app/Main.tsx' } + ]) + }) +}) + +describe('isFilePathCodeSpan', () => { + it('accepts a slashed path code span', () => { + expect(isFilePathCodeSpan('src/app/Main.tsx')).toBe(true) + }) + + it('accepts Windows paths in code spans', () => { + expect(isFilePathCodeSpan(String.raw`src\app\Main.tsx`)).toBe(true) + expect(isFilePathCodeSpan(String.raw`C:\repo\Main.tsx`)).toBe(true) + expect(isFilePathCodeSpan(String.raw`\\server\share\Main.tsx`)).toBe(true) + }) + + it('accepts a bare filename code span', () => { + expect(isFilePathCodeSpan('package.json')).toBe(true) + }) + + it('rejects multi-word code spans', () => { + expect(isFilePathCodeSpan('npm run build')).toBe(false) + }) + + it('rejects non-file code spans', () => { + expect(isFilePathCodeSpan('someVariable')).toBe(false) + }) + + it('rejects urls in code spans', () => { + expect(isFilePathCodeSpan('https://x.com/a.ts')).toBe(false) + }) + + it('accepts scoped-package paths with a segment-leading @', () => { + expect(isFilePathCodeSpan('@types/react/index.d.ts')).toBe(true) + expect(isFilePathCodeSpan('node_modules/@scope/pkg/file.ts')).toBe(true) + }) + + it('rejects emails and git URLs with a mid-token @', () => { + expect(isFilePathCodeSpan('git@github.com:user/repo.git')).toBe(false) + expect(isFilePathCodeSpan('user@host.com/path/file.txt')).toBe(false) + }) +}) + +describe('normalizeFilePath', () => { + it('strips a leading ./', () => { + expect(normalizeFilePath('./a/b.ts')).toBe('a/b.ts') + expect(normalizeFilePath(String.raw`.\a\b.ts`)).toBe(String.raw`a\b.ts`) + }) + + it('leaves other paths unchanged', () => { + expect(normalizeFilePath('../a/b.ts')).toBe('../a/b.ts') + expect(normalizeFilePath('a/b.ts')).toBe('a/b.ts') + }) +}) diff --git a/mobile/src/components/markdown-file-path-detection.ts b/mobile/src/components/markdown-file-path-detection.ts new file mode 100644 index 000000000000..a33c0dceae4c --- /dev/null +++ b/mobile/src/components/markdown-file-path-detection.ts @@ -0,0 +1,212 @@ +// Conservative detection of file-path-like tokens inside an inline markdown text +// run, so the chat view can render them as tappable (opening the mobile file +// viewer). We deliberately favor precision over recall: a missed path is a minor +// annoyance, but a false positive on prose or a version number is a broken tap. + +export type FilePathSegment = + | { type: 'text'; value: string } + | { type: 'file'; value: string; path: string } + +// Common source/code/config extensions we treat as openable file paths. Kept +// explicit (rather than "any extension") so prose like "etc." or "e.g." and +// domain-ish tokens like "example.com" don't get matched. +const FILE_EXTENSIONS = [ + 'ts', + 'tsx', + 'js', + 'jsx', + 'mjs', + 'cjs', + 'json', + 'jsonc', + 'css', + 'scss', + 'sass', + 'less', + 'html', + 'htm', + 'md', + 'mdx', + 'markdown', + 'txt', + 'py', + 'rb', + 'go', + 'rs', + 'java', + 'kt', + 'kts', + 'swift', + 'c', + 'h', + 'cc', + 'cpp', + 'hpp', + 'cs', + 'php', + 'sh', + 'bash', + 'zsh', + 'fish', + 'yml', + 'yaml', + 'toml', + 'ini', + 'cfg', + 'conf', + 'env', + 'lock', + 'sql', + 'graphql', + 'gql', + 'proto', + 'xml', + 'svg', + 'vue', + 'svelte', + 'astro', + 'dart', + 'ex', + 'exs', + 'erl', + 'lua', + 'pl', + 'r', + 'scala', + 'clj', + 'gradle', + 'dockerfile', + 'gitignore', + 'npmrc' +] as const + +const EXTENSION_SET = new Set(FILE_EXTENSIONS) + +// Accept the host's native separator because transcript paths originate on the +// connected runtime, which may be Windows even when the phone is not. +const CANDIDATE_PATTERN = + /(?:[A-Za-z]:[\\/]|\\\\)?(?:\.{1,2}[\\/])?(?:[\w.@~+-]+[\\/])+[\w.@+-]+\.[A-Za-z0-9]+/g + +// A path candidate in chat prose is short; a much longer run can't hold one worth +// linkifying but can push CANDIDATE_PATTERN into super-linear backtracking, so we +// skip detection entirely above this cap. +const MAX_DETECTION_LENGTH = 2000 + +// A mid-token '@' (one preceded by a non-separator) marks an email or git URL such +// as git@github.com; a segment-leading '@' is a scoped package dir (@scope/…) and +// stays eligible. +function hasMidTokenAt(candidate: string): boolean { + return /[^\\/]@/.test(candidate) +} + +function isOpenablePath(candidate: string): boolean { + // Reject anything URL-ish or scheme-bearing — those are handled as web links. + if (candidate.includes('://') || hasMidTokenAt(candidate)) { + return false + } + // Must contain a separator (a bare "file.ts" is too ambiguous in prose). + if (!/[\\/]/.test(candidate)) { + return false + } + const lastSeparator = Math.max(candidate.lastIndexOf('/'), candidate.lastIndexOf('\\')) + const lastSegment = candidate.slice(lastSeparator + 1) + const dot = lastSegment.lastIndexOf('.') + // A leading-dot dotfile in the final segment (e.g. ".env") has no extension to + // anchor on; require a real name.ext shape. + if (dot <= 0) { + return false + } + const ext = lastSegment.slice(dot + 1).toLowerCase() + if (!EXTENSION_SET.has(ext)) { + return false + } + // Guard against version-number-ish tails ("1.2.3" style) where the "extension" + // is purely numeric — those aren't files. + if (/^\d+$/.test(ext)) { + return false + } + return true +} + +// Strip the leading ./ marker so callers receive a clean worktree-relative path, +// while keeping ../ (which is meaningful) intact. +export function normalizeFilePath(path: string): string { + return path.replace(/^\.[\\/]/, '') +} + +/** + * Given a plain inline text run, return ordered segments marking which substrings + * are openable file paths. Non-path text is preserved verbatim so the renderer can + * reassemble the run exactly. Returns a single text segment when nothing matches. + */ +export function detectFilePathSegments(text: string): FilePathSegment[] { + // Every real match ends in a name.ext, so a dot is mandatory; skip the regex when + // there is none, or when the run is too long to hold a chat path but long enough + // to drive CANDIDATE_PATTERN into super-linear backtracking. + if (text.length > MAX_DETECTION_LENGTH || !text.includes('.')) { + return [{ type: 'text', value: text }] + } + const segments: FilePathSegment[] = [] + let lastIndex = 0 + let match: RegExpExecArray | null + CANDIDATE_PATTERN.lastIndex = 0 + + while ((match = CANDIDATE_PATTERN.exec(text))) { + const candidate = match[0] + // Skip candidates that are part of a URL (preceded by a scheme colon or an + // alphanumeric/host char that would make this a domain tail, not a path). + const prev = match.index > 0 ? text[match.index - 1]! : '' + if (prev === ':' || prev === '/' || /[\w.@]/.test(prev)) { + continue + } + if (!isOpenablePath(candidate)) { + continue + } + if (match.index > lastIndex) { + segments.push({ type: 'text', value: text.slice(lastIndex, match.index) }) + } + segments.push({ type: 'file', value: candidate, path: normalizeFilePath(candidate) }) + lastIndex = match.index + candidate.length + } + + if (lastIndex < text.length) { + segments.push({ type: 'text', value: text.slice(lastIndex) }) + } + // Collapse to a single text segment for the common no-match case. + if (segments.length === 0) { + return [{ type: 'text', value: text }] + } + return segments +} + +/** + * True when an inline-code span's entire content is a single openable file path + * (e.g. `src/app/Main.tsx`). Code spans are a strong signal, so we also accept a + * bare `file.ts` here even without a slash. + */ +export function isFilePathCodeSpan(code: string): boolean { + const trimmed = code.trim() + if (!trimmed || /\s/.test(trimmed)) { + return false + } + if (trimmed.includes('://') || hasMidTokenAt(trimmed)) { + return false + } + if (isOpenablePath(trimmed)) { + return true + } + // Separator-less code span: accept a clean name.ext with a known extension. + if (/[\\/]/.test(trimmed)) { + return false + } + const dot = trimmed.lastIndexOf('.') + if (dot <= 0) { + return false + } + const name = trimmed.slice(0, dot) + const ext = trimmed.slice(dot + 1).toLowerCase() + if (/[^\w.@+-]/.test(name)) { + return false + } + return EXTENSION_SET.has(ext) +} diff --git a/mobile/src/components/mobile-markdown-styles.ts b/mobile/src/components/mobile-markdown-styles.ts new file mode 100644 index 000000000000..414a8daf57be --- /dev/null +++ b/mobile/src/components/mobile-markdown-styles.ts @@ -0,0 +1,150 @@ +import { StyleSheet } from 'react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' + +export const styles = StyleSheet.create({ + root: { + gap: spacing.sm + }, + paragraph: { + fontSize: 13, + lineHeight: 19, + color: colors.textPrimary + }, + heading: { + fontSize: 14, + lineHeight: 20, + fontWeight: '700', + color: colors.textPrimary + }, + headingLarge: { + fontSize: 15, + lineHeight: 21 + }, + bold: { + fontWeight: '700', + color: colors.textPrimary + }, + italic: { + fontStyle: 'italic' + }, + strike: { + textDecorationLine: 'line-through' + }, + link: { + color: colors.accentBlue, + textDecorationLine: 'underline' + }, + inlineCode: { + fontFamily: typography.monoFamily, + fontSize: 12, + color: colors.textPrimary, + backgroundColor: colors.bgRaised, + borderRadius: radii.row, + paddingHorizontal: 4 + }, + inlineCodeLink: { + color: colors.accentBlue, + textDecorationLine: 'underline' + }, + quote: { + borderLeftWidth: 2, + borderLeftColor: colors.borderSubtle, + paddingLeft: spacing.sm + }, + quoteText: { + fontSize: 13, + lineHeight: 19, + color: colors.textSecondary + }, + codeBlock: { + backgroundColor: colors.bgRaised, + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.input, + padding: spacing.sm + }, + codeLanguage: { + fontSize: 10, + color: colors.textMuted, + marginBottom: spacing.xs, + textTransform: 'uppercase' + }, + codeText: { + fontFamily: typography.monoFamily, + fontSize: 12, + lineHeight: 17, + color: colors.textPrimary + }, + imageFrame: { + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.input, + backgroundColor: colors.bgRaised, + overflow: 'hidden', + padding: spacing.sm + }, + imageCaption: { + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + fontSize: 11, + color: colors.textSecondary + }, + table: { + borderTopWidth: 1, + borderLeftWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.input, + overflow: 'hidden', + backgroundColor: colors.bgPanel + }, + tableRow: { + flexDirection: 'row' + }, + tableCell: { + minWidth: 112, + maxWidth: 220, + borderRightWidth: 1, + borderBottomWidth: 1, + borderColor: colors.borderSubtle, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + fontSize: 12, + lineHeight: 17, + color: colors.textPrimary + }, + tableHeader: { + fontWeight: '700', + backgroundColor: colors.bgRaised + }, + tableTruncated: { + padding: spacing.sm, + fontSize: 12, + color: colors.textMuted + }, + list: { + gap: spacing.xs + }, + listItem: { + flexDirection: 'row', + alignItems: 'flex-start', + gap: spacing.sm + }, + listMarker: { + width: 22, + fontSize: 13, + lineHeight: 19, + color: colors.textSecondary, + fontFamily: typography.monoFamily + }, + listText: { + flex: 1, + minWidth: 0, + fontSize: 13, + lineHeight: 19, + color: colors.textPrimary + }, + rule: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.borderSubtle + } +}) diff --git a/mobile/src/session/MobileAgentWorkingIndicator.tsx b/mobile/src/session/MobileAgentWorkingIndicator.tsx new file mode 100644 index 000000000000..cb766b0be513 --- /dev/null +++ b/mobile/src/session/MobileAgentWorkingIndicator.tsx @@ -0,0 +1,64 @@ +import { useEffect, useRef } from 'react' +import { Animated, StyleSheet, Text, View } from 'react-native' +import { colors, spacing, typography } from '../theme/mobile-theme' + +/** Animated three-dot "agent is working" row, shown while the active agent is + * still producing a reply. Pure presentation — visibility is the caller's call. */ +export function MobileAgentWorkingIndicator(): React.JSX.Element { + const dots = [ + useRef(new Animated.Value(0.3)).current, + useRef(new Animated.Value(0.3)).current, + useRef(new Animated.Value(0.3)).current + ] + + useEffect(() => { + const animations = dots.map((dot, i) => + Animated.loop( + Animated.sequence([ + Animated.delay(i * 160), + Animated.timing(dot, { toValue: 1, duration: 320, useNativeDriver: true }), + Animated.timing(dot, { toValue: 0.3, duration: 320, useNativeDriver: true }) + ]) + ) + ) + animations.forEach((a) => a.start()) + return () => animations.forEach((a) => a.stop()) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + return ( + + Agent is working + + {dots.map((dot, i) => ( + + ))} + + + ) +} + +const styles = StyleSheet.create({ + row: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm + }, + label: { + color: colors.textMuted, + fontSize: typography.metaSize, + fontStyle: 'italic' + }, + dots: { + flexDirection: 'row', + gap: 4 + }, + dot: { + width: 5, + height: 5, + borderRadius: 2.5, + backgroundColor: colors.textSecondary + } +}) diff --git a/mobile/src/session/MobileBrowserTabActionSheet.tsx b/mobile/src/session/MobileBrowserTabActionSheet.tsx new file mode 100644 index 000000000000..3baa6da8c3ab --- /dev/null +++ b/mobile/src/session/MobileBrowserTabActionSheet.tsx @@ -0,0 +1,79 @@ +import { ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react-native' +import type { MobileSessionTab } from '../../app/h/[hostId]/session/mobile-session-route-types' +import { ActionSheetModal } from '../components/ActionSheetModal' +import { getMobileSessionTabTitle } from './mobile-terminal-tab-agent' + +type BrowserTab = Extract +export type MobileBrowserNavigationMethod = 'browser.back' | 'browser.forward' | 'browser.reload' + +/** Keeps browser-tab navigation actions out of the session route while preserving + * the target captured at the moment each drawer action is pressed. */ +export function MobileBrowserTabActionSheet(props: { + target: BrowserTab | null + onClose: () => void + onNavigate: (target: BrowserTab, method: MobileBrowserNavigationMethod) => void + onCloseTab: (target: BrowserTab) => void +}): React.JSX.Element { + const { target, onClose, onNavigate, onCloseTab } = props + return ( + { + const current = target + onClose() + if (current) { + onNavigate(current, 'browser.back') + } + } + } + ] + : []), + ...(target?.canGoForward + ? [ + { + label: 'Forward', + icon: ChevronRight, + onPress: () => { + const current = target + onClose() + if (current) { + onNavigate(current, 'browser.forward') + } + } + } + ] + : []), + { + label: 'Reload', + icon: RefreshCw, + onPress: () => { + const current = target + onClose() + if (current) { + onNavigate(current, 'browser.reload') + } + } + }, + { + label: 'Close', + destructive: true, + onPress: () => { + const current = target + onClose() + if (current) { + onCloseTab(current) + } + } + } + ]} + onClose={onClose} + /> + ) +} diff --git a/mobile/src/session/MobileNativeChatAsk.tsx b/mobile/src/session/MobileNativeChatAsk.tsx new file mode 100644 index 000000000000..b485818970cc --- /dev/null +++ b/mobile/src/session/MobileNativeChatAsk.tsx @@ -0,0 +1,378 @@ +import { useMemo, useRef, useState } from 'react' +import { Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native' +import { Check } from 'lucide-react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import type { AskAnswerSelection, AskPrompt } from './mobile-native-chat-ask' + +type Props = { + prompt: AskPrompt + /** Deliver the chosen answer (per-question option indices + free text) — + * index-based so Claude's arrow-navigate selector can be driven by the + * option's stable number instead of pasted label text (STA-1860). */ + onAnswer: (selections: AskAnswerSelection[]) => Promise + onCancel?: () => Promise +} + +// Sentinel index for the free-text "Other…" row (never a real option index). +const OTHER = -1 + +/** Native renderer for an agent's AskUserQuestion prompt as a wizard: one + * question per step with tabs across the top, a Next button that advances (Send + * on the last step), and a Cancel that dismisses the prompt. Neutral styling + * with a subtle green accent on the active choice to match the rest of the app. */ +export function MobileNativeChatAsk({ prompt, onAnswer, onCancel }: Props): React.JSX.Element { + const [index, setIndex] = useState(0) + const [selections, setSelections] = useState(() => prompt.questions.map(() => [])) + const [otherText, setOtherText] = useState(() => prompt.questions.map(() => '')) + const [submitting, setSubmitting] = useState(false) + const submittingRef = useRef(false) + + const toggle = (qi: number, optIndex: number, multi: boolean): void => { + setSelections((prev) => { + const next = prev.map((s) => [...s]) + const cur = next[qi] ?? [] + if (multi) { + next[qi] = cur.includes(optIndex) ? cur.filter((i) => i !== optIndex) : [...cur, optIndex] + } else { + next[qi] = cur.includes(optIndex) ? [] : [optIndex] + } + return next + }) + } + + const setOther = (qi: number, value: string): void => { + setOtherText((prev) => { + const next = [...prev] + next[qi] = value + return next + }) + } + + const selectionFor = (qi: number): AskAnswerSelection => { + const picked = (selections[qi] ?? []).filter((i) => i !== OTHER) + const other = (selections[qi] ?? []).includes(OTHER) ? (otherText[qi] ?? '').trim() : '' + return other ? { indices: picked, other } : { indices: picked } + } + + const isAnswered = (qi: number): boolean => { + const sel = selectionFor(qi) + return sel.indices.length > 0 || (sel.other ?? '').length > 0 + } + + const total = prompt.questions.length + const isLast = index === total - 1 + const currentAnswered = useMemo( + () => isAnswered(index), + // eslint-disable-next-line react-hooks/exhaustive-deps + [selections, otherText, index] + ) + const allAnswered = useMemo( + () => prompt.questions.every((_, i) => isAnswered(i)), + // eslint-disable-next-line react-hooks/exhaustive-deps + [otherText, prompt.questions, selections] + ) + const canAdvance = !submitting && (isLast ? allAnswered : currentAnswered) + + const submit = async (): Promise => { + if (!allAnswered || submittingRef.current) { + return + } + submittingRef.current = true + setSubmitting(true) + try { + await onAnswer(prompt.questions.map((_, i) => selectionFor(i))) + } finally { + submittingRef.current = false + setSubmitting(false) + } + } + + const advance = async (): Promise => { + if (isLast) { + await submit() + } else { + setIndex((i) => Math.min(i + 1, total - 1)) + } + } + + const q = prompt.questions[index]! + const otherSelected = (selections[index] ?? []).includes(OTHER) + + return ( + + {total > 1 ? ( + + {prompt.questions.map((qq, i) => ( + setIndex(i)} + > + + {qq.header || `Step ${i + 1}`} + + {isAnswered(i) ? ( + + ) : null} + + ))} + + ) : null} + + + {q.question} + {q.options.map((opt, optIndex) => ( + toggle(index, optIndex, q.multiSelect)} + /> + ))} + toggle(index, OTHER, q.multiSelect)} + /> + {otherSelected ? ( + setOther(index, v)} + placeholder="Type your answer" + placeholderTextColor={colors.textMuted} + multiline + autoFocus + /> + ) : null} + + + + { + if (!submittingRef.current && onCancel) { + submittingRef.current = true + setSubmitting(true) + try { + await onCancel() + } finally { + submittingRef.current = false + setSubmitting(false) + } + } + }} + disabled={submitting} + hitSlop={8} + > + Cancel + + {total > 1 ? ( + + {index + 1}/{total} + + ) : null} + + + {isLast ? 'Send answer' : 'Next'} + + + + + ) +} + +function OptionRow({ + label, + description, + selected, + multi, + onPress +}: { + label: string + description?: string + selected: boolean + multi?: boolean + onPress: () => void +}): React.JSX.Element { + return ( + + {/* Multi-select reads as a checkbox (square); single-select as a radio (circle). */} + + {selected ? : null} + + + {label} + {description ? ( + + {description} + + ) : null} + + + ) +} + +const styles = StyleSheet.create({ + card: { + maxHeight: 380, + backgroundColor: colors.bgPanel, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.borderSubtle + }, + tabs: { + flexGrow: 0, + paddingTop: spacing.sm, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.borderSubtle + }, + tabsContent: { + paddingHorizontal: spacing.sm, + gap: spacing.xs, + alignItems: 'center' + }, + tab: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + minHeight: 36, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + borderBottomWidth: 2, + borderBottomColor: 'transparent' + }, + tabActive: { + borderBottomColor: colors.statusGreen + }, + tabText: { + color: colors.textSecondary, + fontSize: typography.metaSize, + fontWeight: '600' + }, + tabTextActive: { + color: colors.textPrimary + }, + scroll: { + paddingHorizontal: spacing.md + }, + questionText: { + color: colors.textPrimary, + fontSize: typography.bodySize + 1, + fontWeight: '600', + marginVertical: spacing.sm + }, + option: { + flexDirection: 'row', + gap: spacing.sm, + padding: spacing.sm, + borderRadius: radii.card, + backgroundColor: colors.bgRaised, + borderWidth: 1, + borderColor: colors.borderSubtle, + marginBottom: spacing.xs + }, + optionSelected: { + borderColor: colors.statusGreen + }, + check: { + width: 18, + height: 18, + borderWidth: 1.5, + borderColor: colors.textMuted, + alignItems: 'center', + justifyContent: 'center', + marginTop: 1 + }, + checkCircle: { + borderRadius: 9 + }, + checkSquare: { + borderRadius: 4 + }, + checkOn: { + backgroundColor: colors.statusGreen, + borderColor: colors.statusGreen + }, + optionBody: { + flex: 1, + gap: 2 + }, + optionLabel: { + color: colors.textPrimary, + fontSize: typography.bodySize, + fontWeight: '600' + }, + optionDescription: { + color: colors.textSecondary, + fontSize: typography.metaSize + }, + input: { + backgroundColor: colors.bgRaised, + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.card, + color: colors.textPrimary, + fontSize: typography.bodySize, + padding: spacing.sm, + minHeight: 44, + marginBottom: spacing.xs + }, + footer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + padding: spacing.md, + gap: spacing.sm, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.borderSubtle + }, + cancel: { + paddingVertical: spacing.sm, + paddingHorizontal: spacing.sm + }, + cancelText: { + color: colors.textSecondary, + fontSize: typography.bodySize, + fontWeight: '600' + }, + progress: { + color: colors.textMuted, + fontSize: typography.metaSize + }, + next: { + paddingVertical: spacing.sm, + paddingHorizontal: spacing.lg, + borderRadius: radii.button, + backgroundColor: colors.textPrimary + }, + nextDisabled: { + backgroundColor: colors.bgRaised + }, + nextText: { + color: colors.bgBase, + fontSize: typography.bodySize, + fontWeight: '700' + }, + nextTextDisabled: { + color: colors.textMuted + } +}) diff --git a/mobile/src/session/MobileNativeChatComposer.test.ts b/mobile/src/session/MobileNativeChatComposer.test.ts new file mode 100644 index 000000000000..ef2c10126034 --- /dev/null +++ b/mobile/src/session/MobileNativeChatComposer.test.ts @@ -0,0 +1,206 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MobileNativeChatComposer } from './MobileNativeChatComposer' + +vi.mock('react-native', async () => { + const React = await import('react') + return { + ActivityIndicator: 'ActivityIndicator', + Pressable: 'Pressable', + ScrollView: ({ children, ...props }: { children?: unknown }) => + React.createElement('ScrollView', props, children), + StyleSheet: { + create: (styles: unknown) => styles, + hairlineWidth: 1 + }, + Text: 'Text', + TextInput: 'TextInput', + View: 'View' + } +}) + +vi.mock('lucide-react-native', () => ({ + ArrowUp: 'ArrowUp', + ImagePlus: 'ImagePlus', + Mic: 'Mic', + Square: 'Square' +})) + +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 + }) + + async function render( + onSend: (text: string) => Promise, + onChangeText: () => void, + isAttaching = false + ) { + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create( + createElement(MobileNativeChatComposer, { + value: ' hello ', + onChangeText, + onSend, + isAttaching + }) + ) + }) + } finally { + restore() + } + } + + function sendButton(): { props: { onPress: () => Promise } } { + if (!renderer) { + throw new Error('Composer was not rendered') + } + return renderer.root.find( + (node) => node.type === 'Pressable' && node.props.accessibilityLabel === 'Send message' + ) as { props: { onPress: () => Promise } } + } + + it('reports an accepted send without owning route-scoped draft cleanup', async () => { + const onChangeText = vi.fn() + const onSend = vi.fn().mockResolvedValue(true) + await render(onSend, onChangeText) + + await act(async () => sendButton().props.onPress()) + + expect(onSend).toHaveBeenCalledWith('hello') + expect(onChangeText).not.toHaveBeenCalled() + }) + + it('keeps the draft when the send is rejected', async () => { + const onChangeText = vi.fn() + const onSend = vi.fn().mockResolvedValue(false) + await render(onSend, onChangeText) + + await act(async () => sendButton().props.onPress()) + + expect(onSend).toHaveBeenCalledWith('hello') + expect(onChangeText).not.toHaveBeenCalled() + }) + + it('disables send while an attachment path is still being injected', async () => { + const onSend = vi.fn().mockResolvedValue(true) + await render(onSend, vi.fn(), true) + + expect(sendButton().props).toMatchObject({ disabled: true }) + await act(async () => sendButton().props.onPress()) + expect(onSend).not.toHaveBeenCalled() + }) + + 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) + }) + ) + }) + } finally { + restore() + } + const input = () => + renderer!.root.find((node) => node.type === 'TextInput') as { + props: { + selection?: { start: number; end: number } + onSelectionChange: (e: { nativeEvent: { selection: { end: number } } }) => void + } + } + // Uncontrolled selection until a suggestion is applied. + expect(input().props.selection).toBeUndefined() + // Place the caret at the end so the slash trigger is active and suggestions render. + await act(async () => + input().props.onSelectionChange({ nativeEvent: { selection: { end: 2 } } }) + ) + const firstSuggestion = renderer!.root.findAll( + (node) => node.type === 'Pressable' && !node.props.accessibilityLabel + )[0] as { props: { onPress: () => void } } + await act(async () => firstSuggestion.props.onPress()) + // `/clear ` is 7 chars — the caret jumps just past the inserted command + space. + expect(input().props.selection).toEqual({ start: 7, end: 7 }) + // The next native selection event releases control so manual placement still works. + await act(async () => + input().props.onSelectionChange({ nativeEvent: { selection: { end: 7 } } }) + ) + expect(input().props.selection).toBeUndefined() + }) + + it('wires the mic for hold vs toggle dictation like the terminal composer', async () => { + const onMicPress = vi.fn() + const onMicPressIn = vi.fn() + const onMicPressOut = vi.fn() + const mic = () => + renderer!.root.find( + (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!.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() + } + }) +}) diff --git a/mobile/src/session/MobileNativeChatComposer.tsx b/mobile/src/session/MobileNativeChatComposer.tsx new file mode 100644 index 000000000000..ba02fe3d44bb --- /dev/null +++ b/mobile/src/session/MobileNativeChatComposer.tsx @@ -0,0 +1,285 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { + ActivityIndicator, + Pressable, + ScrollView, + StyleSheet, + Text, + TextInput, + View +} from 'react-native' +import { ArrowUp, ImagePlus, Mic, Square } from 'lucide-react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import { + applyAutocomplete, + detectAutocompleteTrigger, + rankSuggestions +} from './mobile-native-chat-autocomplete' + +// Common agent slash commands offered as autocomplete; sending them is just text +// to the agent's terminal, so the set is intentionally provider-agnostic. +const SLASH_COMMANDS = [ + '/clear', + '/compact', + '/review', + '/model', + '/help', + '/init', + '/cost', + '/diff' +] + +const NO_FILE_PATHS: string[] = [] + +type Props = { + /** Controlled composer text — owned by the parent so dictation can write to it. */ + value: string + onChangeText: (text: string) => void + onSend: (text: string) => Promise + onAttachImage?: () => void + isAttaching?: boolean + onMicPress?: () => void + micActive?: boolean + /** Dictation trigger style — 'hold' uses press-in/out, 'toggle' uses tap. */ + dictationMode?: 'toggle' | 'hold' + onMicPressIn?: () => void + onMicPressOut?: () => void + disabled?: boolean + placeholder?: string + filePaths?: string[] + onNeedFiles?: (query: string) => void +} + +export function MobileNativeChatComposer({ + value, + onChangeText, + onSend, + onAttachImage, + isAttaching = false, + onMicPress, + micActive = false, + dictationMode = 'toggle', + onMicPressIn, + onMicPressOut, + disabled = false, + placeholder = 'Message, @files, /commands', + filePaths = NO_FILE_PATHS, + onNeedFiles +}: Props): React.JSX.Element { + const [cursor, setCursor] = useState(0) + // Transiently drives the native caret after a mid-text autocomplete insert, + // then released on the next selection change so manual caret placement still + // works (a permanently controlled `selection` breaks it in React Native). + const [pendingSelection, setPendingSelection] = useState<{ start: number; end: number } | null>( + null + ) + const sendingRef = useRef(false) + const [sending, setSending] = useState(false) + const trimmed = value.trim() + const canSend = trimmed.length > 0 && !disabled && !sending && !isAttaching + + const trigger = useMemo(() => detectAutocompleteTrigger(value, cursor), [value, cursor]) + const suggestions = useMemo(() => { + if (!trigger) { + return [] + } + if (trigger.kind === 'slash') { + return rankSuggestions(SLASH_COMMANDS, trigger.query) + } + return rankSuggestions(filePaths, trigger.query).map((p) => `@${p}`) + }, [trigger, filePaths]) + + useEffect(() => { + if (trigger?.kind === 'file') { + onNeedFiles?.(trigger.query) + } + }, [onNeedFiles, trigger?.kind, trigger?.query]) + + const handleChange = (next: string): void => { + onChangeText(next) + } + + const pickSuggestion = (suggestion: string): void => { + if (!trigger) { + return + } + const { text: nextText, cursor: nextCursor } = applyAutocomplete(value, trigger, suggestion) + onChangeText(nextText) + setCursor(nextCursor) + setPendingSelection({ start: nextCursor, end: nextCursor }) + } + + const handleSend = async (): Promise => { + if (!canSend || sendingRef.current) { + return + } + sendingRef.current = true + setSending(true) + try { + const accepted = await onSend(trimmed) + if (accepted) { + setCursor(0) + } + } finally { + sendingRef.current = false + setSending(false) + } + } + + return ( + + {suggestions.length > 0 ? ( + + + {suggestions.map((s) => ( + [styles.suggestion, pressed && styles.suggestionPressed]} + onPress={() => pickSuggestion(s)} + > + + {s} + + + ))} + + + ) : null} + + {onAttachImage ? ( + [styles.iconButton, pressed && styles.pressed]} + onPress={onAttachImage} + disabled={isAttaching || disabled} + > + {isAttaching ? ( + + ) : ( + + )} + + ) : null} + { + setCursor(e.nativeEvent.selection.end) + setPendingSelection(null) + }} + placeholder={placeholder} + placeholderTextColor={colors.textMuted} + selectionColor={colors.accentBlue} + multiline + editable={!disabled} + textAlignVertical="top" + /> + {onMicPress ? ( + [styles.iconButton, pressed && styles.pressed]} + // Hold mode is walkie-talkie (press-in/out); toggle mode taps. + onPress={dictationMode === 'hold' ? undefined : onMicPress} + onPressIn={dictationMode === 'hold' ? onMicPressIn : undefined} + onPressOut={dictationMode === 'hold' ? onMicPressOut : undefined} + disabled={disabled} + > + {micActive ? ( + + ) : ( + + )} + + ) : null} + [ + styles.sendButton, + !canSend && styles.sendButtonDisabled, + pressed && canSend && styles.pressed + ]} + onPress={handleSend} + disabled={!canSend} + > + + + + + ) +} + +const styles = StyleSheet.create({ + suggestions: { + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.borderSubtle, + backgroundColor: colors.bgPanel + }, + suggestionScroll: { + maxHeight: 180 + }, + suggestion: { + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.borderSubtle + }, + suggestionPressed: { + backgroundColor: colors.bgRaised + }, + suggestionText: { + color: colors.textPrimary, + fontFamily: typography.monoFamily, + fontSize: typography.metaSize + }, + bar: { + flexDirection: 'row', + alignItems: 'flex-end', + gap: spacing.sm, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.borderSubtle, + backgroundColor: colors.bgPanel + }, + input: { + flex: 1, + maxHeight: 140, + minHeight: 40, + color: colors.textPrimary, + fontSize: typography.bodySize + 1, + backgroundColor: colors.bgRaised, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingTop: spacing.sm, + paddingBottom: spacing.sm + }, + iconButton: { + width: 40, + height: 40, + alignItems: 'center', + justifyContent: 'center' + }, + sendButton: { + width: 40, + height: 40, + borderRadius: 20, + alignItems: 'center', + justifyContent: 'center', + // White send affordance per design — dark arrow on a light circle. + backgroundColor: colors.textPrimary + }, + sendButtonDisabled: { + backgroundColor: colors.bgRaised + }, + pressed: { + opacity: 0.7 + } +}) diff --git a/mobile/src/session/MobileNativeChatMessage.tsx b/mobile/src/session/MobileNativeChatMessage.tsx new file mode 100644 index 000000000000..eadfd2043f84 --- /dev/null +++ b/mobile/src/session/MobileNativeChatMessage.tsx @@ -0,0 +1,371 @@ +import { memo, useEffect, useRef, useState } from 'react' +import { 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 { MAX_TOOL_RESULT_CHARS, styles, TEXT_SIZE } from './mobile-native-chat-message-styles' +import { nativeChatMessageText } from './mobile-native-chat-message-text' +import { + summarizeToolInput, + summarizeToolRun, + toolFilePath +} from './mobile-native-chat-tool-summary' + +const MAX_VISIBLE_TOOL_PAIRS = 6 +const MAX_TOOL_RUN_DIFF_ROWS = 240 + +function DiffView({ lines }: { lines: DiffLine[] }): React.JSX.Element { + return ( + + {lines.map((line, i) => ( + + {line.kind === 'add' ? '+' : line.kind === 'del' ? '-' : ' '} + {line.text} + + ))} + + ) +} + +/** A single inline tool line — `▸ ToolName preview` — that expands in place to + * show the call's diff/input or the result's body. Mirrors the reference design + * where tool calls read as flat lines in the conversation, not boxed blocks. */ +function ResultBody({ + output, + isError, + diff +}: { + output: string + isError?: boolean + diff: DiffLine[] | null +}): React.JSX.Element { + if (diff) { + return + } + return ( + + + {output.length > MAX_TOOL_RESULT_CHARS + ? `${output.slice(0, MAX_TOOL_RESULT_CHARS)}…` + : output} + + + ) +} + +/** One request: a tool call and its result rendered together as a single + * expandable line. `defaultExpanded` lets the group toggle open every line. */ +function ToolLine({ + pair, + defaultExpanded, + diffLineLimit, + onOpenFile +}: { + pair: ToolPair + defaultExpanded: boolean + diffLineLimit: number + onOpenFile?: (relativePath: string) => void +}): React.JSX.Element { + const [expanded, setExpanded] = useState(defaultExpanded) + const { call, result } = pair + const name = call ? call.name : 'Result' + const preview = call + ? summarizeToolInput(call.input) + : (result?.output.split('\n')[0]?.slice(0, 80) ?? '') + // Why: collapsed tool rows are the common path; defer bounded diff parsing + // until the user asks to reveal the detail. + const callDiff = expanded && call ? diffFromToolCall(call.name, call.input, diffLineLimit) : null + const resultDiff = expanded && result ? diffFromText(result.output, diffLineLimit) : null + const hasDetail = callDiff !== null || result !== undefined || preview.length > 40 + // A tool that targets a file (Read/Edit/Write…) renders its preview as a + // tappable link that opens the file, independent of the line's expand tap. + const filePath = call ? toolFilePath(call.input) : null + const openable = filePath !== null && onOpenFile !== undefined + return ( + + hasDetail && setExpanded((v) => !v)} + hitSlop={6} + > + {expanded ? ( + + ) : ( + + )} + {name} + {preview ? ( + onOpenFile!(filePath!) : undefined} + suppressHighlighting={!openable} + > + {preview} + + ) : null} + + {expanded ? ( + + {callDiff ? : null} + {!callDiff && call && preview ? {preview} : null} + {result ? ( + + ) : null} + + ) : null} + + ) +} + +function Prose({ + block, + invert, + fontScale, + onOpenFile +}: { + block: NativeChatBlock + invert?: boolean + fontScale: number + onOpenFile?: (relativePath: string) => void +}): React.JSX.Element | null { + if (isTextBlock(block)) { + // Inverted (user) bubbles use a fixed dark-on-light text rather than the + // markdown renderer's light-on-dark palette. + if (invert) { + return ( + {block.text} + ) + } + return ( + + ) + } + if (isImageRefBlock(block)) { + return ( + + 🖼 {block.alt ?? block.path ?? block.url ?? 'image'} + + ) + } + return null +} + +/** A run of a message's tool calls/results, collapsed to a one-line summary that + * expands to the individual inline tool lines. `defaultExpanded` lets the global + * toolbar toggle drive every run at once while still allowing per-run override. */ +function ToolRun({ + blocks, + defaultExpanded, + trailing, + onOpenFile +}: { + blocks: NativeChatBlock[] + defaultExpanded: boolean + trailing?: React.ReactNode + onOpenFile?: (relativePath: string) => void +}): React.JSX.Element { + const [open, setOpen] = useState(defaultExpanded) + const pairs = pairToolBlocks(blocks, MAX_VISIBLE_TOOL_PAIRS) + const diffLineLimit = Math.max(1, Math.floor(MAX_TOOL_RUN_DIFF_ROWS / (pairs.length * 2 || 1))) + let callCount = 0 + for (const block of blocks) { + if (block.type === 'tool-call') { + callCount++ + } + } + callCount ||= pairs.length + const summary = summarizeToolRun(blocks) + return ( + + + setOpen((v) => !v)} hitSlop={6}> + {open ? ( + + ) : ( + + )} + {callCount}× + + {summary || `${callCount} tool ${callCount === 1 ? 'call' : 'calls'}`} + + + {trailing} + + {open ? ( + + {pairs.map((pair, i) => ( + + ))} + {callCount > pairs.length ? ( + … {callCount - pairs.length} more tool calls + ) : null} + + ) : null} + + ) +} + +/** Subtle top-right controls for an agent message: copy its prose, or scroll so + * this message's top aligns to the top of the viewport. */ +function AgentControls({ + onCopy, + onScrollToTop +}: { + onCopy: () => void + onScrollToTop?: () => void +}): React.JSX.Element { + return ( + + [styles.controlButton, pressed && styles.controlPressed]} + onPress={onCopy} + hitSlop={8} + accessibilityLabel="Copy message" + > + + + {onScrollToTop ? ( + [styles.controlButton, pressed && styles.controlPressed]} + onPress={onScrollToTop} + hitSlop={8} + accessibilityLabel="Scroll this message to top" + > + + + ) : null} + + ) +} + +function MobileNativeChatMessageImpl({ + message, + queued, + toolsExpanded = false, + fontScale = 1, + messageIndex, + onScrollToMessage, + onOpenFile +}: { + message: NativeChatMessage + queued?: boolean + toolsExpanded?: boolean + /** Multiplies all chat text sizes for pinch-to-zoom (1 = no change). */ + fontScale?: number + /** This message's index in the list, paired with onScrollToMessage. */ + messageIndex?: number + /** Ask the list to align this message's top to the top of the viewport. */ + onScrollToMessage?: (index: number) => void + onOpenFile?: (relativePath: string) => void +}): React.JSX.Element { + const isUser = message.role === 'user' + const isReasoning = message.role === 'reasoning' + const isAgent = !isUser + // Briefly tint the bubble to confirm a copy landed. + const [copied, setCopied] = useState(false) + const copyTimer = useRef | null>(null) + useEffect( + () => () => { + if (copyTimer.current) { + clearTimeout(copyTimer.current) + } + }, + [] + ) + // Separate the agent's words from its tool activity: prose renders first, the + // tool calls fold into a collapsible run beneath. The user's own messages get + // an inverted (filled accent) bubble so they stand apart from agent prose. + const { prose, tools } = splitNativeChatBlocks(message.blocks) + + const handleCopy = (): void => { + const text = nativeChatMessageText(message.blocks) + if (!text) { + return + } + void Clipboard.setStringAsync(text) + setCopied(true) + if (copyTimer.current) { + clearTimeout(copyTimer.current) + } + copyTimer.current = setTimeout(() => setCopied(false), 700) + } + + // Copy + scroll-to-top, shown inline with the first tool call (or after the + // prose when there are no tools). + const controls = + isAgent && !queued ? ( + onScrollToMessage(messageIndex) + : undefined + } + /> + ) : null + + return ( + + {isUser && queued ? Queued : null} + + {prose.map((block, index) => ( + + ))} + {tools.length > 0 ? ( + + ) : controls ? ( + {controls} + ) : null} + + + ) +} + +export const MobileNativeChatMessage = memo(MobileNativeChatMessageImpl) diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx new file mode 100644 index 000000000000..6acf2d305f6f --- /dev/null +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -0,0 +1,79 @@ +import { StyleSheet, View } from 'react-native' +import { MobileNativeChatView, type MobileNativeChatInputLockReason } from './MobileNativeChatView' +import type { MobileNativeChatController } from './use-mobile-native-chat-controller' + +type Props = { + controller: MobileNativeChatController + onAttachImage: () => void + isAttaching: boolean + onMicPress: () => void + micActive: boolean + dictationMode: 'toggle' | 'hold' + onMicPressIn: () => void + onMicPressOut: () => void + inputLockReason: MobileNativeChatInputLockReason | null + keyboardInset: number +} + +/** Keeps the terminal mounted underneath chat so its PTY subscription survives + * view toggles while the native surface owns the visible composer. */ +export function MobileNativeChatOverlay({ + controller, + onAttachImage, + isAttaching, + onMicPress, + micActive, + dictationMode, + onMicPressIn, + onMicPressOut, + inputLockReason, + keyboardInset +}: Props): React.JSX.Element | null { + if (!controller.showNativeChat) { + return null + } + const session = controller.nativeChatSession + return ( + + + + ) +} + +const styles = StyleSheet.create({ + overlay: StyleSheet.absoluteFillObject +}) diff --git a/mobile/src/session/MobileNativeChatPermission.test.ts b/mobile/src/session/MobileNativeChatPermission.test.ts new file mode 100644 index 000000000000..8e0223f19ec4 --- /dev/null +++ b/mobile/src/session/MobileNativeChatPermission.test.ts @@ -0,0 +1,60 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MobileNativeChatPermission } from './MobileNativeChatPermission' + +vi.mock('react-native', () => ({ + Pressable: 'Pressable', + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, + Text: 'Text', + View: 'View' +})) + +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 + }) + + it('accepts only one response when two presses land in the same render batch', async () => { + 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) + }) + 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(() => { + button.props.onPress() + button.props.onPress() + }) + + expect(onRespond).toHaveBeenCalledOnce() + await act(async () => resolveResponse(true)) + }) +}) diff --git a/mobile/src/session/MobileNativeChatPermission.tsx b/mobile/src/session/MobileNativeChatPermission.tsx new file mode 100644 index 000000000000..ad26d578d931 --- /dev/null +++ b/mobile/src/session/MobileNativeChatPermission.tsx @@ -0,0 +1,123 @@ +import { memo, useRef, useState } from 'react' +import { Pressable, StyleSheet, Text, View } from 'react-native' +import { ShieldQuestion } from 'lucide-react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import type { MobileChatPermission } from './mobile-native-chat-permission' + +// Renders a detected agent permission ask as a card with tappable options. +// The first option is treated as the primary (allow) action and gets a filled +// accent button so the affirmative choice reads as distinct from the rest. +function MobileNativeChatPermissionImpl({ + permission, + onRespond +}: { + permission: MobileChatPermission + onRespond: (send: string) => Promise +}): React.JSX.Element { + const [submitting, setSubmitting] = useState(false) + const submittingRef = useRef(false) + const respond = async (send: string): Promise => { + if (submittingRef.current) { + return + } + submittingRef.current = true + setSubmitting(true) + const accepted = await onRespond(send) + if (!accepted) { + submittingRef.current = false + setSubmitting(false) + } + } + return ( + + + + {permission.title} + + {permission.detail ? {permission.detail} : null} + + {permission.options.map((option, index) => { + const isPrimary = index === 0 + return ( + [ + styles.option, + isPrimary ? styles.optionPrimary : styles.optionSecondary, + pressed && !submitting && styles.optionPressed + ]} + hitSlop={6} + onPress={() => respond(option.send)} + disabled={submitting} + > + + {option.label} + + + ) + })} + + + ) +} + +export const MobileNativeChatPermission = memo(MobileNativeChatPermissionImpl) + +const styles = StyleSheet.create({ + card: { + marginHorizontal: spacing.lg, + marginVertical: spacing.sm, + padding: spacing.md, + gap: spacing.sm, + borderRadius: radii.card, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.borderSubtle, + backgroundColor: colors.bgPanel + }, + header: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + }, + title: { + color: colors.textPrimary, + fontSize: typography.bodySize, + fontWeight: '600' + }, + detail: { + color: colors.textSecondary, + fontSize: typography.metaSize, + lineHeight: typography.metaSize + 5 + }, + options: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.sm + }, + option: { + minHeight: 44, + justifyContent: 'center', + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: radii.button + }, + optionPrimary: { + backgroundColor: colors.accentBlue + }, + optionSecondary: { + backgroundColor: colors.bgRaised, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.borderSubtle + }, + optionPressed: { + opacity: 0.7 + }, + optionText: { + color: colors.textPrimary, + fontSize: typography.bodySize, + fontWeight: '600' + }, + optionTextPrimary: { + color: colors.onAccent + } +}) diff --git a/mobile/src/session/MobileNativeChatQuestion.tsx b/mobile/src/session/MobileNativeChatQuestion.tsx new file mode 100644 index 000000000000..f470214dbedd --- /dev/null +++ b/mobile/src/session/MobileNativeChatQuestion.tsx @@ -0,0 +1,268 @@ +import { useMemo, useRef, useState } from 'react' +import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native' +import { ArrowUp, Check, CircleHelp } from 'lucide-react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import { formatQuestionAnswer, type MobileChatQuestion } from './mobile-native-chat-question' + +type Props = { + question: MobileChatQuestion + onAnswer: (text: string) => Promise +} + +/** Renders an agent's choice prompt as a tappable card. Single-select answers + * on tap; multi-select toggles then Submits; an always-present text entry lets + * the user answer freely (the escape hatch) when the heuristic misreads the + * options or none apply. */ +export function MobileNativeChatQuestion({ question, onAnswer }: Props): React.JSX.Element { + const [selected, setSelected] = useState([]) + const [freeText, setFreeText] = useState('') + const [sending, setSending] = useState(false) + const sendingRef = useRef(false) + + const hasOptions = question.options.length > 0 + const trimmedFreeText = freeText.trim() + + const toggle = (option: string): void => { + setSelected((prev) => + prev.includes(option) ? prev.filter((o) => o !== option) : [...prev, option] + ) + } + + const sendAnswer = async (text: string): Promise => { + if (sendingRef.current) { + return false + } + sendingRef.current = true + setSending(true) + try { + return await onAnswer(text) + } finally { + sendingRef.current = false + setSending(false) + } + } + + const answerSingle = async (option: string): Promise => { + await sendAnswer(formatQuestionAnswer(question, [option])) + } + + const submitMulti = async (): Promise => { + if (selected.length === 0) { + return + } + await sendAnswer(formatQuestionAnswer(question, selected)) + } + + const submitFreeText = async (): Promise => { + if (trimmedFreeText.length === 0) { + return + } + // Free text is an unknown entry; formatQuestionAnswer passes it through. + if (await sendAnswer(formatQuestionAnswer(question, [trimmedFreeText]))) { + setFreeText('') + } + } + + const canSubmitMulti = selected.length > 0 && !sending + const canSendFreeText = trimmedFreeText.length > 0 && !sending + + // Stable keys for option rows even if an agent repeats a label. + const optionRows = useMemo( + () => question.options.map((label, index) => ({ label, key: `${index}:${label}` })), + [question.options] + ) + + return ( + + + + {question.question} + + + {hasOptions ? ( + + {optionRows.map(({ label, key }) => { + const isSelected = selected.includes(label) + return ( + [ + styles.option, + isSelected && styles.optionSelected, + pressed && styles.pressed + ]} + onPress={() => (question.multiSelect ? toggle(label) : answerSingle(label))} + > + {question.multiSelect ? ( + + {isSelected ? : null} + + ) : null} + {label} + + ) + })} + + ) : null} + + {question.multiSelect && hasOptions ? ( + [ + styles.submit, + !canSubmitMulti && styles.submitDisabled, + pressed && canSubmitMulti && styles.pressed + ]} + onPress={submitMulti} + disabled={!canSubmitMulti} + > + + Submit{selected.length > 0 ? ` (${selected.length})` : ''} + + + ) : null} + + + + [ + styles.freeSend, + !canSendFreeText && styles.freeSendDisabled, + pressed && canSendFreeText && styles.pressed + ]} + onPress={submitFreeText} + disabled={!canSendFreeText} + > + + + + + ) +} + +const styles = StyleSheet.create({ + card: { + marginHorizontal: spacing.lg, + marginVertical: spacing.sm, + padding: spacing.md, + gap: spacing.sm, + backgroundColor: colors.bgPanel, + borderRadius: radii.card, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.borderSubtle + }, + header: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + }, + question: { + flex: 1, + color: colors.textPrimary, + fontSize: typography.bodySize + 1, + fontWeight: '600', + lineHeight: typography.bodySize + 7 + }, + options: { + gap: spacing.xs + }, + option: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + minHeight: 44, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + backgroundColor: colors.bgRaised, + borderRadius: radii.button, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.borderSubtle + }, + optionSelected: { + borderColor: colors.accentBlue + }, + optionText: { + flex: 1, + color: colors.textPrimary, + fontSize: typography.bodySize + 1 + }, + checkbox: { + width: 20, + height: 20, + borderRadius: radii.button, + borderWidth: 1.5, + borderColor: colors.textMuted, + alignItems: 'center', + justifyContent: 'center' + }, + checkboxOn: { + backgroundColor: colors.accentBlue, + borderColor: colors.accentBlue + }, + submit: { + minHeight: 44, + alignItems: 'center', + justifyContent: 'center', + borderRadius: radii.button, + backgroundColor: colors.accentBlue + }, + submitDisabled: { + backgroundColor: colors.bgRaised + }, + submitText: { + color: colors.onMergeGreen, + fontSize: typography.bodySize + 1, + fontWeight: '600' + }, + submitTextDisabled: { + color: colors.textMuted + }, + freeTextRow: { + flexDirection: 'row', + alignItems: 'flex-end', + gap: spacing.sm + }, + freeInput: { + flex: 1, + minHeight: 40, + maxHeight: 120, + color: colors.textPrimary, + fontSize: typography.bodySize + 1, + backgroundColor: colors.bgRaised, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingTop: spacing.sm, + paddingBottom: spacing.sm + }, + freeSend: { + width: 40, + height: 40, + borderRadius: 20, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.textPrimary + }, + freeSendDisabled: { + backgroundColor: colors.bgRaised + }, + pressed: { + opacity: 0.7 + } +}) diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx new file mode 100644 index 000000000000..1cfe37e5d4d8 --- /dev/null +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -0,0 +1,425 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + ActivityIndicator, + FlatList, + type NativeScrollEvent, + type NativeSyntheticEvent, + Pressable, + Text, + View +} from 'react-native' +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 { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { colors } from '../theme/mobile-theme' +import { styles } from './mobile-native-chat-view-styles' +import { + buildMobileNativeChatTransientData, + foldMobileNativeChatMessages, + mobileNativeChatEmptyState +} from './mobile-native-chat-render-data' +import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss' +import { useMobileNativeChatPinchGesture } from './use-mobile-native-chat-pinch-gesture' +import { MobileAgentWorkingIndicator } from './MobileAgentWorkingIndicator' +import { MobileNativeChatComposer } from './MobileNativeChatComposer' +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' +import { mobileChatQuestionKey, type MobileChatQuestion } from './mobile-native-chat-question' +import type { MobileNativeChatStatus } from './use-mobile-native-chat-session' + +/** Why the composer input is locked: the transport is disconnected, or the + * terminal subscription has not acknowledged its input lease yet. */ +export type MobileNativeChatInputLockReason = 'disconnected' | 'waiting' + +type Props = { + messages: NativeChatMessage[] + status: MobileNativeChatStatus + error?: string + /** Resolved agent for this chat; names the empty-state copy (desktop parity). */ + agent?: string | null + agentWorking?: boolean + /** Interrupt the agent mid-turn (shown as a Stop button on the working bar). */ + onStop?: () => void + /** Live partial assistant text while a turn is still streaming (from the agent + * status hook). Shown as an in-progress bubble until the transcript catches up. */ + streamingText?: string + hasMore?: boolean + loadingEarlier?: boolean + onLoadEarlier?: () => void + onSend: (text: string) => Promise + /** Optimistic queued sends (owned by the route so they survive view switches). */ + pending: Array<{ id: string; text: string }> + /** Controlled composer text (owned by the route so dictation can write to it). */ + composerText: string + onComposerTextChange: (text: string) => void + onAttachImage?: () => void + isAttaching?: boolean + onMicPress?: () => void + micActive?: boolean + dictationMode?: 'toggle' | 'hold' + onMicPressIn?: () => void + onMicPressOut?: () => void + inputLockReason?: MobileNativeChatInputLockReason | null + filePaths?: string[] + onNeedFiles?: (query: string) => void + /** A pending agent question/permission detected from live status, shown as a + * native card above the composer; answering sends text to the agent. */ + /** Structured AskUserQuestion prompt parsed from the transcript (preferred over + * the heuristic question card). */ + ask?: AskPrompt | null + /** Deliver the ask answer as per-question selections; the send hook turns them + * into selector keystrokes (Claude) or pasted label text (other agents). */ + onAnswerAsk?: (prompt: AskPrompt, selections: AskAnswerSelection[]) => Promise + onCancelAsk?: () => Promise + question?: MobileChatQuestion | null + onAnswerQuestion?: (text: string) => Promise + permission?: MobileChatPermission | null + onRespondPermission?: (send: string) => Promise + /** Open a worktree file tapped in agent markdown. */ + onOpenFile?: (relativePath: string) => void + /** Pixels to lift the composer by when the soft keyboard is open. The route + * owns keyboard tracking (the app uses manual lift, not KeyboardAvoidingView). */ + keyboardInset?: number +} + +export function MobileNativeChatView({ + messages, + status, + error, + agent, + agentWorking, + onStop, + streamingText, + hasMore, + loadingEarlier, + onLoadEarlier, + onSend, + pending, + composerText, + onComposerTextChange, + onAttachImage, + isAttaching, + onMicPress, + micActive, + dictationMode, + onMicPressIn, + onMicPressOut, + inputLockReason, + filePaths, + onNeedFiles, + ask, + onAnswerAsk, + onCancelAsk, + question, + onAnswerQuestion, + permission, + onRespondPermission, + onOpenFile, + keyboardInset = 0 +}: Props): React.JSX.Element { + const insets = useSafeAreaInsets() + const listRef = useRef>(null) + const [toolsExpanded, setToolsExpanded] = useState(false) + // Dismiss the question card as soon as it's answered; the live status lingers + // briefly (the agent emits a post-tool event with the same prompt), so hide it + // until a genuinely different question arrives. + const { askKey, showAsk, dismissAsk } = useMobileNativeChatAskDismiss(ask) + // Lift the composer clear of the keyboard, plus the bottom safe-area so it + // never sits under the home indicator / nav bar (mirrors the terminal dock). + const bottomPad = keyboardInset > 0 ? keyboardInset + insets.bottom : insets.bottom + const [atBottom, setAtBottom] = useState(true) + const sendScrollTimerRef = useRef | null>(null) + const { fontScale, pinchGesture } = useMobileNativeChatPinchGesture() + // Surface a rejected send inline above the composer — a bottom toast gets hidden + // behind the keyboard (the case that prompted this). Auto-dismisses after a beat. + const [sendFailed, setSendFailed] = useState(false) + useEffect(() => { + if (!sendFailed) { + return + } + const t = setTimeout(() => setSendFailed(false), 4000) + return () => clearTimeout(t) + }, [sendFailed]) + + useEffect( + () => () => { + if (sendScrollTimerRef.current) { + clearTimeout(sendScrollTimerRef.current) + } + }, + [] + ) + + const pendingIds = useMemo(() => new Set(pending.map((p) => p.id)), [pending]) + // `data` is the list source: folded transcript + synthetic streaming bubble + + // route-owned optimistic queued messages. Memoize on the same deps so the + // downstream autoscroll effects/`renderItem` keep referential stability. + const foldedMessages = useMemo(() => foldMobileNativeChatMessages(messages), [messages]) + const { data } = useMemo( + () => buildMobileNativeChatTransientData({ folded: foldedMessages, streamingText, pending }), + [foldedMessages, streamingText, pending] + ) + + // Follow the tail as the conversation grows and keep the newest message above + // the keyboard when it opens — but only when already pinned to the bottom, so + // we don't yank the user away while they read history. (Also fires on keyboard + // close, which is harmless while atBottom.) + useEffect(() => { + if (data.length === 0 || !atBottom) { + return + } + const t = setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 60) + return () => clearTimeout(t) + }, [data.length, atBottom, keyboardInset]) + + const handleSend = useCallback( + async (text: string): Promise => { + const accepted = await onSend(text) + if (!accepted) { + setSendFailed(true) + return false + } + setSendFailed(false) + // Always jump to the newest message when the user sends. + setAtBottom(true) + if (sendScrollTimerRef.current) { + clearTimeout(sendScrollTimerRef.current) + } + sendScrollTimerRef.current = setTimeout(() => { + sendScrollTimerRef.current = null + listRef.current?.scrollToEnd({ animated: true }) + }, 60) + return true + }, + [onSend] + ) + + const onScroll = useCallback( + (e: NativeSyntheticEvent) => { + const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent + const distanceFromBottom = contentSize.height - (contentOffset.y + layoutMeasurement.height) + setAtBottom(distanceFromBottom < 80) + // Near the top — page in older history. + if (contentOffset.y < 60 && hasMore && !loadingEarlier) { + onLoadEarlier?.() + } + }, + [hasMore, loadingEarlier, onLoadEarlier] + ) + + // Align a single message's top to the top of the viewport. + const onScrollToMessage = useCallback((index: number) => { + listRef.current?.scrollToIndex({ index, viewPosition: 0, animated: true }) + }, []) + + const renderItem = useCallback( + ({ item, index }: { item: NativeChatMessage; index: number }) => ( + + ), + [pendingIds, toolsExpanded, fontScale, onScrollToMessage, onOpenFile] + ) + + const emptyState = mobileNativeChatEmptyState(status, agent ?? null, error) + const showLoading = status === 'loading' && messages.length === 0 + + // Composer-lock flicker guard: on a remote link, brief connState blips or lease + // hand-offs would otherwise toggle the lock placeholder on and off. Only surface + // a lock once it has held ~600ms; drop it instantly so unlocking stays snappy. + const rawLockReason = inputLockReason ?? null + const [lockHeld, setLockHeld] = useState(false) + useEffect(() => { + if (rawLockReason === null) { + setLockHeld(false) + return + } + const timer = setTimeout(() => setLockHeld(true), 600) + return () => clearTimeout(timer) + }, [rawLockReason]) + const lockReason = lockHeld ? rawLockReason : null + + return ( + + {showLoading ? ( + + + + ) : ( + + + item.id} + renderItem={renderItem} + contentContainerStyle={styles.listContent} + onScroll={onScroll} + scrollEventThrottle={32} + onContentSizeChange={() => { + if (data.length > 0 && atBottom) { + listRef.current?.scrollToEnd({ animated: false }) + } + }} + // scrollToIndex can fail before an off-screen row is measured — + // fall back to an estimated offset, then retry once it's laid out. + onScrollToIndexFailed={(info) => { + listRef.current?.scrollToOffset({ + offset: info.averageItemLength * info.index, + animated: true + }) + setTimeout(() => { + listRef.current?.scrollToIndex({ + index: info.index, + viewPosition: 0, + animated: true + }) + }, 120) + }} + ListHeaderComponent={ + hasMore ? ( + + {loadingEarlier ? ( + + ) : ( + Load earlier messages + )} + + ) : null + } + ListEmptyComponent={ + emptyState ? ( + + {emptyState.title} + {emptyState.subtitle} + + ) : null + } + /> + + {/* Jump-to-latest control. The scroll-to-top affordance now lives + per-message (the up-arrow in each agent message's controls). */} + {!atBottom ? ( + listRef.current?.scrollToEnd({ animated: true })} + > + + + ) : null} + + )} + {/* Pending agent prompt: a structured AskUserQuestion wins, then a + heuristic permission, then a heuristic question. */} + {showAsk && ask ? ( + { + const accepted = (await onAnswerAsk?.(ask, selections)) ?? false + if (accepted) { + dismissAsk() + } + return accepted + }} + onCancel={async () => { + const accepted = (await onCancelAsk?.()) ?? false + if (accepted) { + dismissAsk() + } + return accepted + }} + /> + ) : permission ? ( + (await onRespondPermission?.(send)) ?? false} + /> + ) : question ? ( + (await onAnswerQuestion?.(text)) ?? false} + /> + ) : null} + {/* Chrome row above the composer: the working indicator and the global + tool-calls expand/collapse toggle on the left, Stop in the far corner. */} + + + {agentWorking ? : null} + [styles.chromeToggle, pressed && styles.pressed]} + onPress={() => setToolsExpanded((v) => !v)} + hitSlop={8} + > + {toolsExpanded ? ( + + ) : ( + + )} + {toolsExpanded ? 'Collapse' : 'Tools'} + + + {agentWorking ? ( + [styles.stopButton, pressed && styles.pressed]} + onPress={onStop} + hitSlop={8} + accessibilityLabel="Stop the agent" + > + + Stop + + ) : null} + + {sendFailed ? ( + + + {rawLockReason === 'disconnected' + ? 'Message not sent — reconnecting…' + : 'Message not sent'} + + + ) : null} + + + ) +} diff --git a/mobile/src/session/mobile-image-attachment.test.ts b/mobile/src/session/mobile-image-attachment.test.ts index 1bf3df59e74c..9d725d5fe60f 100644 --- a/mobile/src/session/mobile-image-attachment.test.ts +++ b/mobile/src/session/mobile-image-attachment.test.ts @@ -8,9 +8,9 @@ function ok(id: string, result: unknown): RpcSuccess { } function clientWithResponses(responses: RpcResponse[]): Pick & { - calls: Array<{ method: string; params: unknown }> + calls: { method: string; params: unknown }[] } { - const calls: Array<{ method: string; params: unknown }> = [] + const calls: { method: string; params: unknown }[] = [] return { calls, sendRequest: vi.fn(async (method: string, params?: unknown) => { @@ -35,7 +35,7 @@ describe('attachMobileImageToTerminal', () => { _meta: { runtimeId: 'r' } }, ok('save', '/tmp/orca-attach.png'), - ok('send', { ok: true }) + ok('send', { send: { accepted: true } }) ]) const sent = await attachMobileImageToTerminal('library', { @@ -65,7 +65,7 @@ describe('attachMobileImageToTerminal', () => { _meta: { runtimeId: 'r' } }, ok('save', '/tmp/x.png'), - ok('send', { ok: true }) + ok('send', { send: { accepted: true } }) ]) await attachMobileImageToTerminal('files', { @@ -104,7 +104,7 @@ describe('attachMobileImageToTerminal', () => { _meta: { runtimeId: 'r' } }, ok('save', '/tmp/y.png'), - ok('send', { ok: true }) + ok('send', { send: { accepted: true } }) ]) await attachMobileImageToTerminal('library', { @@ -119,7 +119,7 @@ describe('attachMobileImageToTerminal', () => { expect(sendCall?.params).not.toHaveProperty('client') }) - it('waits for pending live input before sending the image payload', async () => { + it('drops the image payload when the final pre-send check observes an input lease gap', async () => { const client = clientWithResponses([ { id: 'start', @@ -129,6 +129,8 @@ describe('attachMobileImageToTerminal', () => { }, ok('save', '/tmp/pending.png') ]) + // Why: upload can outlive the stream subscription whose acknowledgement + // originally enabled the composer. const beforeTerminalSend = vi.fn(async () => false) const sent = await attachMobileImageToTerminal('library', { @@ -144,4 +146,27 @@ describe('attachMobileImageToTerminal', () => { expect(beforeTerminalSend).toHaveBeenCalledWith('term-pending') expect(client.calls.some((call) => call.method === 'terminal.send')).toBe(false) }) + + it('reports failure when the terminal rejects the uploaded image path', async () => { + const client = clientWithResponses([ + { + id: 'start', + ok: false, + error: { code: 'method_not_found', message: 'no' }, + _meta: { runtimeId: 'r' } + }, + ok('save', '/tmp/rejected.png'), + ok('send', { send: { accepted: false } }) + ]) + + const sent = await attachMobileImageToTerminal('library', { + client, + terminal: 'term-rejected', + deviceToken: null, + getConnectionId: async () => null, + pickImage: vi.fn().mockResolvedValue({ base64: 'EEEE' }) + }) + + expect(sent).toBe(false) + }) }) diff --git a/mobile/src/session/mobile-image-attachment.ts b/mobile/src/session/mobile-image-attachment.ts index b7d14d9a04d5..567be99a9a1d 100644 --- a/mobile/src/session/mobile-image-attachment.ts +++ b/mobile/src/session/mobile-image-attachment.ts @@ -4,6 +4,7 @@ import { saveMobileClipboardImageAsTempFile } from './mobile-clipboard-image' import type { MobileImageSource, PickedMobileImage } from './mobile-image-source-picker' +import { isTerminalSendRpcAccepted } from '../terminal/terminal-send-rpc-response' export type AttachMobileImageDeps = { readonly client: Pick @@ -50,11 +51,11 @@ export async function attachMobileImageToTerminal( if (beforeTerminalSend && !(await beforeTerminalSend(terminal))) { return false } - await client.sendRequest('terminal.send', { + const response = await client.sendRequest('terminal.send', { terminal, text: payload, enter: false, ...(deviceToken ? { client: { id: deviceToken, type: 'mobile' as const } } : {}) }) - return true + return isTerminalSendRpcAccepted(response) } diff --git a/mobile/src/session/mobile-native-chat-answer-stepping.test.ts b/mobile/src/session/mobile-native-chat-answer-stepping.test.ts new file mode 100644 index 000000000000..05e05b1f1884 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-answer-stepping.test.ts @@ -0,0 +1,61 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + mobileNativeChatQuestionOffsets, + scheduleMobileClaudeAnswer, + MOBILE_NATIVE_CHAT_ADVANCE_BUFFER_MS, + MOBILE_NATIVE_CHAT_QUESTION_STEP_MS, + MOBILE_NATIVE_CHAT_SUBMIT_DELAY_MS +} from './mobile-native-chat-answer-stepping' + +describe('mobileNativeChatQuestionOffsets', () => { + it('matches the desktop cadence constants', () => { + expect(MOBILE_NATIVE_CHAT_SUBMIT_DELAY_MS).toBe(500) + expect(MOBILE_NATIVE_CHAT_ADVANCE_BUFFER_MS).toBe(500) + expect(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS).toBe(1000) + }) + + it('paces each question a full step apart, Enter 500ms after its body', () => { + expect(mobileNativeChatQuestionOffsets(0)).toEqual({ bodyAt: 0, enterAt: 500 }) + expect(mobileNativeChatQuestionOffsets(1)).toEqual({ bodyAt: 1000, enterAt: 1500 }) + expect(mobileNativeChatQuestionOffsets(2)).toEqual({ bodyAt: 2000, enterAt: 2500 }) + }) +}) + +describe('scheduleMobileClaudeAnswer', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + it('writes each question body then its Enter, paced per step, one Enter per line', () => { + const events: string[] = [] + const timers = scheduleMobileClaudeAnswer( + ['', 'b', 'c'], // blank middle answer must still get its own body + Enter + (line) => events.push(`body:${line}`), + () => events.push('enter') + ) + expect(timers).toHaveLength(6) + + vi.advanceTimersByTime(0) + expect(events).toEqual(['body:']) + vi.advanceTimersByTime(MOBILE_NATIVE_CHAT_SUBMIT_DELAY_MS) + expect(events).toEqual(['body:', 'enter']) + vi.advanceTimersByTime(MOBILE_NATIVE_CHAT_ADVANCE_BUFFER_MS) + expect(events).toEqual(['body:', 'enter', 'body:b']) + + vi.runAllTimers() + expect(events).toEqual(['body:', 'enter', 'body:b', 'enter', 'body:c', 'enter']) + }) + + it('cancelling the returned timers stops all pending writes', () => { + const events: string[] = [] + const timers = scheduleMobileClaudeAnswer( + ['a', 'b'], + (line) => events.push(`body:${line}`), + () => events.push('enter') + ) + for (const timer of timers) { + clearTimeout(timer) + } + vi.runAllTimers() + expect(events).toEqual([]) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-answer-stepping.ts b/mobile/src/session/mobile-native-chat-answer-stepping.ts new file mode 100644 index 000000000000..441f41291d79 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-answer-stepping.ts @@ -0,0 +1,13 @@ +import { + nativeChatQuestionOffsets, + scheduleNativeChatAnswer, + NATIVE_CHAT_ADVANCE_BUFFER_MS, + NATIVE_CHAT_QUESTION_STEP_MS, + NATIVE_CHAT_SUBMIT_DELAY_MS +} from '../../../src/shared/native-chat-answer-stepping' + +export const MOBILE_NATIVE_CHAT_ADVANCE_BUFFER_MS = NATIVE_CHAT_ADVANCE_BUFFER_MS +export const MOBILE_NATIVE_CHAT_QUESTION_STEP_MS = NATIVE_CHAT_QUESTION_STEP_MS +export const MOBILE_NATIVE_CHAT_SUBMIT_DELAY_MS = NATIVE_CHAT_SUBMIT_DELAY_MS +export const mobileNativeChatQuestionOffsets = nativeChatQuestionOffsets +export const scheduleMobileClaudeAnswer = scheduleNativeChatAnswer diff --git a/mobile/src/session/mobile-native-chat-ask.test.ts b/mobile/src/session/mobile-native-chat-ask.test.ts new file mode 100644 index 000000000000..6654a0e8689e --- /dev/null +++ b/mobile/src/session/mobile-native-chat-ask.test.ts @@ -0,0 +1,160 @@ +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' + +function msg(blocks: NativeChatMessage['blocks'], id = 'm'): NativeChatMessage { + return { id, role: 'assistant', blocks, timestamp: 0, source: 'transcript' } +} + +const askInput = { + questions: [ + { + question: 'Pick one', + header: 'Choice', + multiSelect: false, + options: [ + { label: 'A', description: 'first' }, + { label: 'B', description: 'second' } + ] + } + ] +} + +describe('extractPendingAsk', () => { + it('returns the structured prompt from a pending AskUserQuestion call', () => { + const ask = extractPendingAsk([ + msg([{ type: 'tool-call', name: 'AskUserQuestion', input: askInput }]) + ]) + expect(ask?.questions[0]).toMatchObject({ + question: 'Pick one', + header: 'Choice', + multiSelect: false + }) + expect(ask?.questions[0]!.options.map((o) => o.label)).toEqual(['A', 'B']) + }) + + it('returns null once a tool-result follows the ask', () => { + const ask = extractPendingAsk([ + msg([{ type: 'tool-call', name: 'AskUserQuestion', input: askInput }], 'a'), + msg([{ type: 'tool-result', output: 'answered' }], 'r') + ]) + expect(ask).toBeNull() + }) + + it('ignores non-ask tool calls', () => { + expect( + extractPendingAsk([msg([{ type: 'tool-call', name: 'Bash', input: { command: 'ls' } }])]) + ).toBeNull() + }) + + it('survives an unrelated earlier tool result (FIFO, not adjacency)', () => { + // A parallel Bash call precedes the ask; its result resolves the Bash call + // (oldest outstanding), so the unanswered question must remain pending. + const ask = extractPendingAsk([ + msg([{ type: 'tool-call', name: 'Bash', input: { command: 'ls' } }], 'c1'), + msg([{ type: 'tool-call', name: 'AskUserQuestion', input: askInput }], 'a1'), + msg([{ type: 'tool-result', output: 'ls output' }], 'r1') + ]) + expect(ask?.questions[0]!.question).toBe('Pick one') + }) + + it("clears the ask only when the ask's own result arrives", () => { + const ask = extractPendingAsk([ + msg([{ type: 'tool-call', name: 'Bash', input: { command: 'ls' } }], 'c1'), + msg([{ type: 'tool-call', name: 'AskUserQuestion', input: askInput }], 'a1'), + msg([{ type: 'tool-result', output: 'ls output' }], 'r1'), + msg([{ type: 'tool-result', output: 'answered' }], 'r2') + ]) + expect(ask).toBeNull() + }) + + it('keeps the latest ask when several appear', () => { + const ask = extractPendingAsk([ + msg([{ type: 'tool-call', name: 'AskUserQuestion', input: askInput }], 'a1'), + msg([{ type: 'tool-result', output: 'x' }], 'r1'), + msg([ + { + type: 'tool-call', + name: 'AskUserQuestion', + input: { + questions: [{ question: 'Second', multiSelect: false, options: [{ label: 'Z' }] }] + } + } + ]) + ]) + expect(ask?.questions[0]!.question).toBe('Second') + }) +}) + +describe('parseAskFromStatus', () => { + it('parses the live interactivePrompt JSON into a prompt', () => { + const ask = parseAskFromStatus(JSON.stringify(askInput)) + expect(ask?.questions[0]!.options.map((o) => o.label)).toEqual(['A', 'B']) + }) + + it('returns null for empty or malformed input', () => { + expect(parseAskFromStatus(undefined)).toBeNull() + expect(parseAskFromStatus('')).toBeNull() + expect(parseAskFromStatus('{not json')).toBeNull() + expect(parseAskFromStatus('{"foo":1}')).toBeNull() + }) +}) + +describe('formatAskAnswer', () => { + it('joins selected labels per question', () => { + const prompt = { + questions: [ + { question: 'q1', multiSelect: true, options: [{ label: 'A' }, { label: 'B' }] }, + { question: 'q2', multiSelect: false, options: [{ label: 'C' }] } + ] + } + expect(formatAskAnswer(prompt, [{ indices: [0, 1] }, { indices: [0] }])).toBe('A, B\nC') + }) + + it('keeps one line per question with a blank middle answer (3 questions)', () => { + const prompt = { + questions: [ + { question: 'q1', multiSelect: false, options: [{ label: 'A' }] }, + { question: 'q2', multiSelect: false, options: [{ label: 'B' }] }, + { question: 'q3', multiSelect: false, options: [{ label: 'C' }] } + ] + } + const answer = formatAskAnswer(prompt, [{ indices: [0] }, { indices: [] }, { indices: [0] }]) + expect(answer).toBe('A\n\nC') + expect(answer.split('\n')).toHaveLength(3) + }) +}) + +describe('buildAskAnswerKeys', () => { + it('answers a single-select pick with its option number only', () => { + const prompt = { + questions: [ + { question: 'q', multiSelect: false, options: [{ label: 'Tabs' }, { label: 'Spaces' }] } + ] + } + expect(buildAskAnswerKeys(prompt, [{ indices: [1] }])).toEqual([{ raw: '2' }]) + }) + + it('toggles multi-select numbers then steps to Submit and confirms', () => { + const prompt = { + questions: [ + { + question: 'q', + multiSelect: true, + options: [{ label: 'A' }, { label: 'B' }, { label: 'C' }] + } + ] + } + expect(buildAskAnswerKeys(prompt, [{ indices: [0, 2] }])).toEqual([ + { raw: '1' }, + { raw: '3' }, + { raw: '\x1b[C' }, + { raw: '\r' } + ]) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-ask.ts b/mobile/src/session/mobile-native-chat-ask.ts new file mode 100644 index 000000000000..50766b25ee43 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-ask.ts @@ -0,0 +1,2 @@ +// 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-autocomplete.test.ts b/mobile/src/session/mobile-native-chat-autocomplete.test.ts new file mode 100644 index 000000000000..25c904c7b139 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-autocomplete.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import { + applyAutocomplete, + detectAutocompleteTrigger, + rankSuggestions +} from './mobile-native-chat-autocomplete' + +describe('detectAutocompleteTrigger', () => { + it('detects a slash command only at the start', () => { + expect(detectAutocompleteTrigger('/rev', 4)).toEqual({ + kind: 'slash', + query: 'rev', + start: 0, + end: 4 + }) + expect(detectAutocompleteTrigger('hi /rev', 7)).toBeNull() + }) + + it('detects an @ mention after whitespace or at start', () => { + expect(detectAutocompleteTrigger('@src', 4)).toMatchObject({ kind: 'file', query: 'src' }) + expect(detectAutocompleteTrigger('look at @comp', 13)).toMatchObject({ + kind: 'file', + query: 'comp' + }) + }) + + it('does not trigger @ mid-word (email-like)', () => { + expect(detectAutocompleteTrigger('me@host', 7)).toBeNull() + }) + + it('closes the token once a space is typed', () => { + expect(detectAutocompleteTrigger('@src ', 5)).toBeNull() + }) + + it('returns empty query right after the trigger char', () => { + expect(detectAutocompleteTrigger('@', 1)).toMatchObject({ kind: 'file', query: '' }) + }) +}) + +describe('applyAutocomplete', () => { + it('replaces the trigger span and leaves a trailing space + cursor', () => { + const trigger = detectAutocompleteTrigger('look at @comp', 13)! + const { text, cursor } = applyAutocomplete('look at @comp', trigger, '@src/App.tsx') + expect(text).toBe('look at @src/App.tsx ') + expect(cursor).toBe(text.length) + }) +}) + +describe('rankSuggestions', () => { + it('prefers prefix matches on the basename', () => { + const out = rankSuggestions(['src/app/Main.tsx', 'src/AppBar.tsx', 'lib/zapp.ts'], 'app') + expect(out[0]).toBe('src/AppBar.tsx') + expect(out).toContain('lib/zapp.ts') + }) + + it('returns the head of the list for an empty query', () => { + expect(rankSuggestions(['a', 'b', 'c'], '', 2)).toEqual(['a', 'b']) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-autocomplete.ts b/mobile/src/session/mobile-native-chat-autocomplete.ts new file mode 100644 index 000000000000..de0ddc91f67e --- /dev/null +++ b/mobile/src/session/mobile-native-chat-autocomplete.ts @@ -0,0 +1,93 @@ +// Why: the composer offers two kinds of autocomplete — `@file` mentions and +// `/slash` commands. Detection is pure (text + cursor → active trigger) so it's +// unit-testable and the composer stays a thin view over it. + +export type AutocompleteKind = 'file' | 'slash' + +export type AutocompleteTrigger = { + kind: AutocompleteKind + /** The query typed after the trigger char (may be empty). */ + query: string + /** Index of the trigger char in the text (inclusive). */ + start: number + /** Index just past the cursor / token end (exclusive) — the replace span end. */ + end: number +} + +const TOKEN_CHAR = /[^\s]/ + +/** Detect an active autocomplete trigger at the cursor. A `@` mention triggers + * anywhere it follows whitespace/start; a `/` command only at the very start of + * the input (slash commands are line-leading). Returns null when the cursor is + * not inside such a token. */ +export function detectAutocompleteTrigger( + text: string, + cursor: number +): AutocompleteTrigger | null { + const pos = Math.max(0, Math.min(cursor, text.length)) + // Walk left from the cursor over non-whitespace token chars to find the trigger. + let i = pos - 1 + while (i >= 0 && TOKEN_CHAR.test(text[i]!)) { + i-- + } + const triggerIndex = i + 1 + const triggerChar = text[triggerIndex] + if (triggerChar !== '@' && triggerChar !== '/') { + return null + } + const before = triggerIndex === 0 ? '' : text[triggerIndex - 1]! + if (triggerChar === '/' && triggerIndex !== 0) { + // Slash commands are only offered at the very start of the message. + return null + } + if (triggerChar === '@' && triggerIndex !== 0 && !/\s/.test(before)) { + return null + } + const query = text.slice(triggerIndex + 1, pos) + // A space in the query means the token already closed. + if (/\s/.test(query)) { + return null + } + return { + kind: triggerChar === '@' ? 'file' : 'slash', + query, + start: triggerIndex, + end: pos + } +} + +/** Replace the trigger span with `value`, leaving a trailing space and the + * cursor after it. Returns the new text and the new cursor position. */ +export function applyAutocomplete( + text: string, + trigger: AutocompleteTrigger, + value: string +): { text: string; cursor: number } { + const inserted = `${value} ` + const next = text.slice(0, trigger.start) + inserted + text.slice(trigger.end) + return { text: next, cursor: trigger.start + inserted.length } +} + +/** Rank suggestions for a query: case-insensitive, prefix matches first, then + * substring matches, capped. Used for both file paths and command names. */ +export function rankSuggestions(candidates: readonly string[], query: string, limit = 8): string[] { + const q = query.toLowerCase() + if (q.length === 0) { + return candidates.slice(0, limit) + } + const prefix: string[] = [] + const substring: string[] = [] + for (const candidate of candidates) { + const lower = candidate.toLowerCase() + const base = lower.split('/').pop() ?? lower + if (lower.startsWith(q) || base.startsWith(q)) { + prefix.push(candidate) + } else if (lower.includes(q)) { + substring.push(candidate) + } + if (prefix.length >= limit) { + break + } + } + return [...prefix, ...substring].slice(0, limit) +} diff --git a/mobile/src/session/mobile-native-chat-blocks.test.ts b/mobile/src/session/mobile-native-chat-blocks.test.ts new file mode 100644 index 000000000000..57351e4a20cc --- /dev/null +++ b/mobile/src/session/mobile-native-chat-blocks.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { + foldToolMessages, + pairToolBlocks, + splitNativeChatBlocks +} from './mobile-native-chat-blocks' + +function msg( + role: NativeChatMessage['role'], + blocks: NativeChatMessage['blocks'], + id: string = role +): NativeChatMessage { + return { id, role, blocks, timestamp: 0, source: 'transcript' } +} + +describe('splitNativeChatBlocks', () => { + it('separates prose from tool blocks', () => { + const { prose, tools } = splitNativeChatBlocks([ + { type: 'text', text: 'hi' }, + { type: 'tool-call', name: 'Bash', input: {} }, + { type: 'tool-result', output: 'ok' } + ]) + expect(prose.map((b) => b.type)).toEqual(['text']) + expect(tools.map((b) => b.type)).toEqual(['tool-call', 'tool-result']) + }) +}) + +describe('pairToolBlocks', () => { + it('pairs each call with the following result', () => { + const pairs = pairToolBlocks([ + { type: 'tool-call', name: 'Bash', input: { command: 'ls' } }, + { type: 'tool-result', output: 'a\nb' }, + { type: 'tool-call', name: 'Edit', input: {} }, + { type: 'tool-result', output: 'ok' } + ]) + expect(pairs).toHaveLength(2) + expect(pairs[0]!.call?.name).toBe('Bash') + expect(pairs[0]!.result?.output).toBe('a\nb') + expect(pairs[1]!.call?.name).toBe('Edit') + }) + + it('keeps an orphan result on its own', () => { + const pairs = pairToolBlocks([{ type: 'tool-result', output: 'x' }]) + expect(pairs).toEqual([{ result: { type: 'tool-result', output: 'x' } }]) + }) + + it('does not attach a second result to an already-filled pair', () => { + const pairs = pairToolBlocks([ + { type: 'tool-call', name: 'Bash', input: {} }, + { type: 'tool-result', output: '1' }, + { type: 'tool-result', output: '2' } + ]) + expect(pairs).toHaveLength(2) + expect(pairs[1]!.call).toBeUndefined() + expect(pairs[1]!.result?.output).toBe('2') + }) + + it('pairs batched parallel calls by ordinal, not adjacency', () => { + const call1 = { type: 'tool-call' as const, name: 'Bash', input: { command: 'a' } } + const call2 = { type: 'tool-call' as const, name: 'Bash', input: { command: 'b' } } + const result1 = { type: 'tool-result' as const, output: 'out-a' } + const result2 = { type: 'tool-result' as const, output: 'out-b' } + // Parallel calls stream as [call, call, result, result]; the Nth result + // answers the Nth call, so result1 must land on call1 (not the nearer call2). + const pairs = pairToolBlocks([call1, call2, result1, result2]) + + expect(pairs).toHaveLength(2) + expect(pairs[0]).toEqual({ call: call1, result: result1 }) + expect(pairs[1]).toEqual({ call: call2, result: result2 }) + }) + + it('does not graft a dropped over-limit call result onto a kept call', () => { + const call1 = { type: 'tool-call' as const, name: 'Bash', input: { command: 'a' } } + const call2 = { type: 'tool-call' as const, name: 'Bash', input: { command: 'b' } } + const call3 = { type: 'tool-call' as const, name: 'Bash', input: { command: 'c' } } + const result1 = { type: 'tool-result' as const, output: 'out-a' } + const result2 = { type: 'tool-result' as const, output: 'out-b' } + const result3 = { type: 'tool-result' as const, output: 'out-c' } + const pairs = pairToolBlocks([call1, call2, call3, result1, result2, result3], 2) + + expect(pairs).toHaveLength(2) + expect(pairs[0]).toEqual({ call: call1, result: result1 }) + // call3 was dropped past the limit, so result3 must not misgraft onto call2. + expect(pairs[1]).toEqual({ call: call2, result: result2 }) + }) + + it('retains the last result while bounding rendered pairs', () => { + const blocks = Array.from({ length: 20 }, (_unused, index) => [ + { type: 'tool-call' as const, name: 'Bash', input: { index } }, + { type: 'tool-result' as const, output: String(index) } + ]).flat() + const pairs = pairToolBlocks(blocks, 6) + + expect(pairs).toHaveLength(6) + expect(pairs[5].result?.output).toBe('5') + }) +}) + +describe('foldToolMessages', () => { + it('folds tool-call-only assistant messages and result messages into the prose turn', () => { + const folded = foldToolMessages([ + msg('assistant', [{ type: 'text', text: 'doing' }], 'a0'), + msg('assistant', [{ type: 'tool-call', name: 'Bash', input: {} }], 'a1'), + msg('tool', [{ type: 'tool-result', output: 'done' }], 't1'), + msg('assistant', [{ type: 'tool-call', name: 'Edit', input: {} }], 'a2'), + msg('tool', [{ type: 'tool-result', output: 'ok' }], 't2') + ]) + expect(folded).toHaveLength(1) + expect(folded[0]!.blocks.map((b) => b.type)).toEqual([ + 'text', + 'tool-call', + 'tool-result', + 'tool-call', + 'tool-result' + ]) + }) + + it('starts a new turn at the next prose assistant message', () => { + const folded = foldToolMessages([ + msg('assistant', [{ type: 'text', text: 't1' }], 'a0'), + msg('assistant', [{ type: 'tool-call', name: 'Bash', input: {} }], 'a1'), + msg('assistant', [{ type: 'text', text: 't2' }], 'a2') + ]) + expect(folded.map((m) => m.id)).toEqual(['a0', 'a2']) + }) + + it('keeps a tool message standalone when no assistant precedes it', () => { + const folded = foldToolMessages([msg('tool', [{ type: 'tool-result', output: 'x' }])]) + expect(folded).toHaveLength(1) + expect(folded[0]!.role).toBe('tool') + }) + + it('does not merge across a user message', () => { + const folded = foldToolMessages([ + msg('assistant', [{ type: 'tool-call', name: 'Bash', input: {} }], 'a'), + msg('user', [{ type: 'text', text: 'q' }], 'u'), + msg('tool', [{ type: 'tool-result', output: 'r' }], 't') + ]) + expect(folded.map((m) => m.role)).toEqual(['assistant', 'user', 'tool']) + }) + + it('folds a long tool run without mutating the source assistant', () => { + const assistant = msg('assistant', [{ type: 'text', text: 'working' }], 'a') + const tools = Array.from({ length: 1_000 }, (_unused, index) => + msg('tool', [{ type: 'tool-result', output: String(index) }], `t-${index}`) + ) + const folded = foldToolMessages([assistant, ...tools]) + + expect(folded[0].blocks).toHaveLength(1_001) + expect(assistant.blocks).toHaveLength(1) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-blocks.ts b/mobile/src/session/mobile-native-chat-blocks.ts new file mode 100644 index 000000000000..1cf293a79fd4 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-blocks.ts @@ -0,0 +1,11 @@ +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-diff.test.ts b/mobile/src/session/mobile-native-chat-diff.test.ts new file mode 100644 index 000000000000..3171b818050a --- /dev/null +++ b/mobile/src/session/mobile-native-chat-diff.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { diffFromText, diffFromToolCall } from './mobile-native-chat-diff' + +describe('diffFromToolCall', () => { + it('builds del/add lines from an Edit tool call', () => { + const lines = diffFromToolCall('Edit', { + file_path: 'src/a.ts', + old_string: 'const a = 1\nconst b = 2', + new_string: 'const a = 10' + }) + expect(lines).toEqual([ + { kind: 'meta', text: 'src/a.ts' }, + { kind: 'del', text: 'const a = 1' }, + { kind: 'del', text: 'const b = 2' }, + { kind: 'add', text: 'const a = 10' } + ]) + }) + + it('returns null for non-edit tools', () => { + expect(diffFromToolCall('Bash', { command: 'ls' })).toBeNull() + }) +}) + +describe('diffFromText', () => { + it('parses unified diff lines with colours', () => { + const lines = diffFromText('@@ -1,2 +1,2 @@\n context\n-old\n+new') + expect(lines).toEqual([ + { kind: 'meta', text: '@@ -1,2 +1,2 @@' }, + { kind: 'context', text: ' context' }, + { kind: 'del', text: 'old' }, + { kind: 'add', text: 'new' } + ]) + }) + + it('does not treat ordinary prose as a diff', () => { + expect(diffFromText('Here is a list:\n- one bullet\nthat is all')).toBeNull() + }) + + it('caps large diffs before creating render rows', () => { + const text = Array.from( + { length: 1_000 }, + (_unused, index) => `${index % 2 === 0 ? '+' : '-'}line-${index}` + ).join('\n') + const lines = diffFromText(text) + + expect(lines).toHaveLength(120) + expect(lines?.at(-1)).toEqual({ kind: 'meta', text: '… diff truncated …' }) + }) + + it('honors a smaller caller-owned aggregate row budget', () => { + const text = Array.from( + { length: 100 }, + (_unused, index) => `${index % 2 === 0 ? '+' : '-'}line-${index}` + ).join('\n') + + expect(diffFromText(text, 20)).toHaveLength(20) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-diff.ts b/mobile/src/session/mobile-native-chat-diff.ts new file mode 100644 index 000000000000..4ace06d37d0f --- /dev/null +++ b/mobile/src/session/mobile-native-chat-diff.ts @@ -0,0 +1,6 @@ +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-eligibility.test.ts b/mobile/src/session/mobile-native-chat-eligibility.test.ts new file mode 100644 index 000000000000..387736f065e0 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-eligibility.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest' +import type { AgentStatusEntry } from '../../../src/shared/agent-status-types' +import { + canShowMobileNativeChat, + isMobileNativeChatTranscriptReadable, + resolveMobileNativeChat +} from './mobile-native-chat-eligibility' + +function status(overrides: Partial = {}): AgentStatusEntry { + return { + state: 'working', + prompt: '', + updatedAt: 0, + stateStartedAt: 0, + paneKey: 'tab:leaf', + ...overrides + } as AgentStatusEntry +} + +describe('resolveMobileNativeChat', () => { + it('prefers the authoritative supported live agent over a stale launch hint', () => { + expect( + resolveMobileNativeChat({ + type: 'terminal', + launchAgent: 'claude', + agentStatus: { + agentType: 'codex', + providerSession: { id: 'codex-session', transcriptPath: '/tmp/codex.jsonl' } + } + } as never) + ).toMatchObject({ agent: 'codex', sessionId: 'codex-session' }) + }) + + it('rejects an unsupported live agent instead of combining it with a stale hint', () => { + expect( + resolveMobileNativeChat({ + type: 'terminal', + launchAgent: 'claude', + agentStatus: { + agentType: 'gemini', + providerSession: { id: 'gemini-session', transcriptPath: '/tmp/gemini.jsonl' } + } + } as never) + ).toBeNull() + }) + it('resolves agent + sessionId from launchAgent and provider session', () => { + expect( + resolveMobileNativeChat({ + type: 'terminal', + launchAgent: 'claude', + agentStatus: status({ + providerSession: { + key: 'session_id', + id: 'sess-1', + transcriptPath: '/tmp/claude-real-transcript.jsonl' + } + }) + }) + ).toEqual({ + agent: 'claude', + sessionId: 'sess-1', + transcriptPath: '/tmp/claude-real-transcript.jsonl' + }) + }) + + it('falls back to agentStatus.agentType when no launchAgent', () => { + expect( + resolveMobileNativeChat({ + type: 'terminal', + agentStatus: status({ agentType: 'codex' }) + }) + ).toEqual({ agent: 'codex', sessionId: null, transcriptPath: null }) + }) + + it('admits OpenClaude with its distinct agent identity', () => { + expect(resolveMobileNativeChat({ type: 'terminal', launchAgent: 'openclaude' })).toEqual({ + agent: 'openclaude', + sessionId: null, + transcriptPath: null + }) + }) + + it('returns null for unsupported agents', () => { + expect(resolveMobileNativeChat({ type: 'terminal', launchAgent: 'gemini' })).toBeNull() + }) + + it('admits Grok only when its transcript is readable by the serving host', () => { + const tab = { type: 'terminal', launchAgent: 'grok' } + expect(resolveMobileNativeChat(tab, isMobileNativeChatTranscriptReadable(null))).toMatchObject({ + agent: 'grok' + }) + expect( + resolveMobileNativeChat(tab, isMobileNativeChatTranscriptReadable('runtime-ssh-environment')) + ).toMatchObject({ agent: 'grok' }) + expect( + resolveMobileNativeChat(tab, isMobileNativeChatTranscriptReadable('model-a-ssh')) + ).toBeNull() + }) + + it('returns null for a plain shell (no agent)', () => { + expect(resolveMobileNativeChat({ type: 'terminal' })).toBeNull() + }) + + it('returns null for non-terminal tabs', () => { + expect(resolveMobileNativeChat({ type: 'browser', launchAgent: 'claude' })).toBeNull() + }) + + it('canShowMobileNativeChat mirrors resolution', () => { + expect(canShowMobileNativeChat({ type: 'terminal', launchAgent: 'claude' })).toBe(true) + expect(canShowMobileNativeChat(null)).toBe(false) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-eligibility.ts b/mobile/src/session/mobile-native-chat-eligibility.ts new file mode 100644 index 000000000000..84e0e00d012d --- /dev/null +++ b/mobile/src/session/mobile-native-chat-eligibility.ts @@ -0,0 +1,66 @@ +import type { AgentStatusEntry } from '../../../src/shared/agent-status-types' +import { isRuntimeOwnedSshTargetId } from '../../../src/shared/execution-host' +import { isNativeChatSupportedAgent } from '../../../src/shared/native-chat-agent-support' + +// Why: native chat renders an agent's own JSONL transcript, and the host +// resolver knows these transcript layouts. Grok is additionally gated on host +// readability because Model-A SSH stores its transcript on the remote target. +export function isMobileNativeChatTranscriptReadable( + connectionId: string | null | undefined +): boolean { + return connectionId === null || isRuntimeOwnedSshTargetId(connectionId) +} + +export type MobileNativeChatResolution = { + agent: string + /** The agent's own session id, or null before it has reported one (the view + * then shows a waiting state instead of trying to read an unaddressable file). */ + sessionId: string | null + /** Hook-reported transcript path. Recent Claude sessions cannot always be + * resolved from the provider session id, so mobile forwards this to runtime. */ + transcriptPath: string | null +} + +export type MobileNativeChatTab = { + type: string + launchAgent?: string | null + agentStatus?: AgentStatusEntry | null +} + +/** Resolve a session tab to the transcript identity native chat needs, or + * null when the tab can't show native chat (not a terminal, no agent, or an + * agent whose transcript the host can't read). Agent comes from the launch + * hint or the live status; session id from the captured provider session. */ +export function resolveMobileNativeChat( + tab: MobileNativeChatTab | null, + nativeChatTranscriptIsLocalReadable = false +): MobileNativeChatResolution | null { + if (!tab || tab.type !== 'terminal') { + return null + } + const liveAgent = tab.agentStatus?.agentType ?? null + const agent = liveAgent + ? isNativeChatSupportedAgent(liveAgent) + ? liveAgent + : null + : tab.launchAgent + if (!agent || !isNativeChatSupportedAgent(agent)) { + return null + } + if (agent === 'grok' && !nativeChatTranscriptIsLocalReadable) { + return null + } + return { + agent, + sessionId: tab.agentStatus?.providerSession?.id ?? null, + transcriptPath: tab.agentStatus?.providerSession?.transcriptPath ?? null + } +} + +/** Whether the tab can toggle into native chat — gates the long-press item. */ +export function canShowMobileNativeChat( + tab: MobileNativeChatTab | null, + nativeChatTranscriptIsLocalReadable = false +): boolean { + return resolveMobileNativeChat(tab, nativeChatTranscriptIsLocalReadable) !== null +} diff --git a/mobile/src/session/mobile-native-chat-merge.test.ts b/mobile/src/session/mobile-native-chat-merge.test.ts new file mode 100644 index 000000000000..613126705126 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-merge.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { mergeNativeChatMessages } from './mobile-native-chat-merge' + +function msg(id: string, overrides: Partial = {}): NativeChatMessage { + return { + id, + role: 'assistant', + blocks: [{ type: 'text', text: id }], + timestamp: 0, + source: 'transcript', + ...overrides + } +} + +describe('mergeNativeChatMessages', () => { + it('appends new ids in arrival order', () => { + const merged = mergeNativeChatMessages([msg('a')], [msg('b'), msg('c')]) + expect(merged.map((m) => m.id)).toEqual(['a', 'b', 'c']) + }) + + it('replaces a re-emitted id in place without reordering', () => { + const merged = mergeNativeChatMessages( + [msg('a'), msg('b'), msg('c')], + [msg('b', { blocks: [{ type: 'text', text: 'updated' }] })] + ) + expect(merged.map((m) => m.id)).toEqual(['a', 'b', 'c']) + expect(merged[1]!.blocks).toEqual([{ type: 'text', text: 'updated' }]) + }) + + it('does not let a lower-priority source overwrite a higher one', () => { + const merged = mergeNativeChatMessages( + [msg('a', { source: 'transcript', blocks: [{ type: 'text', text: 'real' }] })], + [msg('a', { source: 'scrape', blocks: [{ type: 'text', text: 'scraped' }] })] + ) + expect(merged[0]!.blocks).toEqual([{ type: 'text', text: 'real' }]) + }) + + it('returns the existing array unchanged for an empty batch', () => { + const existing = [msg('a')] + expect(mergeNativeChatMessages(existing, [])).toBe(existing) + }) + + it('does not mutate the existing array', () => { + const existing = [msg('a')] + mergeNativeChatMessages(existing, [msg('b')]) + expect(existing.map((m) => m.id)).toEqual(['a']) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-merge.ts b/mobile/src/session/mobile-native-chat-merge.ts new file mode 100644 index 000000000000..6b6ffe1781e6 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-merge.ts @@ -0,0 +1,9 @@ +// 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 new file mode 100644 index 000000000000..2a034fd33f31 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-merger-oracle.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { + applyAppend, + boundNativeChatWindow, + createNativeChatMerger, + mergeNativeChatMessages, + replaceList +} from './mobile-native-chat-merge' + +function msg(id: string, overrides: Partial = {}): NativeChatMessage { + return { + id, + role: 'assistant', + blocks: [{ type: 'text', text: id }], + timestamp: 0, + source: 'transcript', + ...overrides + } +} + +// ORACLE: the stateful merger (cached index, applyAppend) must deep-equal the +// pure mergeNativeChatMessages for every prefix of an adversarial subscribe +// sequence — the cache must never diverge from a full rebuild (#18). +describe('mobile stateful merger — oracle vs pure merge', () => { + const base = [msg('a'), msg('b', { source: 'hook' })] + const batches: NativeChatMessage[][] = [ + [msg('c')], // pure tail + [], // empty frame + [msg('b', { source: 'transcript', blocks: [{ type: 'text', text: 'final' }] })], // supersede + [msg('b', { source: 'scrape' })], // lower priority — ignored + [msg('a')], // re-emit, no change + [msg('d'), msg('e')] // multi tail + ] + + it('matches the pure merge for every prefix', () => { + const merger = createNativeChatMerger() + replaceList(merger, base) + let pure = mergeNativeChatMessages(base, []) + expect(merger.list).toEqual(pure) + for (const batch of batches) { + const out = applyAppend(merger, batch) + pure = mergeNativeChatMessages(pure, batch) + expect(out).toEqual(pure) + } + }) + + it('keeps prior row identity on a pure tail append', () => { + const merger = createNativeChatMerger() + replaceList(merger, base) + const before = merger.list + const tail = msg('z') + const after = applyAppend(merger, [tail]) + expect(after).not.toBe(before) + expect(after[0]).toBe(before[0]) + expect(after[after.length - 1]).toBe(tail) + }) + + it('bounds the window to the most-recent tail', () => { + const long = Array.from({ length: 10 }, (_, i) => msg(`m${i}`)) + expect(boundNativeChatWindow(long, 3).map((m) => m.id)).toEqual(['m7', 'm8', 'm9']) + expect(boundNativeChatWindow(long, 0)).toBe(long) + expect(boundNativeChatWindow(long, 20)).toBe(long) + }) + + it('bounds live appends and rebuilds the cached indexes for the retained tail', () => { + const merger = createNativeChatMerger() + replaceList(merger, [msg('a'), msg('b'), msg('c')]) + + expect(applyAppend(merger, [msg('d'), msg('e')], 3).map((m) => m.id)).toEqual(['c', 'd', 'e']) + expect(merger.indexById.has('a')).toBe(false) + expect(merger.indexById.get('c')).toBe(0) + expect( + applyAppend(merger, [msg('d', { blocks: [{ type: 'text', text: 'updated' }] })], 3)[1] + ).toMatchObject({ id: 'd', blocks: [{ type: 'text', text: 'updated' }] }) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-message-styles.ts b/mobile/src/session/mobile-native-chat-message-styles.ts new file mode 100644 index 000000000000..c67ffb738cc8 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-message-styles.ts @@ -0,0 +1,168 @@ +import { StyleSheet } from 'react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' + +export const TEXT_SIZE = 17 +export const MONO_SIZE = 12 +export const MAX_TOOL_RESULT_CHARS = 4000 + +export const styles = StyleSheet.create({ + row: { + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm + }, + rowUser: { + alignItems: 'flex-end' + }, + content: { + maxWidth: '100%', + gap: spacing.sm + }, + userBubble: { + maxWidth: '88%', + backgroundColor: colors.textPrimary, + borderRadius: radii.card, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm + }, + userText: { + color: colors.bgBase, + fontSize: TEXT_SIZE, + lineHeight: TEXT_SIZE + 6, + fontWeight: '500' + }, + controls: { + flexDirection: 'row', + justifyContent: 'flex-end', + gap: spacing.xs, + marginBottom: 2, + opacity: 0.7 + }, + controlButton: { + padding: 3 + }, + controlPressed: { + opacity: 0.5 + }, + copied: { + backgroundColor: colors.diffAddedBg, + borderRadius: radii.card + }, + reasoning: { + opacity: 0.7 + }, + queued: { + opacity: 0.55 + }, + queuedTag: { + color: colors.textMuted, + fontSize: 11, + fontWeight: '600', + marginBottom: 2 + }, + toolRun: { + marginTop: spacing.xs + }, + toolRunHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + }, + toolRunToggle: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingVertical: 3 + }, + controlsRow: { + flexDirection: 'row', + justifyContent: 'flex-end' + }, + toolRunCount: { + color: colors.statusGreen, + fontFamily: typography.monoFamily, + fontSize: MONO_SIZE, + fontWeight: '700' + }, + toolRunLabel: { + flex: 1, + color: colors.textMuted, + fontFamily: typography.monoFamily, + fontSize: MONO_SIZE + }, + toolRunBody: { + paddingLeft: spacing.sm, + borderLeftWidth: 2, + borderLeftColor: colors.borderSubtle, + marginTop: spacing.xs + }, + toolLine: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingVertical: 3 + }, + toolName: { + color: colors.textPrimary, + fontFamily: typography.monoFamily, + fontSize: MONO_SIZE + 1, + fontWeight: '600' + }, + toolPreview: { + flex: 1, + color: colors.textMuted, + fontFamily: typography.monoFamily, + fontSize: MONO_SIZE + }, + toolPreviewLink: { + color: colors.accentBlue, + textDecorationLine: 'underline' + }, + toolDetail: { + paddingLeft: spacing.lg, + paddingBottom: spacing.xs, + gap: spacing.xs + }, + mono: { + color: colors.textSecondary, + fontFamily: typography.monoFamily, + fontSize: MONO_SIZE, + lineHeight: MONO_SIZE + 5 + }, + toolResult: { + borderRadius: radii.button, + backgroundColor: colors.bgPanel, + padding: spacing.md + }, + toolResultError: { + backgroundColor: colors.diffDeletedBg + }, + imageRef: { + color: colors.textSecondary, + fontSize: TEXT_SIZE + }, + diff: { + borderRadius: radii.button, + backgroundColor: colors.bgPanel, + paddingVertical: spacing.xs, + overflow: 'hidden' + }, + diffLine: { + color: colors.textSecondary, + fontFamily: typography.monoFamily, + fontSize: MONO_SIZE, + lineHeight: MONO_SIZE + 5, + paddingHorizontal: spacing.sm + }, + diffAdd: { + color: colors.gitDecorationAdded, + backgroundColor: colors.diffAddedBg + }, + diffDel: { + color: colors.gitDecorationDeleted, + backgroundColor: colors.diffDeletedBg + }, + diffMeta: { + color: colors.textMuted + } +}) diff --git a/mobile/src/session/mobile-native-chat-message-text.test.ts b/mobile/src/session/mobile-native-chat-message-text.test.ts new file mode 100644 index 000000000000..225904cc5d8a --- /dev/null +++ b/mobile/src/session/mobile-native-chat-message-text.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatBlock } from '../../../src/shared/native-chat-types' +import { + clampFontScale, + FONT_SCALE_MAX, + FONT_SCALE_MIN, + nativeChatMessageText +} from './mobile-native-chat-message-text' + +describe('nativeChatMessageText', () => { + it('joins text blocks and skips non-text blocks', () => { + const blocks: NativeChatBlock[] = [ + { type: 'text', text: 'Hello' }, + { type: 'tool-call', name: 'Read', input: {} }, + { type: 'text', text: 'World' } + ] + expect(nativeChatMessageText(blocks)).toBe('Hello\n\nWorld') + }) + + it('returns an empty string when there is no prose', () => { + const blocks: NativeChatBlock[] = [{ type: 'tool-call', name: 'Read', input: {} }] + expect(nativeChatMessageText(blocks)).toBe('') + }) + + it('trims surrounding whitespace', () => { + expect(nativeChatMessageText([{ type: 'text', text: ' hi ' }])).toBe('hi') + }) +}) + +describe('clampFontScale', () => { + it('clamps below the minimum', () => { + expect(clampFontScale(0.1)).toBe(FONT_SCALE_MIN) + }) + + it('clamps above the maximum', () => { + expect(clampFontScale(5)).toBe(FONT_SCALE_MAX) + }) + + it('passes through an in-range value', () => { + expect(clampFontScale(1.2)).toBe(1.2) + }) + + it('falls back to 1 for NaN', () => { + expect(clampFontScale(Number.NaN)).toBe(1) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-message-text.ts b/mobile/src/session/mobile-native-chat-message-text.ts new file mode 100644 index 000000000000..26aeea7680cc --- /dev/null +++ b/mobile/src/session/mobile-native-chat-message-text.ts @@ -0,0 +1,24 @@ +import type { NativeChatBlock } from '../../../src/shared/native-chat-types' +import { isTextBlock } from './mobile-native-chat-blocks' + +/** 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. */ +export function nativeChatMessageText(blocks: readonly NativeChatBlock[]): string { + return blocks + .filter(isTextBlock) + .map((b) => b.text) + .join('\n\n') + .trim() +} + +/** Pinch-to-zoom font bounds. Default 1 means no visible change until pinched. */ +export const FONT_SCALE_MIN = 0.8 +export const FONT_SCALE_MAX = 1.8 + +/** Clamp a proposed font scale into the supported range. */ +export function clampFontScale(scale: number): number { + if (Number.isNaN(scale)) { + return 1 + } + return Math.min(FONT_SCALE_MAX, Math.max(FONT_SCALE_MIN, scale)) +} diff --git a/mobile/src/session/mobile-native-chat-noise.test.ts b/mobile/src/session/mobile-native-chat-noise.test.ts new file mode 100644 index 000000000000..6977b764e720 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-noise.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { isNoiseMessage, stripNoiseMessages } from './mobile-native-chat-noise' + +function msg( + role: NativeChatMessage['role'], + text: string, + blocks?: NativeChatMessage['blocks'] +): NativeChatMessage { + return { + id: text.slice(0, 8), + role, + blocks: blocks ?? [{ type: 'text', text }], + timestamp: 0, + source: 'transcript' + } +} + +describe('isNoiseMessage', () => { + it('flags task-notification and system-reminder user turns', () => { + expect(isNoiseMessage(msg('user', '\nx'))).toBe(true) + expect(isNoiseMessage(msg('user', '\nbe careful'))).toBe(true) + expect( + isNoiseMessage( + msg( + 'user', + 'Caveat: The messages below were generated by the user while running local commands' + ) + ) + ).toBe(true) + expect(isNoiseMessage(msg('user', '[Request interrupted by user]'))).toBe(true) + }) + + it('flags inter-agent harness turns (teammate/agent messages)', () => { + expect(isNoiseMessage(msg('user', 'ping'))).toBe(true) + expect(isNoiseMessage(msg('user', 'status update'))).toBe(true) + }) + + it('keeps real user messages', () => { + expect(isNoiseMessage(msg('user', 'make it work for codex'))).toBe(false) + // A genuine custom tag is not a known harness tag, so it stays a user turn. + expect(isNoiseMessage(msg('user', ' is my band'))).toBe(false) + }) + + it('keeps assistant and tool turns', () => { + expect(isNoiseMessage(msg('assistant', ' in prose'))).toBe(false) + }) + + it('keeps a user turn that carries tool results', () => { + expect(isNoiseMessage(msg('user', '', [{ type: 'tool-result', output: 'ok' }]))).toBe(false) + }) + + it('stripNoiseMessages removes only the noise', () => { + const out = stripNoiseMessages([ + msg('user', 'hello'), + msg('user', 'done'), + msg('assistant', 'hi') + ]) + expect(out.map((m) => m.role)).toEqual(['user', 'assistant']) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-noise.ts b/mobile/src/session/mobile-native-chat-noise.ts new file mode 100644 index 000000000000..1b15a7e414ae --- /dev/null +++ b/mobile/src/session/mobile-native-chat-noise.ts @@ -0,0 +1 @@ +export { isNoiseMessage, stripNoiseMessages } from '../../../src/shared/native-chat-noise' diff --git a/mobile/src/session/mobile-native-chat-open-file.test.ts b/mobile/src/session/mobile-native-chat-open-file.test.ts new file mode 100644 index 000000000000..2f8ceb936a75 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-open-file.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { + openMobileNativeChatFile, + resolveMobileNativeChatWorktreePath +} from './mobile-native-chat-open-file' + +describe('resolveMobileNativeChatWorktreePath', () => { + it('resolves an absolute tool path to a worktree-relative open target', async () => { + const sendRequest = vi.fn().mockResolvedValue({ + ok: true, + result: { + exists: true, + isDirectory: false, + openTarget: { kind: 'worktree-file', relativePath: 'src/app.ts' } + } + }) + await expect( + resolveMobileNativeChatWorktreePath({ + client: { sendRequest } as unknown as RpcClient, + worktreeId: 'worktree', + pathText: '/repo/src/app.ts', + terminal: 'terminal' + }) + ).resolves.toBe('src/app.ts') + expect(sendRequest).toHaveBeenCalledWith('files.resolveTerminalPath', { + worktree: 'id:worktree', + pathText: '/repo/src/app.ts', + terminal: 'terminal' + }) + }) + + it('opens only the resolved worktree-relative target', async () => { + const sendRequest = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + result: { + exists: true, + isDirectory: false, + openTarget: { kind: 'worktree-file', relativePath: 'src/app.ts' } + } + }) + .mockResolvedValueOnce({ ok: true, result: {} }) + + await openMobileNativeChatFile({ + client: { sendRequest } as unknown as RpcClient, + worktreeId: 'worktree', + pathText: '../repo/src/app.ts', + terminal: 'terminal' + }) + + expect(sendRequest).toHaveBeenLastCalledWith('files.open', { + worktree: 'id:worktree', + relativePath: 'src/app.ts' + }) + }) + + it('resolves null when the resolve request rejects', async () => { + const sendRequest = vi.fn().mockRejectedValue(new Error('Request timed out')) + await expect( + resolveMobileNativeChatWorktreePath({ + client: { sendRequest } as unknown as RpcClient, + worktreeId: 'worktree', + pathText: 'src/app.ts', + terminal: null + }) + ).resolves.toBeNull() + }) + + it('does not reject when the open request fails', async () => { + const sendRequest = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + result: { + exists: true, + isDirectory: false, + openTarget: { kind: 'worktree-file', relativePath: 'src/app.ts' } + } + }) + .mockRejectedValueOnce(new Error('connection interrupted')) + + await expect( + openMobileNativeChatFile({ + client: { sendRequest } as unknown as RpcClient, + worktreeId: 'worktree', + pathText: 'src/app.ts', + terminal: null + }) + ).resolves.toBeUndefined() + }) +}) diff --git a/mobile/src/session/mobile-native-chat-open-file.ts b/mobile/src/session/mobile-native-chat-open-file.ts new file mode 100644 index 000000000000..44d6d23c0666 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-open-file.ts @@ -0,0 +1,51 @@ +import type { RuntimeTerminalPathResolution } from '../../../src/shared/runtime-types' +import type { RpcClient } from '../transport/rpc-client' + +export async function resolveMobileNativeChatWorktreePath(args: { + client: RpcClient + worktreeId: string + pathText: string + terminal: string | null +}): Promise { + try { + const response = await args.client.sendRequest('files.resolveTerminalPath', { + worktree: `id:${args.worktreeId}`, + pathText: args.pathText, + ...(args.terminal ? { terminal: args.terminal } : {}) + }) + if (!response.ok) { + return null + } + const resolved = response.result as RuntimeTerminalPathResolution + if (!resolved.exists || resolved.isDirectory) { + return null + } + return resolved.openTarget?.kind === 'worktree-file' + ? resolved.openTarget.relativePath + : (resolved.relativePath ?? null) + } catch { + // Callers fire-and-forget file opens; a disconnect/timeout must not become + // an unhandled rejection. + return null + } +} + +export async function openMobileNativeChatFile(args: { + client: RpcClient + worktreeId: string + pathText: string + terminal: string | null +}): Promise { + const relativePath = await resolveMobileNativeChatWorktreePath(args) + if (relativePath) { + try { + await args.client.sendRequest('files.open', { + worktree: `id:${args.worktreeId}`, + relativePath + }) + } catch { + // Best-effort open; failures surface as a no-op rather than an + // unhandled rejection. + } + } +} diff --git a/mobile/src/session/mobile-native-chat-permission-send.test.ts b/mobile/src/session/mobile-native-chat-permission-send.test.ts new file mode 100644 index 000000000000..2592038d7c94 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-permission-send.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { sendMobileNativeChatPermissionResponse } from './mobile-native-chat-permission-send' + +describe('sendMobileNativeChatPermissionResponse', () => { + it('writes an approval as raw bytes without appending Return', async () => { + const sendRequest = vi.fn().mockResolvedValue({ + ok: true, + result: { send: { handle: 'terminal', accepted: true, bytesWritten: 1 } } + }) + + await expect( + sendMobileNativeChatPermissionResponse({ + client: { sendRequest } as unknown as RpcClient, + terminal: 'terminal', + deviceToken: 'phone', + text: '1' + }) + ).resolves.toBe(true) + expect(sendRequest).toHaveBeenCalledWith('terminal.send', { + terminal: 'terminal', + text: '1', + enter: false, + client: { id: 'phone', type: 'mobile' } + }) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-permission-send.ts b/mobile/src/session/mobile-native-chat-permission-send.ts new file mode 100644 index 000000000000..679be2d94ed5 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-permission-send.ts @@ -0,0 +1,49 @@ +import { useCallback, type MutableRefObject } from 'react' +import type { RpcClient } from '../transport/rpc-client' +import { sendMobileNativeChatMessage } from './mobile-native-chat-send' + +export function sendMobileNativeChatPermissionResponse(args: { + client: RpcClient + terminal: string + deviceToken: string | null + text: string +}): Promise { + // Why: approval choices are already complete terminal control sequences; + // appending Return changes both numbered choices and Escape denial. + return sendMobileNativeChatMessage({ + client: args.client, + terminal: args.terminal, + text: args.text, + enter: false, + ...(args.deviceToken ? { mobileClient: { id: args.deviceToken, type: 'mobile' as const } } : {}) + }) +} + +export function useMobileNativeChatPermissionSend(args: { + client: RpcClient | null + enabled: boolean + handleRef: MutableRefObject + deviceTokenRef: MutableRefObject + onSendError: (message: string) => void +}): (text: string) => Promise { + return useCallback( + async (text: string): Promise => { + const terminal = args.handleRef.current + if (!args.client || !terminal || !args.enabled) { + args.onSendError('Response not sent (disconnected)') + return false + } + const accepted = await sendMobileNativeChatPermissionResponse({ + client: args.client, + terminal, + deviceToken: args.deviceTokenRef.current, + text + }) + if (!accepted) { + args.onSendError('Response not sent') + } + return accepted + }, + [args.client, args.deviceTokenRef, args.enabled, args.handleRef, args.onSendError] + ) +} diff --git a/mobile/src/session/mobile-native-chat-permission.test.ts b/mobile/src/session/mobile-native-chat-permission.test.ts new file mode 100644 index 000000000000..4744c394756f --- /dev/null +++ b/mobile/src/session/mobile-native-chat-permission.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from 'vitest' +import { detectAgentPermission, parseApprovalFromStatus } from './mobile-native-chat-permission' + +describe('detectAgentPermission', () => { + it('returns null for a working agent even with permission-like text', () => { + expect( + detectAgentPermission({ + state: 'working', + lastAssistantMessage: 'Do you want to proceed? (y/n)' + }) + ).toBeNull() + }) + + it('returns null when state is undefined', () => { + expect( + detectAgentPermission({ lastAssistantMessage: 'Do you want to proceed? (y/n)' }) + ).toBeNull() + }) + + it('returns null for ordinary prose while blocked', () => { + expect( + detectAgentPermission({ + state: 'blocked', + lastAssistantMessage: 'I finished editing the file and ran the tests.' + }) + ).toBeNull() + }) + + it('returns null when the message is empty', () => { + expect(detectAgentPermission({ state: 'waiting', lastAssistantMessage: ' ' })).toBeNull() + }) + + it('detects a (y/n) prompt and offers Allow/Deny with y/n send values', () => { + const result = detectAgentPermission({ + state: 'waiting', + lastAssistantMessage: 'Allow running `rm -rf build`? (y/n)' + }) + expect(result).not.toBeNull() + expect(result?.title).toBe('Permission requested') + expect(result?.options).toEqual([ + { label: 'Allow', send: 'y' }, + { label: 'Deny', send: 'n' } + ]) + expect(result?.detail).toContain('Allow running') + }) + + it('detects a numbered allow/deny menu and sends the digit', () => { + const result = detectAgentPermission({ + state: 'blocked', + lastAssistantMessage: + 'Claude wants to edit App.tsx. Do you want to proceed?\n' + + '1. Yes\n' + + '2. No, and tell Claude what to do differently' + }) + expect(result).not.toBeNull() + expect(result?.options).toHaveLength(2) + expect(result?.options[0]).toEqual({ label: 'Yes', send: '1' }) + expect(result?.options[1]?.send).toBe('2') + expect(result?.options[1]?.label).toContain('No') + }) + + it('handles parenthesized numbered options "1) Yes 2) No"', () => { + const result = detectAgentPermission({ + state: 'waiting', + lastAssistantMessage: 'Approve this command?\n1) Yes\n2) No' + }) + expect(result?.options.map((o) => o.send)).toEqual(['1', '2']) + }) + + it('supports a three-option numbered menu (Yes / Yes always / No)', () => { + const result = detectAgentPermission({ + state: 'blocked', + lastAssistantMessage: + "Allow this tool call?\n1. Yes\n2. Yes, and don't ask again this session\n3. No" + }) + expect(result?.options).toHaveLength(3) + expect(result?.options.map((o) => o.send)).toEqual(['1', '2', '3']) + }) + + it('adds an "Allow always" option when the text offers a persistent grant', () => { + const result = detectAgentPermission({ + state: 'waiting', + lastAssistantMessage: + 'Do you want to allow this? You can allow always for this session. (y/n)' + }) + expect(result?.options.map((o) => o.label)).toEqual(['Allow', 'Allow always', 'Deny']) + expect(result?.options.map((o) => o.send)).toEqual(['y', 'a', 'n']) + }) + + it('detects keyword-only permission asks without explicit y/n tokens', () => { + const result = detectAgentPermission({ + state: 'blocked', + lastAssistantMessage: 'I need your permission to run this Bash command.' + }) + expect(result).not.toBeNull() + expect(result?.options.map((o) => o.send)).toEqual(['y', 'n']) + }) + + it('detects "approve" / "deny" phrasing', () => { + const result = detectAgentPermission({ + state: 'waiting', + lastAssistantMessage: 'Please approve or deny this write to /etc/hosts.' + }) + expect(result).not.toBeNull() + expect(result?.options).toHaveLength(2) + }) + + it('truncates a very long detail line', () => { + const long = `Do you want to proceed? ${'x'.repeat(300)}` + const result = detectAgentPermission({ state: 'waiting', lastAssistantMessage: long }) + expect(result).not.toBeNull() + expect((result?.detail ?? '').length).toBeLessThanOrEqual(160) + }) + + it('shortens long numbered option labels', () => { + const result = detectAgentPermission({ + state: 'blocked', + lastAssistantMessage: + 'Proceed?\n1. Yes\n2. No, and explain in great detail exactly why this particular approach is wrong' + }) + const second = result?.options[1] + expect(second?.send).toBe('2') + expect((second?.label ?? '').length).toBeLessThanOrEqual(40) + }) +}) + +describe('parseApprovalFromStatus', () => { + it('parses an approval envelope into an Allow/Deny card', () => { + const card = parseApprovalFromStatus( + JSON.stringify({ approval: { tool: 'Bash', summary: 'rm -rf build' } }) + ) + expect(card?.title).toBe('Allow Bash?') + expect(card?.detail).toBe('rm -rf build') + expect(card?.options.map((o) => o.label)).toEqual(['Allow', 'Deny']) + expect(card?.options[0]!.send).toBe('1') + }) + + it('returns null for non-approval / malformed input', () => { + expect(parseApprovalFromStatus(undefined)).toBeNull() + expect(parseApprovalFromStatus('{bad')).toBeNull() + expect(parseApprovalFromStatus(JSON.stringify({ questions: [] }))).toBeNull() + expect(parseApprovalFromStatus(JSON.stringify({ approval: {} }))).toBeNull() + }) +}) diff --git a/mobile/src/session/mobile-native-chat-permission.ts b/mobile/src/session/mobile-native-chat-permission.ts new file mode 100644 index 000000000000..fe3f9a0d89ba --- /dev/null +++ b/mobile/src/session/mobile-native-chat-permission.ts @@ -0,0 +1,179 @@ +// Agent permission asks (e.g. Claude/Codex "Do you want to proceed?") surface +// as plain TUI text in the agent's last assistant message — there is no +// structured permission event on mobile. We detect them heuristically so the +// native chat can render tappable Allow/Deny buttons instead of forcing the +// user to type into the composer. Be conservative: only fire when the agent is +// actually paused (blocked/waiting) AND the text reads like an approval ask. + +/** A detected permission prompt, rendered as a card with tappable options. + * Each option's `send` is the literal string to write back to the agent + * (e.g. "y", "1") when the user taps it. */ +export type MobileChatPermission = { + title: string + detail?: string + options: Array<{ label: string; send: string }> +} + +const ESCAPE = String.fromCharCode(27) + +/** Parse the live `agentStatus.interactivePrompt` approval envelope + * (`{ approval: { tool, summary } }`, emitted by the host on a PermissionRequest) + * into an Allow/Deny card. This is the reliable, agent-emitted signal — unlike + * detectAgentPermission it doesn't depend on heuristic text parsing. The default + * sends (number for allow, Escape for deny) match the common TUI approval prompt; + * detectAgentPermission still takes precedence when it can read the real numbered + * options from the prompt text. */ +export function parseApprovalFromStatus( + interactivePrompt: string | undefined | null +): MobileChatPermission | null { + if (!interactivePrompt) { + return null + } + let parsed: unknown + try { + parsed = JSON.parse(interactivePrompt) + } catch { + return null + } + if (!parsed || typeof parsed !== 'object') { + return null + } + const approval = (parsed as { approval?: unknown }).approval + if (!approval || typeof approval !== 'object') { + return null + } + const tool = (approval as { tool?: unknown }).tool + if (typeof tool !== 'string' || tool.length === 0) { + return null + } + const summary = (approval as { summary?: unknown }).summary + return { + title: `Allow ${tool}?`, + detail: typeof summary === 'string' && summary.length > 0 ? summary : undefined, + options: [ + { label: 'Allow', send: '1' }, + { label: 'Deny', send: ESCAPE } + ] + } +} + +type PermissionInput = { + state?: string + lastAssistantMessage?: string + toolName?: string + toolInput?: unknown +} + +// States where the agent is paused waiting on the human. Only these can yield a +// permission prompt — a "working" agent is mid-turn and must not be answered. +const PAUSED_STATES: ReadonlySet = new Set(['blocked', 'waiting']) + +// Phrases that read as an approval request. Kept broad but anchored to +// approval/permission language so ordinary prose doesn't trip detection. +const PERMISSION_PATTERNS: RegExp[] = [ + /\bpermission\b/i, + /\bapprove\b/i, + /\bapproval\b/i, + /\ballow\b/i, + /\bdeny\b/i, + /\bgrant\b/i, + /\bauthorize\b/i, + /\bdo you want to\b/i, + /\bwould you like to\b/i, + /\bproceed\?/i, + /\bconfirm\b/i, + /\(y\/n\)/i, + /\by\/n\b/i, + /\byes\/no\b/i, + /\bplease confirm\b/i +] + +function looksLikePermissionAsk(text: string): boolean { + return PERMISSION_PATTERNS.some((re) => re.test(text)) +} + +// A numbered-choice prompt like "1. Yes 2. No" / "2) No, and tell Claude…". +// Captures the option number and its label so we can send the literal digit. +const NUMBERED_OPTION_RE = /(?:^|\n)\s*(\d+)[.)]\s*([^\n]+)/g + +type NumberedOption = { num: string; text: string } + +function parseNumberedOptions(text: string): NumberedOption[] { + const out: NumberedOption[] = [] + for (const match of text.matchAll(NUMBERED_OPTION_RE)) { + const num = match[1] + const body = match[2]?.trim() + if (num && body) { + out.push({ num, text: body }) + } + } + return out +} + +// Whether a label reads as "allow for every future call" rather than just once. +function isAlwaysLabel(text: string): boolean { + return /\balways\b|don't ask again|do not ask again|for the rest|this session/i.test(text) +} + +function shortLabel(text: string, max = 40): string { + const trimmed = text.replace(/\s+/g, ' ').trim() + return trimmed.length > max ? `${trimmed.slice(0, max - 1)}…` : trimmed +} + +function buildNumberedPermission( + options: NumberedOption[], + detail: string | undefined +): MobileChatPermission { + return { + title: 'Permission requested', + detail, + options: options.map((opt) => ({ label: shortLabel(opt.text), send: opt.num })) + } +} + +function firstLine(text: string): string { + return text.split('\n')[0]?.trim() ?? '' +} + +/** + * Heuristically detect an agent permission ask from its paused-state context. + * Returns a renderable prompt, or null when the agent is working or the text + * doesn't read like an approval request. + */ +export function detectAgentPermission(input: PermissionInput): MobileChatPermission | null { + // Only answer a genuinely paused agent. A working agent is mid-turn. + if (!input.state || !PAUSED_STATES.has(input.state)) { + return null + } + + const text = typeof input.lastAssistantMessage === 'string' ? input.lastAssistantMessage : '' + if (!text.trim()) { + return null + } + + if (!looksLikePermissionAsk(text)) { + return null + } + + const detail = shortLabel(firstLine(text), 160) || undefined + + // Prefer an explicit numbered menu ("1. Yes 2. No, and tell…") — its labels + // and send-digits come straight from the agent, so no guessing. + const numbered = parseNumberedOptions(text) + if (numbered.length >= 2) { + return buildNumberedPermission(numbered, detail) + } + + // Otherwise fall back to a y/n prompt. We surface "Allow always" only when the + // text actually offers a persistent option, to avoid sending a token the agent + // doesn't understand. + const options: MobileChatPermission['options'] = [ + { label: 'Allow', send: 'y' }, + { label: 'Deny', send: 'n' } + ] + if (isAlwaysLabel(text)) { + options.splice(1, 0, { label: 'Allow always', send: 'a' }) + } + + return { title: 'Permission requested', detail, options } +} diff --git a/mobile/src/session/mobile-native-chat-question.test.ts b/mobile/src/session/mobile-native-chat-question.test.ts new file mode 100644 index 000000000000..94fbcf055a9e --- /dev/null +++ b/mobile/src/session/mobile-native-chat-question.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from 'vitest' +import { + formatQuestionAnswer, + mobileChatQuestionKey, + parseAgentQuestion, + type MobileChatQuestion +} from './mobile-native-chat-question' + +describe('parseAgentQuestion', () => { + it('parses a numbered list with a question line', () => { + const text = ['Which database should I use?', '1. PostgreSQL', '2. MySQL', '3. SQLite'].join( + '\n' + ) + const q = parseAgentQuestion(text) + expect(q).not.toBeNull() + expect(q?.question).toBe('Which database should I use?') + expect(q?.options).toEqual(['PostgreSQL', 'MySQL', 'SQLite']) + expect(q?.optionTokens).toEqual(['1', '2', '3']) + expect(q?.multiSelect).toBe(false) + }) + + it('parses numbered lists using ) markers', () => { + const q = parseAgentQuestion('Pick:\n1) Yes\n2) No') + expect(q?.options).toEqual(['Yes', 'No']) + expect(q?.optionTokens).toEqual(['1', '2']) + }) + + it('parses a bulleted list', () => { + const text = ['How should we proceed?', '- Rebase', '- Merge', '* Squash'].join('\n') + const q = parseAgentQuestion(text) + expect(q?.options).toEqual(['Rebase', 'Merge', 'Squash']) + expect(q?.optionTokens).toEqual([null, null, null]) + expect(q?.question).toBe('How should we proceed?') + }) + + it('parses lettered options in brackets and parens', () => { + const q = parseAgentQuestion('Choose one:\n[a] Alpha\n[b] Beta') + expect(q?.options).toEqual(['Alpha', 'Beta']) + expect(q?.optionTokens).toEqual(['a', 'b']) + + const q2 = parseAgentQuestion('Choose one:\na) Alpha\nb) Beta') + expect(q2?.options).toEqual(['Alpha', 'Beta']) + expect(q2?.optionTokens).toEqual(['a', 'b']) + }) + + it('strips a leading pointer/highlight glyph from the selected row', () => { + const text = ['Select a target:', '❯ 1. main', ' 2. develop'].join('\n') + const q = parseAgentQuestion(text) + expect(q?.options).toEqual(['main', 'develop']) + expect(q?.optionTokens).toEqual(['1', '2']) + }) + + it('drops a trailing colon but keeps a question mark in the title', () => { + expect(parseAgentQuestion('Options:\n1. A\n2. B')?.question).toBe('Options') + expect(parseAgentQuestion('Ready?\n1. A\n2. B')?.question).toBe('Ready?') + }) + + it('returns null for ordinary prose with no option list', () => { + expect(parseAgentQuestion('I finished the refactor and ran the tests.')).toBeNull() + expect(parseAgentQuestion('')).toBeNull() + expect(parseAgentQuestion(' ')).toBeNull() + }) + + it('returns null for a single bare option with no introducing prompt', () => { + // A lone "- item" in prose must not be treated as a question. + expect(parseAgentQuestion('Here is what I did:\nsome work\n- one stray bullet')).toBeNull() + }) + + it('accepts a single option when introduced by a prompt line', () => { + const q = parseAgentQuestion('Apply this change?\n1. Yes, apply it') + expect(q).not.toBeNull() + expect(q?.options).toEqual(['Yes, apply it']) + }) + + it('uses a fallback title when no introducing line exists', () => { + const q = parseAgentQuestion('1. Alpha\n2. Beta') + expect(q?.question).toBe('Choose an option') + expect(q?.options).toEqual(['Alpha', 'Beta']) + }) + + it('detects multi-select hints', () => { + expect(parseAgentQuestion('Select all that apply:\n1. A\n2. B')?.multiSelect).toBe(true) + expect(parseAgentQuestion('Choose multiple:\n- A\n- B')?.multiSelect).toBe(true) + expect(parseAgentQuestion('Pick one:\n1. A\n2. B')?.multiSelect).toBe(false) + }) + + it('does not flag multi-select when only one option is present', () => { + const q = parseAgentQuestion('Select all that apply?\n1. Only one') + expect(q?.multiSelect).toBe(false) + }) + + it('finds the question line nearest the options, skipping blanks', () => { + const text = ['Some preamble.', '', 'Which branch?', '', '1. main', '2. dev'].join('\n') + const q = parseAgentQuestion(text) + expect(q?.question).toBe('Which branch?') + }) +}) + +describe('formatQuestionAnswer', () => { + const numbered: MobileChatQuestion = { + question: 'Pick', + options: ['Alpha', 'Beta', 'Gamma'], + multiSelect: false, + optionTokens: ['1', '2', '3'] + } + + it('echoes the leading token for single-select', () => { + expect(formatQuestionAnswer(numbered, ['Beta'])).toBe('2') + }) + + it('comma-joins tokens for multi-select', () => { + const multi: MobileChatQuestion = { ...numbered, multiSelect: true } + expect(formatQuestionAnswer(multi, ['Alpha', 'Gamma'])).toBe('1, 3') + }) + + it('sends the label text when the option had no token (bullet list)', () => { + const bullets: MobileChatQuestion = { + question: 'Pick', + options: ['Rebase', 'Merge'], + multiSelect: false, + optionTokens: [null, null] + } + expect(formatQuestionAnswer(bullets, ['Merge'])).toBe('Merge') + }) + + it('echoes letter tokens', () => { + const lettered: MobileChatQuestion = { + question: 'Pick', + options: ['Alpha', 'Beta'], + multiSelect: false, + optionTokens: ['a', 'b'] + } + expect(formatQuestionAnswer(lettered, ['Beta'])).toBe('b') + }) + + it('passes free-text / unknown entries through verbatim', () => { + expect(formatQuestionAnswer(numbered, ['something custom'])).toBe('something custom') + }) + + it('returns empty string when nothing is selected', () => { + expect(formatQuestionAnswer(numbered, [])).toBe('') + expect(formatQuestionAnswer(numbered, [' '])).toBe('') + }) +}) + +describe('mobileChatQuestionKey', () => { + it('changes when a replacement prompt changes any positional content', () => { + const first: MobileChatQuestion = { + question: 'Pick', + options: ['A', 'B'], + multiSelect: true, + optionTokens: ['1', '2'] + } + expect(mobileChatQuestionKey({ ...first, options: ['A', 'C'] })).not.toBe( + mobileChatQuestionKey(first) + ) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-question.ts b/mobile/src/session/mobile-native-chat-question.ts new file mode 100644 index 000000000000..8a1f06dfbf70 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-question.ts @@ -0,0 +1,154 @@ +// Heuristic detection of an agent's "pick an option" prompt from its status / +// assistant text. Agents (Claude et al.) render these as a TUI choice list; we +// have no structured signal, so we parse the text conservatively and only treat +// it as a question when a clear option list is present. + +export type MobileChatQuestion = { + question: string + options: string[] + multiSelect: boolean + /** Per-option leading marker ("1", "b", …) when the source line carried one, + * parallel to `options`. Null where the option was a plain bullet. Used to + * echo the exact choice the agent listed back to the terminal. */ + optionTokens: (string | null)[] +} + +export function mobileChatQuestionKey(question: MobileChatQuestion): string { + return JSON.stringify(question) +} + +type ParsedOption = { + label: string + token: string | null +} + +// A pointer/highlight glyph some TUIs prefix onto the *currently selected* row. +// Stripped first so "❯ 2. Foo" parses identically to "2. Foo". +const POINTER_PREFIX = /^(\s*)(?:❯|›|»)\s+/ + +// Ordered most-specific-first so a numbered/lettered marker wins over the +// bullet fallback. `token` is the capture index of the leading marker (0 = none), +// `label` the capture index of the choice text. +const OPTION_PATTERNS: { re: RegExp; token: number; label: number }[] = [ + // 1. Option 12) Option + { re: /^\s*(\d{1,2})[.)]\s+(\S.*?)\s*$/, token: 1, label: 2 }, + // [a] Option [1] Option + { re: /^\s*\[([0-9a-zA-Z])\]\s+(\S.*?)\s*$/, token: 1, label: 2 }, + // a) Option a. Option (single letter, to avoid eating prose like "e.g.") + { re: /^\s*([a-zA-Z])[.)]\s+(\S.*?)\s*$/, token: 1, label: 2 }, + // - Option * Option • Option > Option (no token) + { re: /^\s*(?:[-*•>])\s+(\S.*?)\s*$/, token: 0, label: 1 } +] + +function parseOptionLine(line: string): ParsedOption | null { + const stripped = line.replace(POINTER_PREFIX, '$1') + for (const { re, token, label } of OPTION_PATTERNS) { + const m = stripped.match(re) + if (!m) { + continue + } + const text = m[label].trim() + if (text.length === 0) { + continue + } + return { label: text, token: token > 0 ? m[token] : null } + } + return null +} + +const MULTI_SELECT_HINT = + /\b(select all|choose all|choose multiple|select multiple|pick multiple|all that apply|one or more|comma[- ]separated|multiple options)\b/i + +// Question-like introducing line: ends in ? or :. +const QUESTION_LINE = /[?:]\s*$/ + +// Drop a trailing ":" off a card title but keep a meaningful "?". +function cleanQuestionText(raw: string): string { + const trimmed = raw.trim() + return trimmed.endsWith(':') ? trimmed.slice(0, -1).trim() : trimmed +} + +/** + * Heuristically parse a question + its option list from agent text. Returns null + * when no clear option list is present (so ordinary prose is never treated as a + * question). Conservative on purpose: requires at least two option lines, or one + * option line introduced by a question-like prompt line. + */ +export function parseAgentQuestion(text: string): MobileChatQuestion | null { + if (typeof text !== 'string' || text.trim().length === 0) { + return null + } + + const lines = text.replace(/\r\n/g, '\n').split('\n') + const parsed: { index: number; option: ParsedOption }[] = [] + + lines.forEach((line, index) => { + const option = parseOptionLine(line) + if (option) { + parsed.push({ index, option }) + } + }) + + if (parsed.length === 0) { + return null + } + + const firstOptionIndex = parsed[0].index + const options = parsed.map((p) => p.option.label) + const optionTokens = parsed.map((p) => p.option.token) + + // Find the introducing question: nearest non-empty, non-option line above the + // first option. + let question = '' + let questionLooksLikePrompt = false + for (let i = firstOptionIndex - 1; i >= 0; i--) { + if (lines[i].trim().length === 0 || parseOptionLine(lines[i])) { + continue + } + question = lines[i] + questionLooksLikePrompt = QUESTION_LINE.test(lines[i]) + break + } + + // Conservative gate: a single bare option with no introducing prompt is more + // likely stray prose (a lone "- item") than a real choice list. + if (options.length < 2 && !questionLooksLikePrompt) { + return null + } + + const multiSelect = MULTI_SELECT_HINT.test(text) && options.length > 1 + + return { + question: question.length > 0 ? cleanQuestionText(question) : 'Choose an option', + options, + multiSelect, + optionTokens + } +} + +/** + * Build the text to send to the agent terminal for the selected option(s). + * Convention: echo the option's leading marker (number/letter) when the list had + * one; otherwise send the option label text (TUIs accept the literal choice). + * Multi-select answers are comma-joined; single-select is sent as-is. Unknown + * entries (free-text escape hatch) pass through verbatim. Returns '' when + * nothing is selected. + */ +export function formatQuestionAnswer(question: MobileChatQuestion, selected: string[]): string { + const labels = selected.map((s) => s.trim()).filter((s) => s.length > 0) + if (labels.length === 0) { + return '' + } + + const parts = labels.map((label) => { + const index = question.options.indexOf(label) + if (index === -1) { + // Free-text / unknown entry: pass the user's text straight through. + return label + } + const token = question.optionTokens[index] + return token != null && token.length > 0 ? token : label + }) + + return parts.join(question.multiSelect ? ', ' : ' ') +} diff --git a/mobile/src/session/mobile-native-chat-render-data.test.ts b/mobile/src/session/mobile-native-chat-render-data.test.ts new file mode 100644 index 000000000000..e933971220d0 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-render-data.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { + buildMobileNativeChatData, + mobileNativeChatEmptyState +} from './mobile-native-chat-render-data' + +function assistant(id: string, text: string): NativeChatMessage { + return { + id, + role: 'assistant', + blocks: [{ type: 'text', text }], + timestamp: 0, + source: 'transcript' + } +} + +function user(id: string, text: string): NativeChatMessage { + return { id, role: 'user', blocks: [{ type: 'text', text }], timestamp: 0, source: 'transcript' } +} + +describe('mobileNativeChatEmptyState', () => { + it('invites a first message naming the agent, matching desktop copy', () => { + // waiting-session (live agent, no transcript) and ready (loaded, empty) both + // resolve to the shared "empty" copy with the agent label substituted. + const waiting = mobileNativeChatEmptyState('waiting-session', 'claude') + expect(waiting).toEqual({ + title: 'Start a chat with Claude', + subtitle: 'Ask Claude to inspect code, explain output, or make a change.' + }) + expect(mobileNativeChatEmptyState('ready', 'codex')?.title).toBe('Start a chat with Codex') + }) + + it('falls back to "the agent" when the agent is unknown', () => { + expect(mobileNativeChatEmptyState('waiting-session', null)?.title).toBe( + 'Start a chat with the agent' + ) + }) + + it('prefers the provided error message over the default subtitle', () => { + expect(mobileNativeChatEmptyState('error', 'claude', 'boom')?.subtitle).toBe('boom') + expect(mobileNativeChatEmptyState('error', 'claude')?.subtitle).toBe( + 'The transcript could not be read. Toggle back to the terminal to keep working.' + ) + }) + + it('returns null for states that show no empty copy', () => { + expect(mobileNativeChatEmptyState('loading', 'claude')).toBeNull() + expect(mobileNativeChatEmptyState('idle', 'claude')).toBeNull() + }) +}) + +describe('buildMobileNativeChatData', () => { + it('appends pending optimistic messages at the tail as user turns', () => { + const { data } = buildMobileNativeChatData({ + messages: [assistant('a1', 'hello')], + streamingText: undefined, + pending: [{ id: 'p1', text: 'queued' }] + }) + const last = data[data.length - 1] + expect(last.id).toBe('p1') + expect(last.role).toBe('user') + expect(last.blocks).toEqual([{ type: 'text', text: 'queued' }]) + }) + + it('adds a synthetic streaming bubble while the partial text leads the transcript', () => { + const { streaming, data } = buildMobileNativeChatData({ + messages: [user('u1', 'hi')], + streamingText: 'thinking out loud', + pending: [] + }) + expect(streaming).toBe('thinking out loud') + expect(data.some((m) => m.id === 'streaming')).toBe(true) + }) + + it('shows a short new streaming reply even after a longer previous turn', () => { + // The last folded turn is a long completed reply; a short new stream must not + // be suppressed just for being shorter than the prior turn. + const { streaming, data } = buildMobileNativeChatData({ + messages: [assistant('a1', 'This is a long completed previous answer that ran on a while')], + streamingText: 'Ok', + pending: [] + }) + expect(streaming).toBe('Ok') + expect(data.some((m) => m.id === 'streaming')).toBe(true) + }) + + it('drops the streaming bubble once the real assistant turn already contains it', () => { + const { streaming, data } = buildMobileNativeChatData({ + messages: [assistant('a1', 'done answer')], + streamingText: 'done', + pending: [] + }) + expect(streaming).toBeNull() + expect(data.some((m) => m.id === 'streaming')).toBe(false) + }) + + it('returns no streaming bubble for empty/whitespace streaming text', () => { + expect( + buildMobileNativeChatData({ messages: [], streamingText: ' ', pending: [] }).streaming + ).toBeNull() + expect(buildMobileNativeChatData({ messages: [], pending: [] }).streaming).toBeNull() + }) +}) diff --git a/mobile/src/session/mobile-native-chat-render-data.ts b/mobile/src/session/mobile-native-chat-render-data.ts new file mode 100644 index 000000000000..74ca01939baf --- /dev/null +++ b/mobile/src/session/mobile-native-chat-render-data.ts @@ -0,0 +1,115 @@ +import { formatAgentTypeLabel } from '../../../src/shared/agent-type-label' +import { + formatNativeChatEmptyStateCopy, + type NativeChatEmptyStateCopy +} from '../../../src/shared/native-chat-empty-state' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { foldToolMessages } from './mobile-native-chat-blocks' +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 + * desktop `NativeChatEmptyState` (shared copy + agent label) so the two surfaces + * stay in lockstep. Returns null when the list should stay bare (idle, or the + * loading spinner owns the view). */ +export function mobileNativeChatEmptyState( + status: MobileNativeChatStatus, + agent: string | null, + error?: string +): NativeChatEmptyStateCopy | null { + const agentLabel = agent ? formatAgentTypeLabel(agent) : 'the agent' + switch (status) { + // A live agent with no transcript yet — and a loaded-but-empty transcript — + // are both "start a chat"; invite the first message instead of implying the + // agent is still starting up. + case 'waiting-session': + case 'ready': + return formatNativeChatEmptyStateCopy('empty', agentLabel) + case 'error': { + const copy = formatNativeChatEmptyStateCopy('error', agentLabel) + return error ? { ...copy, subtitle: error } : copy + } + default: + return null + } +} + +/** Derive the list data from the raw transcript: fold tool turns into the + * assistant turn, optionally append a synthetic streaming bubble, then the + * route-owned optimistic "queued" messages at the tail. Returns the + * intermediate `folded`/`streaming` so the caller can memoize on them. */ +export function buildMobileNativeChatData({ + messages, + streamingText, + pending +}: { + messages: NativeChatMessage[] + streamingText?: string + pending: Array<{ id: string; text: string }> +}): { folded: NativeChatMessage[]; streaming: string | null; data: NativeChatMessage[] } { + const folded = foldMobileNativeChatMessages(messages) + return buildMobileNativeChatTransientData({ folded, streamingText, pending }) +} + +export function foldMobileNativeChatMessages(messages: NativeChatMessage[]): NativeChatMessage[] { + return foldToolMessages(stripNoiseMessages(messages)) +} + +export function buildMobileNativeChatTransientData({ + folded, + streamingText, + pending +}: { + folded: NativeChatMessage[] + streamingText?: string + pending: Array<{ id: string; text: string }> +}): { folded: NativeChatMessage[]; streaming: string | null; data: NativeChatMessage[] } { + // Only show the streaming bubble while its text leads the transcript — once the + // real assistant turn lands with the same text, drop the synthetic one. + const streaming = deriveStreaming(folded, streamingText) + const data: NativeChatMessage[] = [ + ...folded, + ...(streaming + ? [ + { + id: 'streaming', + role: 'assistant' as const, + blocks: [{ type: 'text' as const, text: streaming }], + timestamp: null, + source: 'hook' as const + } + ] + : []), + ...pending.map((p) => ({ + id: p.id, + role: 'user' as const, + blocks: [{ type: 'text' as const, text: p.text }], + timestamp: null, + source: 'transcript' as const + })) + ] + return { folded, streaming, data } +} + +function deriveStreaming(folded: NativeChatMessage[], streamingText?: string): string | null { + const text = streamingText?.trim() + if (!text) { + return null + } + const last = folded[folded.length - 1] + const lastText = + last?.role === 'assistant' + ? last.blocks + .filter((b) => b.type === 'text') + .map((b) => (b.type === 'text' ? b.text : '')) + .join('') + .trim() + : '' + // Hide the synthetic bubble only once the real turn has landed leading with the + // streamed text. A bare length compare would suppress a short new reply behind a + // longer previous turn; a completed prior turn won't start with the new prefix. + if (lastText.startsWith(text)) { + return null + } + return text +} diff --git a/mobile/src/session/mobile-native-chat-send.test.ts b/mobile/src/session/mobile-native-chat-send.test.ts new file mode 100644 index 000000000000..01f4d5289bad --- /dev/null +++ b/mobile/src/session/mobile-native-chat-send.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { sendMobileNativeChatMessage } from './mobile-native-chat-send' + +function clientWithResponse(response: unknown): RpcClient { + return { + sendRequest: vi.fn().mockResolvedValue(response) + } as unknown as RpcClient +} + +describe('sendMobileNativeChatMessage', () => { + it('returns true only when the terminal accepts the send', async () => { + const client = clientWithResponse({ + id: 'request', + ok: true, + result: { send: { accepted: true } }, + _meta: { runtimeId: 'runtime' } + }) + + await expect( + sendMobileNativeChatMessage({ + client, + terminal: 'term', + text: 'hello', + mobileClient: { id: 'device', type: 'mobile' } + }) + ).resolves.toBe(true) + expect(client.sendRequest).toHaveBeenCalledWith('terminal.send', { + terminal: 'term', + text: 'hello', + enter: true, + client: { id: 'device', type: 'mobile' } + }) + }) + + it('returns false when the terminal rejects the send', async () => { + const client = clientWithResponse({ + id: 'request', + ok: true, + result: { send: { accepted: false } }, + _meta: { runtimeId: 'runtime' } + }) + + await expect( + sendMobileNativeChatMessage({ client, terminal: 'term', text: 'hello' }) + ).resolves.toBe(false) + }) + + it('returns false when the RPC fails', async () => { + const client = { + sendRequest: vi.fn().mockRejectedValue(new Error('disconnected')) + } as unknown as RpcClient + + await expect( + sendMobileNativeChatMessage({ client, terminal: 'term', text: 'hello' }) + ).resolves.toBe(false) + }) + + it('sends a single non-submitting Escape for prompt cancellation', async () => { + const client = clientWithResponse({ + id: 'request', + ok: true, + result: { send: { accepted: true } }, + _meta: { runtimeId: 'runtime' } + }) + + await sendMobileNativeChatMessage({ + client, + terminal: 'term', + text: String.fromCharCode(27), + enter: false + }) + expect(client.sendRequest).toHaveBeenCalledWith('terminal.send', { + terminal: 'term', + text: String.fromCharCode(27), + enter: false + }) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-send.ts b/mobile/src/session/mobile-native-chat-send.ts new file mode 100644 index 000000000000..4cf1776843e5 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-send.ts @@ -0,0 +1,27 @@ +import type { RpcClient } from '../transport/rpc-client' +import { isTerminalSendRpcAccepted } from '../terminal/terminal-send-rpc-response' + +type MobileTerminalClient = { + id: string + type: 'mobile' +} + +export async function sendMobileNativeChatMessage(args: { + client: RpcClient + terminal: string + text: string + enter?: boolean + mobileClient?: MobileTerminalClient +}): Promise { + try { + const response = await args.client.sendRequest('terminal.send', { + terminal: args.terminal, + text: args.text, + enter: args.enter ?? true, + ...(args.mobileClient ? { client: args.mobileClient } : {}) + }) + return isTerminalSendRpcAccepted(response) + } catch { + return false + } +} diff --git a/mobile/src/session/mobile-native-chat-stream-frame.test.ts b/mobile/src/session/mobile-native-chat-stream-frame.test.ts new file mode 100644 index 000000000000..8d7d1e7e07a4 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-stream-frame.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest' +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 { + return { + id, + role: 'assistant', + blocks: [{ type: 'text', text: id }], + timestamp: 0, + source: 'transcript' + } +} + +describe('applyMobileNativeChatStreamFrame', () => { + it('uses the first snapshot as the ordered base and carries pagination state', () => { + const merger = createNativeChatMerger() + const result = applyMobileNativeChatStreamFrame({ + merger, + frame: { + type: 'snapshot', + messages: [message('a'), message('b')], + hasMore: true, + beforeOffset: 123 + }, + limit: 40, + replaceSnapshot: true + }) + + expect(result).toEqual({ + kind: 'messages', + messages: [message('a'), message('b')], + hasMore: true, + beforeOffset: 123 + }) + }) + + it('merges reconnect snapshots and live appends into the bounded window', () => { + const merger = createNativeChatMerger() + replaceList(merger, [message('a'), message('b')]) + + const result = applyMobileNativeChatStreamFrame({ + merger, + frame: { type: 'snapshot', messages: [message('b'), message('c'), message('d')] }, + limit: 3, + replaceSnapshot: false + }) + + expect(result).toMatchObject({ + kind: 'messages', + messages: [message('b'), message('c'), message('d')], + cursorInvalidated: true + }) + }) + + it('keeps the pagination cursor when an append does not trim history', () => { + const merger = createNativeChatMerger() + replaceList(merger, [message('a')]) + + expect( + applyMobileNativeChatStreamFrame({ + merger, + frame: { type: 'appended', messages: [message('b')] }, + limit: 3, + replaceSnapshot: false + }) + ).toEqual({ kind: 'messages', messages: [message('a'), message('b')] }) + }) + + it('replaces stale history for an explicit transcript replacement frame', () => { + const merger = createNativeChatMerger() + replaceList(merger, [message('old')]) + + expect( + applyMobileNativeChatStreamFrame({ + merger, + frame: { type: 'replacement', messages: [message('new')], hasMore: false }, + limit: 40, + replaceSnapshot: false + }) + ).toEqual({ kind: 'messages', messages: [message('new')], hasMore: false }) + }) + + it('surfaces snapshot errors and ignores unrelated frames', () => { + const merger = createNativeChatMerger() + expect( + applyMobileNativeChatStreamFrame({ + merger, + frame: { type: 'snapshot', error: 'Transcript unavailable' }, + limit: 40, + replaceSnapshot: true + }) + ).toEqual({ kind: 'error', error: 'Transcript unavailable' }) + expect( + applyMobileNativeChatStreamFrame({ + merger, + frame: { type: 'error', message: 'Socket closed' }, + limit: 40, + replaceSnapshot: false + }) + ).toEqual({ kind: 'error', error: 'Socket closed' }) + expect( + applyMobileNativeChatStreamFrame({ + merger, + frame: { type: 'end' }, + limit: 40, + replaceSnapshot: true + }) + ).toEqual({ kind: 'ignored' }) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-stream-frame.ts b/mobile/src/session/mobile-native-chat-stream-frame.ts new file mode 100644 index 000000000000..690994f02f3d --- /dev/null +++ b/mobile/src/session/mobile-native-chat-stream-frame.ts @@ -0,0 +1,63 @@ +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { applyAppend, replaceList, type NativeChatMerger } from './mobile-native-chat-merge' + +export type MobileNativeChatStreamFrame = { + type?: string + messages?: NativeChatMessage[] + hasMore?: boolean + beforeOffset?: number + error?: string + message?: string +} + +export type AppliedMobileNativeChatFrame = + | { kind: 'ignored' } + | { kind: 'error'; error: string } + | { + kind: 'messages' + messages: NativeChatMessage[] + hasMore?: boolean + beforeOffset?: number + cursorInvalidated?: boolean + } + +/** Applies runtime stream frames while preserving the initial-snapshot versus + * reconnect-replay distinction owned by the session hook. */ +export function applyMobileNativeChatStreamFrame(args: { + merger: NativeChatMerger + frame: MobileNativeChatStreamFrame + limit: number + replaceSnapshot: boolean +}): AppliedMobileNativeChatFrame { + const { merger, frame, limit, replaceSnapshot } = args + if (frame.type === 'error') { + return { kind: 'error', error: frame.message ?? frame.error ?? 'Transcript stream failed' } + } + if (frame.type !== 'snapshot' && frame.type !== 'replacement' && frame.type !== 'appended') { + return { kind: 'ignored' } + } + if (frame.error) { + return { kind: 'error', error: frame.error } + } + if (!Array.isArray(frame.messages)) { + return { kind: 'ignored' } + } + if (frame.type === 'replacement' || (frame.type === 'snapshot' && replaceSnapshot)) { + replaceList(merger, frame.messages) + return { + kind: 'messages', + messages: merger.list, + hasMore: frame.hasMore, + ...(frame.beforeOffset == null ? {} : { beforeOffset: frame.beforeOffset }) + } + } + const previousFirstId = merger.list[0]?.id + const messages = applyAppend(merger, frame.messages, limit) + return { + kind: 'messages', + messages, + // Why: once the bounded live window drops its oldest row, the snapshot's + // byte cursor no longer describes the oldest retained message. + ...(previousFirstId && messages[0]?.id !== previousFirstId ? { cursorInvalidated: true } : {}) + } +} diff --git a/mobile/src/session/mobile-native-chat-terminal-stream.test.ts b/mobile/src/session/mobile-native-chat-terminal-stream.test.ts new file mode 100644 index 000000000000..cc92c6b3acab --- /dev/null +++ b/mobile/src/session/mobile-native-chat-terminal-stream.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest' +import { + isTerminalCoveredByNativeChat, + mobileNativeChatTerminalCapabilities, + resolveMobileNativeChatTerminalStreamAction +} from './mobile-native-chat-terminal-stream' + +const base = { + showNativeChat: false, + activeHandle: 'pty-1', + activeTabType: 'terminal', + streamActive: true, + streamCovered: false, + webViewReady: true +} + +describe('mobile native-chat terminal stream lifecycle', () => { + it('pauses an active terminal stream while chat covers it', () => { + expect(resolveMobileNativeChatTerminalStreamAction({ ...base, showNativeChat: true })).toBe( + 'pause' + ) + expect( + resolveMobileNativeChatTerminalStreamAction({ + ...base, + showNativeChat: true, + streamActive: false + }) + ).toBe('pause') + expect(isTerminalCoveredByNativeChat(true, 'pty-1', 'pty-1')).toBe(true) + expect(mobileNativeChatTerminalCapabilities(true)).toEqual({ + terminalBinaryStream: 1, + mobileInputLeaseOnly: 1 + }) + expect(mobileNativeChatTerminalCapabilities(false)).toEqual({ terminalBinaryStream: 1 }) + }) + + it('records a cold-start cover before WebView readiness so return refreshes', () => { + expect( + resolveMobileNativeChatTerminalStreamAction({ + ...base, + showNativeChat: true, + streamActive: false, + webViewReady: false + }) + ).toBe('pause') + expect( + resolveMobileNativeChatTerminalStreamAction({ + ...base, + streamActive: true, + streamCovered: true + }) + ).toBe('resume') + }) + + it('resumes only the ready active terminal after chat closes', () => { + expect(resolveMobileNativeChatTerminalStreamAction({ ...base, streamActive: false })).toBe( + 'resume' + ) + expect( + resolveMobileNativeChatTerminalStreamAction({ + ...base, + streamActive: false, + webViewReady: false + }) + ).toBe('none') + expect(resolveMobileNativeChatTerminalStreamAction({ ...base, streamCovered: true })).toBe( + 'resume' + ) + }) + + it('does nothing for non-terminal tabs, missing handles, or settled states', () => { + expect(resolveMobileNativeChatTerminalStreamAction(base)).toBe('none') + expect( + resolveMobileNativeChatTerminalStreamAction({ + ...base, + showNativeChat: true, + streamActive: false, + streamCovered: true + }) + ).toBe('none') + expect(resolveMobileNativeChatTerminalStreamAction({ ...base, activeTabType: 'browser' })).toBe( + 'none' + ) + expect(resolveMobileNativeChatTerminalStreamAction({ ...base, activeHandle: null })).toBe( + 'none' + ) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-terminal-stream.ts b/mobile/src/session/mobile-native-chat-terminal-stream.ts new file mode 100644 index 000000000000..387fe9c3580e --- /dev/null +++ b/mobile/src/session/mobile-native-chat-terminal-stream.ts @@ -0,0 +1,37 @@ +export type MobileNativeChatTerminalStreamAction = 'pause' | 'resume' | 'none' + +/** Decides whether the active mobile terminal stream should run while native chat + * covers its WebView. Resume is allowed only once the mounted WebView is ready. */ +export function resolveMobileNativeChatTerminalStreamAction(args: { + showNativeChat: boolean + activeHandle: string | null + activeTabType: string | null + streamActive: boolean + streamCovered: boolean + webViewReady: boolean +}): MobileNativeChatTerminalStreamAction { + if (!args.activeHandle || args.activeTabType !== 'terminal') { + return 'none' + } + if (args.showNativeChat) { + return !args.streamCovered ? 'pause' : 'none' + } + return (args.streamCovered || !args.streamActive) && args.webViewReady ? 'resume' : 'none' +} + +export function isTerminalCoveredByNativeChat( + showNativeChat: boolean, + activeHandle: string | null, + handle: string +): boolean { + return showNativeChat && activeHandle === handle +} + +export function mobileNativeChatTerminalCapabilities(covered: boolean): { + terminalBinaryStream: 1 + mobileInputLeaseOnly?: 1 +} { + return covered + ? { terminalBinaryStream: 1, mobileInputLeaseOnly: 1 } + : { terminalBinaryStream: 1 } +} diff --git a/mobile/src/session/mobile-native-chat-toggle-action.ts b/mobile/src/session/mobile-native-chat-toggle-action.ts new file mode 100644 index 000000000000..32ae700671a4 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-toggle-action.ts @@ -0,0 +1,37 @@ +import { MessageSquare, SquareTerminal } from 'lucide-react-native' +import type { ActionSheetAction } from '../components/ActionSheetModal' +import { resolveMobileNativeChat, type MobileNativeChatTab } from './mobile-native-chat-eligibility' + +type ToggleTab = MobileNativeChatTab & { + id: string + terminal: string | null +} + +/** Builds the optional terminal/chat switch shown in a terminal's long-press menu. */ +export function getMobileNativeChatToggleActions(args: { + terminalHandle: string | null + tabs: readonly ToggleTab[] + chatTabIds: ReadonlySet + nativeChatTranscriptIsLocalReadable: boolean + onClose: () => void + onToggle: (tabId: string) => void +}): ActionSheetAction[] { + const { terminalHandle, tabs, chatTabIds, onClose, onToggle } = args + const tab = terminalHandle + ? tabs.find((candidate) => candidate.terminal === terminalHandle) + : null + if (!tab || !resolveMobileNativeChat(tab, args.nativeChatTranscriptIsLocalReadable)) { + return [] + } + const isChat = chatTabIds.has(tab.id) + return [ + { + label: isChat ? 'Switch to terminal view' : 'Switch to chat view', + icon: isChat ? SquareTerminal : MessageSquare, + onPress: () => { + onClose() + onToggle(tab.id) + } + } + ] +} diff --git a/mobile/src/session/mobile-native-chat-tool-summary.test.ts b/mobile/src/session/mobile-native-chat-tool-summary.test.ts new file mode 100644 index 000000000000..d08deec4b31e --- /dev/null +++ b/mobile/src/session/mobile-native-chat-tool-summary.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from 'vitest' +import { briefToolArg, summarizeToolInput, toolFilePath } from './mobile-native-chat-tool-summary' + +describe('summarizeToolInput', () => { + it('passes short strings through, collapsing whitespace', () => { + expect(summarizeToolInput('ls -la\n/tmp')).toBe('ls -la /tmp') + }) + + it('serializes objects to compact JSON', () => { + expect(summarizeToolInput({ path: 'a.ts', limit: 5 })).toBe('{"path":"a.ts","limit":5}') + }) + + it('truncates long previews with an ellipsis', () => { + const long = 'x'.repeat(200) + const out = summarizeToolInput(long) + expect(out.length).toBe(80) + expect(out.endsWith('…')).toBe(true) + }) + + it('returns empty string for null/undefined', () => { + expect(summarizeToolInput(null)).toBe('') + expect(summarizeToolInput(undefined)).toBe('') + }) + + it('handles numbers and booleans', () => { + expect(summarizeToolInput(42)).toBe('42') + expect(summarizeToolInput(true)).toBe('true') + }) + + it('does not traverse an unbounded tool-input object for a short preview', () => { + const input: Record = Object.fromEntries( + Array.from({ length: 8 }, (_unused, index) => [`key${index}`, index]) + ) + const lateGetter = vi.fn(() => 'should not be read') + Object.defineProperty(input, 'late', { enumerable: true, get: lateGetter }) + + expect(summarizeToolInput(input)).not.toBe('') + expect(lateGetter).not.toHaveBeenCalled() + }) +}) + +describe('toolFilePath', () => { + it('extracts file_path / filePath / path / notebook_path', () => { + expect(toolFilePath({ file_path: 'src/a.ts' })).toBe('src/a.ts') + expect(toolFilePath({ filePath: 'src/b.ts' })).toBe('src/b.ts') + expect(toolFilePath({ path: 'src/c.ts' })).toBe('src/c.ts') + expect(toolFilePath({ notebook_path: 'n.ipynb' })).toBe('n.ipynb') + }) + it('returns null when there is no file target', () => { + expect(toolFilePath({ command: 'ls' })).toBeNull() + expect(toolFilePath('x')).toBeNull() + expect(toolFilePath(null)).toBeNull() + }) +}) + +describe('briefToolArg', () => { + it('takes the basename of a forward-slash path', () => { + expect(briefToolArg({ path: 'src/session/app.ts' })).toBe('app.ts') + }) + + it('takes the basename of a Windows backslash path from a raw transcript', () => { + expect(briefToolArg({ file_path: 'C:\\Users\\me\\project\\app.tsx' })).toBe('app.tsx') + }) +}) diff --git a/mobile/src/session/mobile-native-chat-tool-summary.ts b/mobile/src/session/mobile-native-chat-tool-summary.ts new file mode 100644 index 000000000000..bc3f7a2f89af --- /dev/null +++ b/mobile/src/session/mobile-native-chat-tool-summary.ts @@ -0,0 +1,7 @@ +export { + briefToolArg, + countToolCalls, + summarizeToolInput, + summarizeToolRun, + toolFilePath +} from '../../../src/shared/native-chat-tool-summary' diff --git a/mobile/src/session/mobile-native-chat-view-styles.ts b/mobile/src/session/mobile-native-chat-view-styles.ts new file mode 100644 index 000000000000..160477cfad80 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-view-styles.ts @@ -0,0 +1,109 @@ +import { StyleSheet } from 'react-native' +import { colors, spacing, typography } from '../theme/mobile-theme' + +export const styles = StyleSheet.create({ + root: { + flex: 1, + backgroundColor: colors.bgBase + }, + chromeRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + minHeight: 28, + paddingHorizontal: spacing.md + }, + chromeLeft: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + }, + stopButton: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs + }, + stopLabel: { + color: colors.statusRed, + fontSize: typography.metaSize, + fontWeight: '700' + }, + sendError: { + alignItems: 'center', + paddingHorizontal: spacing.md, + paddingBottom: spacing.xs + }, + sendErrorText: { + color: colors.statusRed, + fontSize: typography.metaSize, + fontWeight: '600' + }, + chromeToggle: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + paddingVertical: 4, + paddingHorizontal: spacing.xs + }, + chromeToggleLabel: { + color: colors.textMuted, + fontSize: typography.metaSize, + fontWeight: '600' + }, + pressed: { + opacity: 0.6 + }, + listWrap: { + flex: 1, + position: 'relative' + }, + listContent: { + paddingVertical: spacing.sm, + flexGrow: 1 + }, + center: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + padding: spacing.xl + }, + emptyTitle: { + color: colors.textSecondary, + fontSize: typography.bodySize, + fontWeight: '600', + textAlign: 'center', + marginBottom: spacing.xs + }, + emptySubtitle: { + color: colors.textMuted, + fontSize: typography.metaSize, + textAlign: 'center' + }, + fab: { + position: 'absolute', + right: spacing.md, + width: 38, + height: 38, + borderRadius: 19, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.bgRaised, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.borderSubtle + }, + fabBottom: { + bottom: spacing.md + }, + loadEarlier: { + alignItems: 'center', + justifyContent: 'center', + paddingVertical: spacing.md, + minHeight: 36 + }, + loadEarlierText: { + color: colors.textMuted, + fontSize: typography.metaSize, + fontWeight: '600' + } +}) diff --git a/mobile/src/session/mobile-terminal-action-sheet-actions.ts b/mobile/src/session/mobile-terminal-action-sheet-actions.ts new file mode 100644 index 000000000000..3b29a6dfaf7d --- /dev/null +++ b/mobile/src/session/mobile-terminal-action-sheet-actions.ts @@ -0,0 +1,69 @@ +import { Eraser, Monitor, Smartphone } from 'lucide-react-native' +import type { ActionSheetAction } from '../components/ActionSheetModal' +import type { MobileNativeChatTab } from './mobile-native-chat-eligibility' +import { getMobileNativeChatToggleActions } from './mobile-native-chat-toggle-action' + +type TerminalTab = MobileNativeChatTab & { id: string; terminal: string | null } + +/** Builds the terminal long-press menu without adding another action block to the + * already dense session route. Native chat stays first as the view switch. */ +export function getMobileTerminalActionSheetActions(args: { + target: Target | null + tabs: readonly TerminalTab[] + chatTabIds: ReadonlySet + nativeChatTranscriptIsLocalReadable: boolean + onDismiss: () => void + onToggleChat: (tabId: string) => void + isPhoneMode: (handle: string) => boolean + onToggleDisplayMode: (handle: string) => void + onRename: (target: Target) => void + onClear: (target: Target) => void + onClose: (target: Target) => void +}): ActionSheetAction[] { + const { target } = args + if (!target) { + return [] + } + const phoneMode = args.isPhoneMode(target.handle) + return [ + ...getMobileNativeChatToggleActions({ + terminalHandle: target.handle, + tabs: args.tabs, + chatTabIds: args.chatTabIds, + nativeChatTranscriptIsLocalReadable: args.nativeChatTranscriptIsLocalReadable, + onClose: args.onDismiss, + onToggle: args.onToggleChat + }), + { + label: phoneMode ? 'Switch to Desktop' : 'Switch to Phone', + icon: phoneMode ? Monitor : Smartphone, + onPress: () => { + args.onDismiss() + args.onToggleDisplayMode(target.handle) + } + }, + { + label: 'Rename', + onPress: () => { + args.onDismiss() + args.onRename(target) + } + }, + { + label: 'Clear Terminal', + icon: Eraser, + onPress: () => { + args.onDismiss() + args.onClear(target) + } + }, + { + label: 'Close', + destructive: true, + onPress: () => { + args.onDismiss() + args.onClose(target) + } + } + ] +} diff --git a/mobile/src/session/mobile-terminal-stream-subscribe.test.ts b/mobile/src/session/mobile-terminal-stream-subscribe.test.ts new file mode 100644 index 000000000000..8d3785efdcf3 --- /dev/null +++ b/mobile/src/session/mobile-terminal-stream-subscribe.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { subscribeMobileTerminalSafely } from './mobile-terminal-stream-subscribe' + +describe('subscribeMobileTerminalSafely', () => { + it('reports a synchronous subscribe failure and returns a safe no-op cleanup', () => { + const onSynchronousError = vi.fn() + const client = { + subscribe: vi.fn(() => { + throw new Error('socket closed') + }) + } as unknown as Pick + + const unsubscribe = subscribeMobileTerminalSafely( + client, + { terminal: 'terminal-1' }, + vi.fn(), + onSynchronousError + ) + + expect(onSynchronousError).toHaveBeenCalledOnce() + expect(unsubscribe).not.toThrow() + }) +}) diff --git a/mobile/src/session/mobile-terminal-stream-subscribe.ts b/mobile/src/session/mobile-terminal-stream-subscribe.ts new file mode 100644 index 000000000000..189b07a74f0c --- /dev/null +++ b/mobile/src/session/mobile-terminal-stream-subscribe.ts @@ -0,0 +1,17 @@ +import type { RpcClient } from '../transport/rpc-client' + +export function subscribeMobileTerminalSafely( + client: Pick, + params: unknown, + onData: Parameters[2], + onSynchronousError: () => void +): () => void { + try { + return client.subscribe('terminal.subscribe', params, onData) + } catch { + // Why: a transport mock or closing socket can reject before returning an + // unsubscribe handle; callers must still release their subscribing marker. + onSynchronousError() + return () => {} + } +} 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 new file mode 100644 index 000000000000..a69d66accdde --- /dev/null +++ b/mobile/src/session/use-mobile-attachment-input-lease-gate.test.ts @@ -0,0 +1,134 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useMobileAttachmentInputLeaseGate } from './use-mobile-attachment-input-lease-gate' + +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() + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + vi.useRealTimers() + errorSpy?.mockRestore() + }) + + function renderGate(args: { + connState: { current: string } + activeHandle: { current: string | null } + tabType: { current: string | null } + leaseReady: { current: boolean } + showToast: (message: string, durationMs?: number) => void + }): { gate: () => Gate } { + let gate: Gate = () => Promise.resolve(false) + function Probe(): null { + gate = useMobileAttachmentInputLeaseGate({ + flushPendingLiveInputBeforeExternalSend: () => Promise.resolve(true), + connStateRef: args.connState, + activeHandleRef: args.activeHandle, + activeSessionTabTypeRef: args.tabType, + nativeChatInputLeaseReadyRef: args.leaseReady, + showToast: args.showToast + }) + return null + } + act(() => { + renderer = create(createElement(Probe)) + }) + return { gate: () => gate } + } + + function baseRefs(): { + connState: { current: string } + activeHandle: { current: string | null } + tabType: { current: string | null } + leaseReady: { current: boolean } + } { + return { + connState: { current: 'connected' }, + activeHandle: { current: 'terminal-1' }, + tabType: { current: 'terminal' }, + leaseReady: { current: true } + } + } + + it('passes immediately when the lease is ready and the target is active', async () => { + const refs = baseRefs() + const showToast = vi.fn() + const { gate } = renderGate({ ...refs, showToast }) + + await expect(gate()('terminal-1')).resolves.toBe(true) + expect(showToast).not.toHaveBeenCalled() + }) + + it('waits out a lease-not-ready window and then sends', async () => { + const refs = baseRefs() + refs.leaseReady.current = false + const showToast = vi.fn() + const { gate } = renderGate({ ...refs, showToast }) + + const result = gate()('terminal-1') + await vi.advanceTimersByTimeAsync(200) + refs.leaseReady.current = true + await vi.advanceTimersByTimeAsync(100) + await expect(result).resolves.toBe(true) + expect(showToast).not.toHaveBeenCalled() + }) + + it('surfaces a toast when the lease never recovers', async () => { + const refs = baseRefs() + refs.leaseReady.current = false + const showToast = vi.fn() + const { gate } = renderGate({ ...refs, showToast }) + + const result = gate()('terminal-1') + await vi.advanceTimersByTimeAsync(3200) + await expect(result).resolves.toBe(false) + expect(showToast).toHaveBeenCalledWith('Attach failed (reconnecting)', 1500) + }) + + it('drops silently when the target changes while waiting for the lease', async () => { + const refs = baseRefs() + refs.leaseReady.current = false + const showToast = vi.fn() + const { gate } = renderGate({ ...refs, showToast }) + + const result = gate()('terminal-1') + // Mid-wait the user switches tabs: the lease recovers, but for a different + // target — the attach must not proceed against the stale handle. + await vi.advanceTimersByTimeAsync(200) + refs.activeHandle.current = 'terminal-2' + refs.leaseReady.current = true + await vi.advanceTimersByTimeAsync(100) + await expect(result).resolves.toBe(false) + expect(showToast).not.toHaveBeenCalled() + }) + + it('drops silently when the connection is lost while waiting', async () => { + const refs = baseRefs() + refs.leaseReady.current = false + const showToast = vi.fn() + const { gate } = renderGate({ ...refs, showToast }) + + const result = gate()('terminal-1') + await vi.advanceTimersByTimeAsync(200) + refs.connState.current = 'reconnecting' + await vi.advanceTimersByTimeAsync(3200) + await expect(result).resolves.toBe(false) + expect(showToast).not.toHaveBeenCalled() + }) +}) diff --git a/mobile/src/session/use-mobile-attachment-input-lease-gate.ts b/mobile/src/session/use-mobile-attachment-input-lease-gate.ts new file mode 100644 index 000000000000..a0ae29b6c51c --- /dev/null +++ b/mobile/src/session/use-mobile-attachment-input-lease-gate.ts @@ -0,0 +1,75 @@ +import { useCallback } from 'react' + +type CurrentRef = { readonly current: T } + +type AttachmentInputLeaseGateArgs = { + readonly flushPendingLiveInputBeforeExternalSend: (handle: string) => Promise + readonly connStateRef: CurrentRef + readonly activeHandleRef: CurrentRef + readonly activeSessionTabTypeRef: CurrentRef + readonly nativeChatInputLeaseReadyRef: CurrentRef + readonly showToast: (message: string, durationMs?: number) => void +} + +// Poll cadence + ceiling for riding out a terminal resubscribe (WS reconnect or +// return-to-terminal) during which the input lease is briefly not ready. +const LEASE_READY_POLL_MS = 100 +const LEASE_READY_TIMEOUT_MS = 3000 + +/** Gates an image attachment's terminal.send on a ready input lease. Flushes any + * pending IME/live input, confirms the send still targets the connected terminal + * tab, then waits out a short lease-not-ready window so a finished upload isn't + * dropped as if the picker were cancelled. Returns false only when the lease + * never recovers — surfacing a toast, since the caller treats a bare false as a + * silent picker-cancel (no error path). */ +export function useMobileAttachmentInputLeaseGate({ + flushPendingLiveInputBeforeExternalSend, + connStateRef, + activeHandleRef, + activeSessionTabTypeRef, + nativeChatInputLeaseReadyRef, + showToast +}: AttachmentInputLeaseGateArgs): (targetHandle: string) => Promise { + return useCallback( + async (targetHandle: string): Promise => { + const flushedPendingInput = await flushPendingLiveInputBeforeExternalSend(targetHandle) + // Why: image picking/upload and IME flushing can outlive the original tab. + if ( + !flushedPendingInput || + connStateRef.current !== 'connected' || + targetHandle !== activeHandleRef.current || + activeSessionTabTypeRef.current !== 'terminal' + ) { + return false + } + const deadline = Date.now() + LEASE_READY_TIMEOUT_MS + while (!nativeChatInputLeaseReadyRef.current && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, LEASE_READY_POLL_MS)) + } + // Why: the wait can outlive the target too — re-check so a tab/host switch + // or disconnect mid-wait doesn't send into the wrong (or dead) terminal. + // A moved-away target drops silently like the pre-wait guard; only a lease + // that never recovered warrants the toast. + if ( + connStateRef.current !== 'connected' || + targetHandle !== activeHandleRef.current || + activeSessionTabTypeRef.current !== 'terminal' + ) { + return false + } + if (nativeChatInputLeaseReadyRef.current) { + return true + } + showToast('Attach failed (reconnecting)', 1500) + return false + }, + [ + activeHandleRef, + activeSessionTabTypeRef, + connStateRef, + flushPendingLiveInputBeforeExternalSend, + nativeChatInputLeaseReadyRef, + showToast + ] + ) +} 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 new file mode 100644 index 000000000000..79a11b6d723e --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-answer-send.test.ts @@ -0,0 +1,243 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentType } from '../../../src/shared/native-chat-types' +import type { RpcClient } from '../transport/rpc-client' +import { MOBILE_NATIVE_CHAT_QUESTION_STEP_MS } from './mobile-native-chat-answer-stepping' +import type { AskPrompt } from './mobile-native-chat-ask' +import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send' + +type AnswerSend = ReturnType + +function acceptedResponse() { + return { + id: 'send', + ok: true as const, + result: { send: { accepted: true } }, + _meta: { runtimeId: 'runtime' } + } +} + +const TABS_OR_SPACES: AskPrompt = { + questions: [ + { + question: 'Tabs or spaces?', + multiSelect: false, + options: [{ label: 'Tabs' }, { label: 'Spaces' }] + } + ] +} + +describe('useMobileNativeChatAnswerSend', () => { + let renderer: ReactTestRenderer | null = null + let answerSend: AnswerSend | null = null + let mountedClient: RpcClient | null = null + let mountedOnSendError: ((message: string) => void) | null = null + let mountedAgent: AgentType = 'claude' + + beforeEach(() => { + vi.useFakeTimers() + globalThis.IS_REACT_ACT_ENVIRONMENT = true + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + answerSend = null + mountedClient = null + mountedOnSendError = null + mountedAgent = 'claude' + vi.useRealTimers() + }) + + function Harness({ enabled }: { enabled: boolean }): null { + answerSend = useMobileNativeChatAnswerSend({ + client: mountedClient, + enabled, + handleRef: { current: 'terminal' }, + deviceTokenRef: { current: 'device' }, + agentRef: { current: mountedAgent }, + sessionId: 'session', + streamIdentity: 'host\0worktree\0tab\0session', + onSendError: mountedOnSendError! + }) + return null + } + + async function mount( + client: RpcClient, + onSendError: (message: string) => void, + agent: AgentType = 'claude' + ): Promise { + 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) + }) + try { + await act(async () => { + renderer = create(createElement(Harness, { enabled: true })) + }) + } finally { + consoleSpy.mockRestore() + } + } + + async function setEnabled(enabled: boolean): Promise { + await act(async () => { + renderer?.update(createElement(Harness, { enabled })) + }) + } + + it('single-select: sends the picked option NUMBER (not the label), no trailing Enter', async () => { + const sendRequest = vi.fn().mockResolvedValue(acceptedResponse()) + await mount({ sendRequest } as unknown as RpcClient, vi.fn()) + + // Spaces is option 2 — the STA-1860 case where label text committed Tabs. + await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(true) + expect(sendRequest).toHaveBeenCalledTimes(1) + expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '2', enter: false }) + }) + + it('multi-select: toggles each option number, steps to Submit, confirms — paced apart', async () => { + const sendRequest = vi.fn().mockResolvedValue(acceptedResponse()) + await mount({ sendRequest } as unknown as RpcClient, vi.fn()) + + const prompt: AskPrompt = { + questions: [ + { + question: 'Pick fruits', + multiSelect: true, + options: [{ label: 'Apple' }, { label: 'Banana' }, { label: 'Cherry' }] + } + ] + } + let result: Promise | undefined + await act(async () => { + result = answerSend?.answerAsk(prompt, [{ indices: [0, 2] }]) + }) + expect(sendRequest).toHaveBeenCalledTimes(1) + expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '1', enter: false }) + + await act(async () => vi.advanceTimersByTimeAsync(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS)) + expect(sendRequest.mock.calls[1]?.[1]).toMatchObject({ text: '3', enter: false }) + await act(async () => vi.advanceTimersByTimeAsync(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS)) + expect(sendRequest.mock.calls[2]?.[1]).toMatchObject({ text: '\x1b[C', enter: false }) + await act(async () => vi.advanceTimersByTimeAsync(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS)) + await expect(result).resolves.toBe(true) + expect(sendRequest.mock.calls[3]?.[1]).toMatchObject({ text: '\r', enter: false }) + }) + + it('multi-question: option numbers auto-advance, one final submit Enter', async () => { + const sendRequest = vi.fn().mockResolvedValue(acceptedResponse()) + await mount({ sendRequest } as unknown as RpcClient, vi.fn()) + + const prompt: AskPrompt = { + questions: [ + { question: 'q1', multiSelect: false, options: [{ label: 'A' }, { label: 'B' }] }, + { question: 'q2', multiSelect: false, options: [{ label: 'C' }, { label: 'D' }] } + ] + } + let result: Promise | undefined + await act(async () => { + result = answerSend?.answerAsk(prompt, [{ indices: [1] }, { indices: [0] }]) + }) + await act(async () => vi.runAllTimersAsync()) + + await expect(result).resolves.toBe(true) + expect(sendRequest.mock.calls.map((call) => call[1])).toEqual([ + expect.objectContaining({ text: '2', enter: false }), + expect.objectContaining({ text: '1', enter: false }), + expect.objectContaining({ text: '\r', enter: false }) + ]) + }) + + it('free text: opens "Type something", types the sanitized answer, then Enter', async () => { + const sendRequest = vi.fn().mockResolvedValue(acceptedResponse()) + await mount({ sendRequest } as unknown as RpcClient, vi.fn()) + + let result: Promise | undefined + await act(async () => { + // A newline in raw keystrokes would submit early — must collapse to space. + result = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [], other: 'zeta\nspaces' }]) + }) + await act(async () => vi.runAllTimersAsync()) + + await expect(result).resolves.toBe(true) + expect(sendRequest.mock.calls.map((call) => call[1])).toEqual([ + expect.objectContaining({ text: '3', enter: false }), + expect.objectContaining({ text: 'zeta spaces', enter: false }), + expect.objectContaining({ text: '\r', enter: false }) + ]) + }) + + it('answers OpenClaude asks with Claude selector keystrokes', async () => { + const sendRequest = vi.fn().mockResolvedValue(acceptedResponse()) + await mount({ sendRequest } as unknown as RpcClient, vi.fn(), 'openclaude') + + await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(true) + expect(sendRequest).toHaveBeenCalledTimes(1) + expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '2', enter: false }) + }) + + it('submits a non-Claude answer as pasted label text with a single Enter', async () => { + const sendRequest = vi.fn().mockResolvedValue(acceptedResponse()) + await mount({ sendRequest } as unknown as RpcClient, vi.fn(), 'codex') + + await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(true) + // Codex's question tool commits the pasted answer: label text + one Enter. + expect(sendRequest).toHaveBeenCalledTimes(1) + expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: 'Spaces', enter: true }) + }) + + it('stops at the first rejected write and reports failure', async () => { + const onSendError = vi.fn() + const sendRequest = vi.fn().mockResolvedValue({ + id: 'send', + ok: true, + result: { send: { accepted: false } }, + _meta: { runtimeId: 'runtime' } + }) + await mount({ sendRequest } as unknown as RpcClient, onSendError) + + await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(false) + expect(sendRequest).toHaveBeenCalledTimes(1) + expect(onSendError).toHaveBeenCalledWith('Answer not sent') + }) + + it('rejects an empty selection without writing anything', async () => { + const sendRequest = vi.fn().mockResolvedValue(acceptedResponse()) + await mount({ sendRequest } as unknown as RpcClient, vi.fn()) + + await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [] }])).resolves.toBe(false) + expect(sendRequest).not.toHaveBeenCalled() + }) + + it('cancels delayed keystrokes when the acknowledged input lease is lost', async () => { + const sendRequest = vi.fn().mockResolvedValue(acceptedResponse()) + await mount({ sendRequest } as unknown as RpcClient, vi.fn()) + + const prompt: AskPrompt = { + questions: [ + { question: 'q1', multiSelect: false, options: [{ label: 'A' }, { label: 'B' }] }, + { question: 'q2', multiSelect: false, options: [{ label: 'C' }, { label: 'D' }] } + ] + } + let result: Promise | undefined + await act(async () => { + result = answerSend?.answerAsk(prompt, [{ indices: [1] }, { indices: [0] }]) + }) + expect(sendRequest).toHaveBeenCalledTimes(1) + + await setEnabled(false) + await act(async () => vi.runAllTimersAsync()) + + await expect(result).resolves.toBe(false) + expect(sendRequest).toHaveBeenCalledTimes(1) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-answer-send.ts b/mobile/src/session/use-mobile-native-chat-answer-send.ts new file mode 100644 index 000000000000..255f60de3a76 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-answer-send.ts @@ -0,0 +1,176 @@ +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, + formatAskAnswer, + hasAskAnswer, + type AskAnswerSelection, + type AskPrompt +} from './mobile-native-chat-ask' +import { sendMobileNativeChatMessage } from './mobile-native-chat-send' +import { shouldStepNativeChatAskAnswer } from '../../../src/shared/native-chat-agent-support' + +/** Sends an AskUserQuestion answer to the active chat pane. Claude's selector is + * answered by option-number keystrokes; other agents get pasted label text. + * Extracted from the session route to keep that file under its line cap and to + * own the pending-timer lifecycle in one place. */ +export type MobileNativeChatAnswerSend = { + /** Answer the current question(s) from the card's per-question selections. */ + answerAsk: (prompt: AskPrompt, selections: AskAnswerSelection[]) => Promise + /** Drop any in-flight per-keystroke writes (call on Stop). */ + cancelPending: () => void +} + +// A free-text answer is written as raw keystrokes into Claude's "Type something" +// input (terminal.send has no paste framing), so an embedded newline would +// submit it early — collapse line breaks to spaces. +function sanitizeAskFreeText(text: string): string { + return text.replace(/[\r\n]+/g, ' ') +} + +/** + * Owns the ask-answer send sequence for the mobile native chat. Reads the live + * pane/agent through refs (the route already keeps them current) so the returned + * callbacks stay stable. Claude answers are delivered as `buildAskAnswerKeys` + * keystroke groups written one selector-step apart over the EXISTING + * `terminal.send` passthrough (raw text, no enter) — same contract the + * permission card already uses, so old runtimes replay them verbatim (no new + * RPC; keystrokes are built client-side). The scheduled wait chain is cancelled + * on a new answer, on `cancelPending` (Stop), and on unmount / session swap — so + * a detached chain can never write PTY bytes to a stale pane. + */ +export function useMobileNativeChatAnswerSend(args: { + client: RpcClient | null + enabled: boolean + handleRef: MutableRefObject + deviceTokenRef: MutableRefObject + agentRef: MutableRefObject + /** Changes on chat session swap; cancels pending writes when it does. */ + sessionId: string | null + streamIdentity: string + onSendError: (message: string) => void +}): MobileNativeChatAnswerSend { + const { + client, + enabled, + handleRef, + deviceTokenRef, + agentRef, + sessionId, + streamIdentity, + onSendError + } = args + const generationRef = useRef(0) + const activeRouteRef = useRef({ client, enabled, sessionId, streamIdentity }) + activeRouteRef.current = { client, enabled, sessionId, streamIdentity } + const delaysRef = useRef< + Set<{ timer: ReturnType; resolve: (completed: boolean) => void }> + >(new Set()) + + const cancelPending = useCallback(() => { + generationRef.current += 1 + for (const delay of delaysRef.current) { + clearTimeout(delay.timer) + delay.resolve(false) + } + delaysRef.current.clear() + }, []) + + // Cancel pending writes on unmount and whenever the chat session swaps. + useEffect(() => { + if (!enabled) { + cancelPending() + } + return cancelPending + }, [client, enabled, sessionId, streamIdentity, cancelPending]) + + const answerAsk = useCallback( + async (prompt: AskPrompt, selections: AskAnswerSelection[]): Promise => { + const handle = handleRef.current + if (!client || !handle || !enabled) { + onSendError('Answer not sent (disconnected)') + return false + } + if (!hasAskAnswer(prompt, selections)) { + return false + } + // A new answer supersedes any still-pending keystroke writes. + cancelPending() + const generation = generationRef.current + const sendTerminal = (body: string, enter: boolean): Promise => { + const activeRoute = activeRouteRef.current + if ( + !activeRoute.enabled || + activeRoute.client !== client || + activeRoute.sessionId !== sessionId || + activeRoute.streamIdentity !== streamIdentity || + handleRef.current !== handle + ) { + return Promise.resolve(false) + } + return sendMobileNativeChatMessage({ + client, + terminal: handle, + text: body, + enter, + ...(deviceTokenRef.current + ? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } } + : {}) + }) + } + const wait = (ms: number): Promise => + new Promise((resolve) => { + const delay = { + timer: setTimeout(() => { + delaysRef.current.delete(delay) + resolve(generationRef.current === generation) + }, ms), + resolve + } + delaysRef.current.add(delay) + }) + const fail = (): false => { + if (generationRef.current === generation) { + onSendError('Answer not sent') + } + return false + } + // Non-Claude question tools commit a pasted answer, so send the label text + // with one Enter. Claude's arrow-navigate selector ignores pasted labels + // (STA-1860): drive it by option-number keystrokes instead, one group per + // selector step so each renders before the next lands. + if (!shouldStepNativeChatAskAnswer(agentRef.current)) { + return (await sendTerminal(formatAskAnswer(prompt, selections), true)) || fail() + } + const groups = buildAskAnswerKeys(prompt, selections) + for (let index = 0; index < groups.length; index += 1) { + if (generationRef.current !== generation) { + return false + } + const group = groups[index]! + const body = 'raw' in group ? group.raw : sanitizeAskFreeText(group.text) + if (!(await sendTerminal(body, false))) { + return fail() + } + if (index < groups.length - 1 && !(await wait(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS))) { + return false + } + } + return groups.length > 0 + }, + [ + agentRef, + enabled, + cancelPending, + client, + deviceTokenRef, + handleRef, + onSendError, + sessionId, + streamIdentity + ] + ) + + return { answerAsk, cancelPending } +} 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 new file mode 100644 index 000000000000..8c81f0255c2a --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts @@ -0,0 +1,60 @@ +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 './mobile-native-chat-ask' +import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss' + +describe('useMobileNativeChatAskDismiss', () => { + let renderer: ReactTestRenderer | null = null + let state: ReturnType | null = null + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + state = null + }) + + function Harness({ prompt }: { prompt: AskPrompt }): null { + state = useMobileNativeChatAskDismiss(prompt) + return null + } + + const first: AskPrompt = { + questions: [ + { question: 'same first', multiSelect: false, options: [] }, + { question: 'old second', multiSelect: false, options: [] } + ] + } + const replacement: AskPrompt = { + questions: [ + { question: 'same first', multiSelect: false, options: [] }, + { question: 'new second', multiSelect: false, options: [] } + ] + } + + it('shows a structurally different replacement without an intervening null', 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, { prompt: first })) + }) + } finally { + consoleSpy.mockRestore() + } + act(() => state?.dismissAsk()) + expect(state?.showAsk).toBe(false) + + await act(async () => renderer?.update(createElement(Harness, { prompt: replacement }))) + expect(state?.showAsk).toBe(true) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-ask-dismiss.ts b/mobile/src/session/use-mobile-native-chat-ask-dismiss.ts new file mode 100644 index 000000000000..8f7289f07531 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-ask-dismiss.ts @@ -0,0 +1,26 @@ +import { useEffect, useState } from 'react' +import type { AskPrompt } from './mobile-native-chat-ask' + +/** Track the answered-ask key so the lingering live status doesn't re-show the + * same card. The agent emits a post-tool event with the same prompt right after + * an answer, so the card is hidden until a genuinely different question arrives. */ +export function useMobileNativeChatAskDismiss(ask?: AskPrompt | null): { + askKey: string | null + showAsk: boolean + dismissAsk: () => void +} { + const askKey = ask ? JSON.stringify(ask.questions) : null + const [dismissedAskKey, setDismissedAskKey] = useState(null) + // Once the prompt clears (agent moved on), forget the dismissal so a later + // question — even an identical one — shows again instead of staying hidden. + const askPresent = ask != null + useEffect(() => { + if (!askPresent) { + setDismissedAskKey(null) + } + }, [askPresent]) + const showAsk = askPresent && askKey !== dismissedAskKey + const dismissAsk = (): void => setDismissedAskKey(askKey) + + return { askKey, showAsk, dismissAsk } +} diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts new file mode 100644 index 000000000000..cca9ae3f9231 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -0,0 +1,314 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type Dispatch, + type MutableRefObject, + type SetStateAction +} from 'react' +import { loadNativeChatTabIds, saveNativeChatTabIds } from '../storage/preferences' +import type { RpcClient } from '../transport/rpc-client' +import { + parseAskFromStatus, + type AskAnswerSelection, + type AskPrompt +} from './mobile-native-chat-ask' +import { type MobileNativeChatTab, resolveMobileNativeChat } from './mobile-native-chat-eligibility' +import { detectAgentPermission } from './mobile-native-chat-permission' +import { parseAgentQuestion } from './mobile-native-chat-question' +import { openMobileNativeChatFile } from './mobile-native-chat-open-file' +import { useMobileNativeChatPermissionSend } from './mobile-native-chat-permission-send' +import { sendMobileNativeChatMessage } from './mobile-native-chat-send' +import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send' +import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' +import { useMobileNativeChatFileSearch } from './use-mobile-native-chat-file-search' +import { useMobileNativeChatSession } from './use-mobile-native-chat-session' +import { useMobileNativeChatPrompts } from './use-mobile-native-chat-prompts' +import { useMobileNativeChatStop } from './use-mobile-native-chat-stop' +import { useThrottledLatestValue } from './use-throttled-latest-value' + +const NATIVE_CHAT_STREAM_THROTTLE_MS = 50 + +export type MobileNativeChatController = { + chatTabIds: Set + toggleTabChatView: (tabId: string) => void + showNativeChat: boolean + showNativeChatRef: MutableRefObject + /** Resolved agent for the active chat tab (names the empty-state copy). */ + nativeChatAgent: string | null + chatComposerText: string + setChatComposerText: Dispatch> + chatPending: Array<{ id: string; text: string }> + nativeChatSession: ReturnType + nativeChatAgentWorking: boolean + nativeChatStreamingText?: string + nativeChatPermission: ReturnType + nativeChatQuestion: ReturnType + nativeChatAsk: ReturnType + handleNativeChatOpenFile: (relativePath: string) => void + handleNativeChatAnswerAsk: ( + prompt: AskPrompt, + selections: AskAnswerSelection[] + ) => Promise + handleNativeChatCancelAsk: () => Promise + handleNativeChatRespondPermission: (text: string) => Promise + handleNativeChatStop: () => void + nativeChatFilePaths: string[] + loadNativeChatFiles: (query: string) => void + handleNativeChatSend: (text: string) => Promise +} + +/** Owns mobile native-chat state and teardown outside the already dense session + * route. The route remains responsible only for choosing and rendering the view. */ +export function useMobileNativeChatController(args: { + client: RpcClient | null + hostId: string + worktreeId: string + activeSessionTab: MobileNativeChatTab | null + activeSessionTabId: string | null + activeHandleRef: MutableRefObject + deviceTokenRef: MutableRefObject + nativeChatTranscriptIsLocalReadable: boolean + nativeChatInputLeaseReady: boolean + onSendError: (message: string) => void +}): MobileNativeChatController { + const { + client, + hostId, + worktreeId, + activeSessionTab, + activeSessionTabId, + activeHandleRef, + deviceTokenRef, + nativeChatTranscriptIsLocalReadable, + nativeChatInputLeaseReady, + onSendError + } = args + const [chatTabIds, setChatTabIds] = useState>(new Set()) + const chatTabIdsToggledRef = useRef(false) + + useEffect(() => { + let active = true + // Re-arm per host/worktree so the fresh load can seed the new selection. + chatTabIdsToggledRef.current = false + void loadNativeChatTabIds(hostId, worktreeId).then((ids) => { + // Why: a toggle before this load resolves is authoritative; the load must + // not revert the user's in-memory choice back to the persisted state. + if (active && !chatTabIdsToggledRef.current) { + // Why: route reuse must clear the prior worktree's selection even when + // the newly loaded worktree has no saved chat tabs. + setChatTabIds(new Set(ids)) + } + }) + return () => { + active = false + } + }, [hostId, worktreeId]) + + const toggleTabChatView = useCallback( + (tabId: string) => { + chatTabIdsToggledRef.current = true + setChatTabIds((previous) => { + const next = new Set(previous) + if (next.has(tabId)) { + next.delete(tabId) + } else { + next.add(tabId) + } + void saveNativeChatTabIds(hostId, worktreeId, [...next]) + return next + }) + }, + [hostId, worktreeId] + ) + + const activeChatResolution = + activeSessionTab && activeSessionTabId && chatTabIds.has(activeSessionTabId) + ? resolveMobileNativeChat(activeSessionTab, nativeChatTranscriptIsLocalReadable) + : null + const showNativeChat = activeChatResolution != null + const showNativeChatRef = useRef(showNativeChat) + showNativeChatRef.current = showNativeChat + const activeChatAgentRef = useRef(activeChatResolution?.agent ?? null) + activeChatAgentRef.current = activeChatResolution?.agent ?? null + + const activeChatSessionId = activeChatResolution?.sessionId ?? null + const streamIdentity = `${hostId}\0${worktreeId}\0${activeSessionTabId ?? ''}\0${activeChatSessionId ?? ''}\0${activeHandleRef.current ?? ''}` + + const nativeChatSession = useMobileNativeChatSession({ + client, + agent: activeChatResolution?.agent ?? null, + sessionId: activeChatSessionId, + transcriptPath: activeChatResolution?.transcriptPath ?? null + }) + const { + composerText: chatComposerText, + setComposerText: setChatComposerText, + pending: chatPending, + captureSendOrigin, + acceptSend + } = useMobileNativeChatDrafts({ + hostId, + worktreeId, + tabId: activeSessionTabId, + sessionId: activeChatSessionId, + messages: nativeChatSession.messages + }) + + const nativeChatStatus = activeChatResolution ? activeSessionTab?.agentStatus : null + const nativeChatAgentWorking = nativeChatStatus?.state === 'working' + // Throttle the streaming bubble: OpenCode emits a status frame per streamed + // part, and each one re-renders and re-parses the whole accumulated markdown. + const nativeChatStreamingText = useThrottledLatestValue( + nativeChatAgentWorking ? nativeChatStatus?.lastAssistantMessage : undefined, + NATIVE_CHAT_STREAM_THROTTLE_MS + ) + const { + permission: nativeChatPermission, + question: nativeChatQuestion, + ask: nativeChatAsk + } = useMobileNativeChatPrompts({ + enabled: activeChatResolution != null, + status: nativeChatStatus, + messages: nativeChatSession.messages + }) + + const handleNativeChatOpenFile = useCallback( + (pathText: string) => { + if (!client) { + return + } + void openMobileNativeChatFile({ + client, + worktreeId, + pathText, + terminal: activeHandleRef.current + }) + }, + [activeHandleRef, client, worktreeId] + ) + + const { answerAsk: handleNativeChatAnswerAsk, cancelPending: cancelNativeChatAnswer } = + useMobileNativeChatAnswerSend({ + client, + enabled: nativeChatInputLeaseReady, + handleRef: activeHandleRef, + deviceTokenRef, + agentRef: activeChatAgentRef, + sessionId: activeChatSessionId, + streamIdentity, + onSendError + }) + + const handleNativeChatCancelAsk = useCallback(async (): Promise => { + const handle = activeHandleRef.current + if (!client || !handle || !nativeChatInputLeaseReady) { + onSendError('Cancel not sent (disconnected)') + return false + } + cancelNativeChatAnswer() + const accepted = await sendMobileNativeChatMessage({ + client, + terminal: handle, + text: String.fromCharCode(27), + enter: false, + ...(deviceTokenRef.current + ? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } } + : {}) + }) + if (!accepted) { + onSendError('Cancel not sent') + } + return accepted + }, [ + activeHandleRef, + cancelNativeChatAnswer, + client, + deviceTokenRef, + nativeChatInputLeaseReady, + onSendError + ]) + + const handleNativeChatRespondPermission = useMobileNativeChatPermissionSend({ + client, + enabled: nativeChatInputLeaseReady, + handleRef: activeHandleRef, + deviceTokenRef, + onSendError + }) + + const handleNativeChatStop = useMobileNativeChatStop({ + client, + enabled: nativeChatInputLeaseReady, + handleRef: activeHandleRef, + deviceTokenRef, + streamIdentity, + cancelPending: cancelNativeChatAnswer, + onSendError + }) + + const { nativeChatFilePaths, loadNativeChatFiles } = useMobileNativeChatFileSearch({ + client, + worktreeId + }) + + const handleNativeChatSend = useCallback( + async (text: string): Promise => { + const handle = activeHandleRef.current + const origin = captureSendOrigin(text) + if (!client || !handle || !origin || !nativeChatInputLeaseReady) { + onSendError('Message not sent (disconnected)') + return false + } + const accepted = await sendMobileNativeChatMessage({ + client, + terminal: handle, + text, + ...(deviceTokenRef.current + ? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } } + : {}) + }) + if (!accepted) { + onSendError('Message not sent') + return false + } + acceptSend(origin, text) + return true + }, + [ + acceptSend, + activeHandleRef, + captureSendOrigin, + client, + deviceTokenRef, + nativeChatInputLeaseReady, + onSendError + ] + ) + + return { + chatTabIds, + toggleTabChatView, + showNativeChat, + showNativeChatRef, + nativeChatAgent: activeChatResolution?.agent ?? null, + chatComposerText, + setChatComposerText, + chatPending, + nativeChatSession, + nativeChatAgentWorking, + nativeChatStreamingText, + nativeChatPermission, + nativeChatQuestion, + nativeChatAsk, + handleNativeChatOpenFile, + handleNativeChatAnswerAsk, + handleNativeChatCancelAsk, + handleNativeChatRespondPermission, + handleNativeChatStop, + nativeChatFilePaths, + loadNativeChatFiles, + handleNativeChatSend + } +} diff --git a/mobile/src/session/use-mobile-native-chat-drafts.test.ts b/mobile/src/session/use-mobile-native-chat-drafts.test.ts new file mode 100644 index 000000000000..1505c9d1dc4b --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-drafts.test.ts @@ -0,0 +1,192 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' + +type DraftState = ReturnType + +function userTextMessage(id: string, text: string): NativeChatMessage { + return { + id, + role: 'user', + blocks: [{ type: 'text', text }], + timestamp: null, + source: 'transcript' + } +} + +function assistantTextMessage(id: string, text: string): NativeChatMessage { + return { + id, + role: 'assistant', + blocks: [{ type: 'text', text }], + timestamp: null, + source: 'transcript' + } +} + +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 + state = null + }) + + function Harness({ + tabId, + sessionId = `session-${tabId}`, + messages = [] + }: { + tabId: string + sessionId?: string | null + messages?: NativeChatMessage[] + }): null { + state = useMobileNativeChatDrafts({ + hostId: 'host', + worktreeId: 'worktree', + tabId, + sessionId, + messages + }) + return null + } + + 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) + }) + try { + await act(async () => { + renderer = create(createElement(Harness, { tabId })) + }) + } finally { + consoleSpy.mockRestore() + } + } + + async function switchTo(tabId: string): Promise { + await act(async () => renderer?.update(createElement(Harness, { tabId }))) + } + + it('keeps drafts and accepted pending messages on their originating tabs', async () => { + await mount('a') + act(() => state?.setComposerText('from a')) + const originA = state?.captureSendOrigin('from a') + expect(originA).not.toBeNull() + + await switchTo('b') + act(() => state?.setComposerText('from b')) + act(() => { + if (originA) { + state?.acceptSend(originA, 'from a') + } + }) + expect(state?.composerText).toBe('from b') + expect(state?.pending).toEqual([]) + + await switchTo('a') + expect(state?.composerText).toBe('') + expect(state?.pending.map((pending) => pending.text)).toEqual(['from a']) + }) + + it('clears one pending per landed message so duplicate sends are not all dropped', async () => { + await mount('a') + const origin = state?.captureSendOrigin('ping') + act(() => { + if (origin) { + state?.acceptSend(origin, 'ping') + state?.acceptSend(origin, 'ping') + } + }) + expect(state?.pending.map((pending) => pending.text)).toEqual(['ping', 'ping']) + + await act(async () => + renderer?.update( + createElement(Harness, { tabId: 'a', messages: [userTextMessage('m1', 'ping')] }) + ) + ) + expect(state?.pending.map((pending) => pending.text)).toEqual(['ping']) + }) + + it('does not reconcile a repeated send against an older identical turn', async () => { + await mount('a') + await act(async () => + renderer?.update( + createElement(Harness, { tabId: 'a', messages: [userTextMessage('old', 'ping')] }) + ) + ) + const origin = state?.captureSendOrigin('ping') + act(() => { + if (origin) { + state?.acceptSend(origin, 'ping') + } + }) + + await act(async () => + renderer?.update( + createElement(Harness, { + tabId: 'a', + messages: [userTextMessage('old', 'ping'), assistantTextMessage('other', 'working')] + }) + ) + ) + expect(state?.pending.map((pending) => pending.text)).toEqual(['ping']) + + await act(async () => + renderer?.update( + createElement(Harness, { + tabId: 'a', + messages: [ + userTextMessage('old', 'ping'), + assistantTextMessage('other', 'working'), + userTextMessage('new', 'ping') + ] + }) + ) + ) + expect(state?.pending).toEqual([]) + }) + + it('does not erase newer edits when an older send settles', async () => { + await mount('a') + act(() => state?.setComposerText('submitted')) + const origin = state?.captureSendOrigin('submitted') + act(() => state?.setComposerText('new edit')) + act(() => { + if (origin) { + state?.acceptSend(origin, 'submitted') + } + }) + + expect(state?.composerText).toBe('new edit') + }) + + it('accepts and clears the first send before a provider session id exists', async () => { + await mount('a') + await act(async () => renderer?.update(createElement(Harness, { tabId: 'a', sessionId: null }))) + act(() => state?.setComposerText('start the session')) + + const origin = state?.captureSendOrigin('start the session') + expect(origin).toMatchObject({ pendingKey: null }) + act(() => { + if (origin) { + state?.acceptSend(origin, 'start the session') + } + }) + + expect(state?.composerText).toBe('') + expect(state?.pending).toEqual([]) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-drafts.ts b/mobile/src/session/use-mobile-native-chat-drafts.ts new file mode 100644 index 000000000000..ddd730e227c9 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-drafts.ts @@ -0,0 +1,165 @@ +import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' + +export type MobileNativeChatPendingMessage = { + id: string + text: string + expectedOccurrence: number +} +export type MobileNativeChatSendOrigin = { + draftKey: string + pendingKey: string | null + normalizedText: string + baselineOccurrences: number +} + +const NO_PENDING_MESSAGES: MobileNativeChatPendingMessage[] = [] + +function normalizedUserText(message: NativeChatMessage): string | null { + if (message.role !== 'user') { + return null + } + const text = message.blocks + .filter((block) => block.type === 'text') + .map((block) => (block.type === 'text' ? block.text : '')) + .join('') + .trim() + return text || null +} + +function countUserTextOccurrences(messages: readonly NativeChatMessage[], text: string): number { + let count = 0 + for (const message of messages) { + if (normalizedUserText(message) === text) { + count++ + } + } + return count +} + +export function useMobileNativeChatDrafts(args: { + hostId: string + worktreeId: string + tabId: string | null + sessionId: string | null + messages: readonly NativeChatMessage[] +}): { + composerText: string + setComposerText: Dispatch> + pending: MobileNativeChatPendingMessage[] + captureSendOrigin: (text: string) => MobileNativeChatSendOrigin | null + acceptSend: (origin: MobileNativeChatSendOrigin, text: string) => void +} { + const { hostId, worktreeId, tabId, sessionId, messages } = args + const draftKey = tabId ? `${hostId}\0${worktreeId}\0${tabId}` : null + const pendingKey = draftKey && sessionId ? `${draftKey}\0${sessionId}` : null + const [drafts, setDrafts] = useState>({}) + const [pendingBySession, setPendingBySession] = useState< + Record + >({}) + const pendingCounterRef = useRef(0) + const messagesRef = useRef(messages) + messagesRef.current = messages + + const setComposerText: Dispatch> = useCallback( + (value) => { + if (!draftKey) { + return + } + setDrafts((previous) => { + const current = previous[draftKey] ?? '' + const next = typeof value === 'function' ? value(current) : value + return next === current ? previous : { ...previous, [draftKey]: next } + }) + }, + [draftKey] + ) + + const captureSendOrigin = useCallback( + (text: string) => { + if (!draftKey) { + return null + } + const normalizedText = text.trim() + return { + draftKey, + pendingKey, + normalizedText, + baselineOccurrences: countUserTextOccurrences(messagesRef.current, normalizedText) + } + }, + [draftKey, pendingKey] + ) + + const acceptSend = useCallback((origin: MobileNativeChatSendOrigin, text: string) => { + // Why: an RPC may settle after a tab switch; mutate only the tab that + // originated the send, without erasing edits typed after it began. + setDrafts((previous) => + (previous[origin.draftKey] ?? '').trim() === text.trim() + ? { ...previous, [origin.draftKey]: '' } + : previous + ) + // Why: the first prompt can be sent before the provider reports a session + // id; clear its draft, but wait for an id before keying an optimistic echo. + if (!origin.pendingKey) { + return + } + const pendingKey = origin.pendingKey + pendingCounterRef.current += 1 + setPendingBySession((previous) => { + const current = previous[pendingKey] ?? NO_PENDING_MESSAGES + const earlierOutstanding = current.filter( + (pending) => + pending.text.trim() === origin.normalizedText && + pending.expectedOccurrence > origin.baselineOccurrences + ).length + const pending = { + id: `pending-${pendingCounterRef.current}`, + text, + expectedOccurrence: origin.baselineOccurrences + earlierOutstanding + 1 + } + return { ...previous, [pendingKey]: [...current, pending] } + }) + }, []) + + const pending = pendingKey + ? (pendingBySession[pendingKey] ?? NO_PENDING_MESSAGES) + : NO_PENDING_MESSAGES + useEffect(() => { + if (!pendingKey || pending.length === 0) { + return + } + setPendingBySession((previous) => { + const current = previous[pendingKey] ?? [] + const landedCounts = new Map() + for (const message of messages) { + const text = normalizedUserText(message) + if (text) { + landedCounts.set(text, (landedCounts.get(text) ?? 0) + 1) + } + } + // Why: compare against the count captured before send; historical equal + // turns cannot clear a new echo, while duplicates land one occurrence each. + const next = current.filter( + (item) => (landedCounts.get(item.text.trim()) ?? 0) < item.expectedOccurrence + ) + if (next.length === current.length) { + return previous + } + if (next.length > 0) { + return { ...previous, [pendingKey]: next } + } + const remaining = { ...previous } + delete remaining[pendingKey] + return remaining + }) + }, [messages, pending, pendingKey]) + + return { + composerText: draftKey ? (drafts[draftKey] ?? '') : '', + setComposerText, + pending, + captureSendOrigin, + acceptSend + } +} 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 new file mode 100644 index 000000000000..e05000198445 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-file-search.test.ts @@ -0,0 +1,165 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { useMobileNativeChatFileSearch } from './use-mobile-native-chat-file-search' + +type SearchState = ReturnType + +function rpcSuccess(files: string[]): Awaited> { + return { + id: 'files', + ok: true, + result: { files: files.map((relativePath) => ({ relativePath })) }, + _meta: { runtimeId: 'runtime-1' } + } +} + +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('useMobileNativeChatFileSearch', () => { + let renderer: ReactTestRenderer | null = null + let state: SearchState | null = null + + async function mount(client: RpcClient): Promise { + function Harness(): null { + state = useMobileNativeChatFileSearch({ client, worktreeId: 'wt-1' }) + return null + } + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create(createElement(Harness)) + }) + } finally { + restore() + } + } + + beforeEach(() => { + vi.useFakeTimers() + globalThis.IS_REACT_ACT_ENVIRONMENT = true + state = null + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + vi.useRealTimers() + }) + + it('coalesces rapid queries and retains only the bounded host result', async () => { + const sendRequest = vi.fn().mockResolvedValue(rpcSuccess(['src/app.ts', 'src/app.test.ts'])) + await mount({ sendRequest } as unknown as RpcClient) + + act(() => { + state?.loadNativeChatFiles('a') + state?.loadNativeChatFiles('app') + }) + await act(async () => vi.advanceTimersByTimeAsync(119)) + expect(sendRequest).not.toHaveBeenCalled() + + await act(async () => vi.advanceTimersByTimeAsync(1)) + expect(sendRequest).toHaveBeenCalledTimes(1) + expect(sendRequest).toHaveBeenCalledWith('files.searchPaths', { + worktree: 'id:wt-1', + query: 'app', + limit: 16 + }) + expect(state?.nativeChatFilePaths).toEqual(['src/app.ts', 'src/app.test.ts']) + }) + + it('loads the legacy inventory once when an older host lacks searchPaths', async () => { + const sendRequest = vi.fn(async (method: string) => { + if (method === 'files.searchPaths') { + return { + id: 'missing', + ok: false as const, + error: { code: 'method_not_found', message: 'Unknown method' }, + _meta: { runtimeId: 'runtime-1' } + } + } + return rpcSuccess(['src/apple.ts', 'docs/readme.md']) + }) + await mount({ sendRequest } as unknown as RpcClient) + + act(() => state?.loadNativeChatFiles('apple')) + await act(async () => vi.advanceTimersByTimeAsync(120)) + expect(state?.nativeChatFilePaths).toEqual(['src/apple.ts']) + + act(() => state?.loadNativeChatFiles('readme')) + await act(async () => vi.advanceTimersByTimeAsync(120)) + expect(state?.nativeChatFilePaths).toEqual(['docs/readme.md']) + expect(sendRequest.mock.calls.map(([method]) => method)).toEqual([ + 'files.searchPaths', + 'files.list' + ]) + }) + + it('cancels an in-flight query on a cache hit so a stale result cannot clobber it', async () => { + const sendRequest = vi.fn(async (_method: string, params: { query: string }) => + rpcSuccess(params.query === 'app' ? ['src/app.ts'] : ['src/beta.ts']) + ) + await mount({ sendRequest } as unknown as RpcClient) + + // Populate the cache for 'app'. + act(() => state?.loadNativeChatFiles('app')) + await act(async () => vi.advanceTimersByTimeAsync(120)) + expect(state?.nativeChatFilePaths).toEqual(['src/app.ts']) + + // Schedule 'beta' (debounced, unresolved), then hit the cache for 'app'. + act(() => { + state?.loadNativeChatFiles('beta') + state?.loadNativeChatFiles('app') + }) + expect(state?.nativeChatFilePaths).toEqual(['src/app.ts']) + + // The cancelled 'beta' request must never fire and overwrite the cached result. + await act(async () => vi.advanceTimersByTimeAsync(120)) + expect(state?.nativeChatFilePaths).toEqual(['src/app.ts']) + expect( + sendRequest.mock.calls.filter(([, params]) => (params as { query: string }).query === 'beta') + ).toHaveLength(0) + }) + + it('coalesces overlapping legacy inventory requests on a slow host', async () => { + let resolveList: (value: Awaited>) => void = () => {} + const listResponse = new Promise>>((resolve) => { + resolveList = resolve + }) + const sendRequest = vi.fn((method: string) => { + if (method === 'files.searchPaths') { + return Promise.resolve({ + id: 'missing', + ok: false as const, + error: { code: 'method_not_found', message: 'Unknown method' }, + _meta: { runtimeId: 'runtime-1' } + }) + } + return listResponse + }) + await mount({ sendRequest } as unknown as RpcClient) + + act(() => state?.loadNativeChatFiles('apple')) + await act(async () => vi.advanceTimersByTimeAsync(120)) + act(() => state?.loadNativeChatFiles('readme')) + await act(async () => vi.advanceTimersByTimeAsync(120)) + expect(sendRequest.mock.calls.filter(([method]) => method === 'files.list')).toHaveLength(1) + + await act(async () => { + resolveList(rpcSuccess(['src/apple.ts', 'docs/readme.md'])) + await Promise.resolve() + await Promise.resolve() + }) + expect(state?.nativeChatFilePaths).toEqual(['docs/readme.md']) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-file-search.ts b/mobile/src/session/use-mobile-native-chat-file-search.ts new file mode 100644 index 000000000000..53df98e3ee0c --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-file-search.ts @@ -0,0 +1,146 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import type { RpcClient } from '../transport/rpc-client' +import { rankSuggestions } from './mobile-native-chat-autocomplete' + +const FILE_SEARCH_DEBOUNCE_MS = 120 +const FILE_SEARCH_RESULT_LIMIT = 16 +const FILE_SEARCH_QUERY_CACHE_LIMIT = 20 + +function extractPaths(result: unknown): string[] { + const files = (result as { files?: Array<{ relativePath?: string }> }).files ?? [] + return files + .map((file) => file.relativePath ?? '') + .filter((path): path is string => path.length > 0) +} + +/** Debounces current-host path searches, bounds the mobile result/cache, and + * falls back to the legacy one-time full list when paired to an older host. */ +export function useMobileNativeChatFileSearch(args: { + client: RpcClient | null + worktreeId: string +}): { nativeChatFilePaths: string[]; loadNativeChatFiles: (query: string) => void } { + const { client, worktreeId } = args + const [nativeChatFilePaths, setNativeChatFilePaths] = useState([]) + const timerRef = useRef | null>(null) + const sequenceRef = useRef(0) + const generationRef = useRef(0) + const queryCacheRef = useRef(new Map()) + const legacyPathsRef = useRef(null) + const legacyLoadRef = useRef | null>(null) + const searchSupportedRef = useRef(null) + + useEffect(() => { + sequenceRef.current++ + generationRef.current++ + queryCacheRef.current.clear() + legacyPathsRef.current = null + legacyLoadRef.current = null + searchSupportedRef.current = null + setNativeChatFilePaths([]) + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current) + timerRef.current = null + } + } + }, [client, worktreeId]) + + const loadNativeChatFiles = useCallback( + (query: string) => { + if (!client) { + return + } + const normalizedQuery = query.trim().toLowerCase().slice(0, 256) + const cached = queryCacheRef.current.get(normalizedQuery) + if (cached) { + // Why: cancel and stale-out any in-flight debounced query so an older + // request cannot later clobber this displayed cached result. + if (timerRef.current) { + clearTimeout(timerRef.current) + timerRef.current = null + } + sequenceRef.current++ + setNativeChatFilePaths(cached) + return + } + if (timerRef.current) { + clearTimeout(timerRef.current) + } + const sequence = ++sequenceRef.current + const generation = generationRef.current + setNativeChatFilePaths([]) + timerRef.current = setTimeout(() => { + timerRef.current = null + const applyPaths = (paths: string[]): void => { + if (sequenceRef.current !== sequence) { + return + } + queryCacheRef.current.set(normalizedQuery, paths) + while (queryCacheRef.current.size > FILE_SEARCH_QUERY_CACHE_LIMIT) { + const oldest = queryCacheRef.current.keys().next().value as string | undefined + if (!oldest) { + break + } + queryCacheRef.current.delete(oldest) + } + setNativeChatFilePaths(paths) + } + const loadLegacyPaths = async (): Promise => { + if (!legacyPathsRef.current) { + if (!legacyLoadRef.current) { + const request = client + .sendRequest('files.list', { worktree: `id:${worktreeId}` }) + .then((response) => { + if (!response.ok || generationRef.current !== generation) { + return null + } + const paths = extractPaths(response.result) + legacyPathsRef.current = paths + return paths + }) + .finally(() => { + if (legacyLoadRef.current === request && !legacyPathsRef.current) { + legacyLoadRef.current = null + } + }) + // Why: older hosts expose only the full inventory RPC; queries that + // overlap its slow local/SSH read must share one request. + legacyLoadRef.current = request + } + const paths = await legacyLoadRef.current + if (!paths) { + return + } + } + const legacyPaths = legacyPathsRef.current + if (legacyPaths) { + applyPaths(rankSuggestions(legacyPaths, normalizedQuery, FILE_SEARCH_RESULT_LIMIT)) + } + } + void (async () => { + if (searchSupportedRef.current === false) { + await loadLegacyPaths() + return + } + const response = await client.sendRequest('files.searchPaths', { + worktree: `id:${worktreeId}`, + query: normalizedQuery, + limit: FILE_SEARCH_RESULT_LIMIT + }) + if (response.ok) { + searchSupportedRef.current = true + applyPaths(extractPaths(response.result)) + return + } + if (response.error.code === 'method_not_found') { + searchSupportedRef.current = false + await loadLegacyPaths() + } + })().catch(() => {}) + }, FILE_SEARCH_DEBOUNCE_MS) + }, + [client, worktreeId] + ) + + return { nativeChatFilePaths, loadNativeChatFiles } +} 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 new file mode 100644 index 000000000000..bb90327c5b93 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-input-lease.test.ts @@ -0,0 +1,57 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useMobileNativeChatInputLease } from './use-mobile-native-chat-input-lease' + +type Lease = ReturnType + +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 + lease = null + }) + + function Harness({ connected }: { connected: boolean }): null { + lease = useMobileNativeChatInputLease({ activeHandle: 'terminal', connected }) + return null + } + + 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) + }) + 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() + + 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() + } + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-input-lease.ts b/mobile/src/session/use-mobile-native-chat-input-lease.ts new file mode 100644 index 000000000000..02dfeff84a8e --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-input-lease.ts @@ -0,0 +1,53 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import type { MobileNativeChatInputLockReason } from './MobileNativeChatView' + +export function useMobileNativeChatInputLease(args: { + activeHandle: string | null + connected: boolean +}): { + ready: boolean + readyRef: { readonly current: boolean } + lockReason: MobileNativeChatInputLockReason | null + markReady: (handle: string) => void + clear: (handle?: string) => void +} { + const [readyHandles, setReadyHandles] = useState>(new Set()) + const ready = args.activeHandle != null && readyHandles.has(args.activeHandle) + // Why: absence of an acknowledgement proves only that setup is still pending; + // the protocol does not report evidence that another client owns the floor. + const lockReason: MobileNativeChatInputLockReason | null = !args.connected + ? 'disconnected' + : ready + ? null + : 'waiting' + const readyRef = useRef(ready) + readyRef.current = ready + useEffect(() => { + if (!args.connected) { + setReadyHandles(new Set()) + } + }, [args.connected]) + const markReady = useCallback((handle: string) => { + setReadyHandles((current) => new Set(current).add(handle)) + }, []) + const clear = useCallback((handle?: string) => { + setReadyHandles((current) => { + if (handle === undefined) { + return new Set() + } + if (!current.has(handle)) { + return current + } + const next = new Set(current) + next.delete(handle) + return next + }) + }, []) + return { + ready, + readyRef, + lockReason, + markReady, + clear + } +} diff --git a/mobile/src/session/use-mobile-native-chat-pinch-gesture.ts b/mobile/src/session/use-mobile-native-chat-pinch-gesture.ts new file mode 100644 index 000000000000..3b3cdb2e8a71 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-pinch-gesture.ts @@ -0,0 +1,39 @@ +import { useMemo, useRef, useState } from 'react' +import { Gesture } from 'react-native-gesture-handler' +import type { ComposedGesture } from 'react-native-gesture-handler' +import { clampFontScale } from './mobile-native-chat-message-text' + +/** Pinch-to-zoom chat font. `fontScale` is the committed size; `pinchBase` + * anchors the live gesture so successive pinches compound rather than reset. */ +export function useMobileNativeChatPinchGesture(): { + fontScale: number + pinchGesture: ComposedGesture +} { + const [fontScale, setFontScale] = useState(1) + const fontScaleRef = useRef(1) + fontScaleRef.current = fontScale + const pinchBase = useRef(1) + // Why: run the gesture callbacks on the JS thread (not a reanimated worklet) so + // they can touch React refs/state and clampFontScale directly — accessing those + // from the UI-thread worklet crashes the app. + // Compose the pinch with the list's native scroll as Simultaneous so a + // two-finger pinch is recognized even while the scroll view is active — + // otherwise the scroll grabs the gesture first and the pinch never fires. + const pinchGesture = useMemo( + () => + Gesture.Simultaneous( + Gesture.Native(), + Gesture.Pinch() + .runOnJS(true) + .onStart(() => { + pinchBase.current = fontScaleRef.current + }) + .onUpdate((e) => { + setFontScale(clampFontScale(pinchBase.current * e.scale)) + }) + ), + [] + ) + + return { fontScale, pinchGesture } +} diff --git a/mobile/src/session/use-mobile-native-chat-prompts.ts b/mobile/src/session/use-mobile-native-chat-prompts.ts new file mode 100644 index 000000000000..11a17696bfc7 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-prompts.ts @@ -0,0 +1,47 @@ +import { useMemo } from 'react' +import type { AgentStatusEntry } from '../../../src/shared/agent-status-types' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { extractPendingAsk, parseAskFromStatus } from './mobile-native-chat-ask' +import { detectAgentPermission, parseApprovalFromStatus } from './mobile-native-chat-permission' +import { parseAgentQuestion } from './mobile-native-chat-question' + +export type MobileNativeChatPrompts = { + permission: ReturnType + question: ReturnType + ask: ReturnType +} + +/** Derives the prompt cards shown above the composer. */ +export function useMobileNativeChatPrompts(args: { + enabled: boolean + status: AgentStatusEntry | null | undefined + messages: readonly NativeChatMessage[] +}): MobileNativeChatPrompts { + const { enabled, status, messages } = args + const blocked = status?.state === 'waiting' || status?.state === 'blocked' + const permission = + (blocked && status + ? detectAgentPermission({ + state: status.state, + lastAssistantMessage: status.lastAssistantMessage, + toolName: status.toolName, + toolInput: status.toolInput + }) + : null) ?? parseApprovalFromStatus(status?.interactivePrompt) + const question = + blocked && status && !permission ? parseAgentQuestion(status.lastAssistantMessage ?? '') : null + const askFromStatus = useMemo( + () => parseAskFromStatus(status?.interactivePrompt, status?.toolName), + [status?.interactivePrompt, status?.toolName] + ) + const askFromMessages = useMemo( + () => (askFromStatus ? null : extractPendingAsk(messages)), + [askFromStatus, messages] + ) + + return { + permission, + question, + ask: enabled ? (askFromStatus ?? askFromMessages) : null + } +} diff --git a/mobile/src/session/use-mobile-native-chat-readability.test.ts b/mobile/src/session/use-mobile-native-chat-readability.test.ts new file mode 100644 index 000000000000..46feaf79217d --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-readability.test.ts @@ -0,0 +1,107 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { useMobileNativeChatReadability } from './use-mobile-native-chat-readability' + +describe('useMobileNativeChatReadability', () => { + let renderer: ReactTestRenderer | null = null + let readable = false + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + readable = false + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + async function mount(connectionId: string | null): Promise { + const client = { + sendRequest: vi.fn().mockResolvedValue({ + ok: true, + result: { repos: [{ id: 'repo', connectionId }] } + }) + } as unknown as RpcClient + function Harness(): null { + readable = useMobileNativeChatReadability(client, 'repo::/worktree') + 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) + }) + try { + await act(async () => { + renderer = create(createElement(Harness)) + await Promise.resolve() + }) + } finally { + consoleSpy.mockRestore() + } + } + + it('admits local and runtime-owned transcript hosts', async () => { + await mount(null) + expect(readable).toBe(true) + act(() => renderer?.unmount()) + renderer = null + + await mount('runtime-ssh-environment') + expect(readable).toBe(true) + }) + + it('fails closed for Model-A SSH transcript hosts', async () => { + await mount('model-a-ssh') + expect(readable).toBe(false) + }) + + it('fails closed immediately while a reused route resolves its new worktree', async () => { + let resolveNext: (response: unknown) => void = () => {} + const client = { + sendRequest: vi + .fn() + .mockResolvedValueOnce({ + ok: true, + result: { repos: [{ id: 'local-repo', connectionId: null }] } + }) + .mockImplementationOnce(() => new Promise((resolve) => (resolveNext = resolve))) + } as unknown as RpcClient + function Harness({ worktreeId }: { worktreeId: string }): null { + 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) + }) + try { + await act(async () => { + renderer = create(createElement(Harness, { worktreeId: 'local-repo::/one' })) + await Promise.resolve() + }) + 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() + }) + expect(readable).toBe(false) + } finally { + consoleSpy.mockRestore() + } + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-readability.ts b/mobile/src/session/use-mobile-native-chat-readability.ts new file mode 100644 index 000000000000..e952335fe239 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-readability.ts @@ -0,0 +1,53 @@ +import { useEffect, useState } from 'react' +import type { RpcClient } from '../transport/rpc-client' +import { isMobileNativeChatTranscriptReadable } from './mobile-native-chat-eligibility' +import { getRepoIdFromMobileWorktreeId } from './mobile-session-route-helpers' + +type RepoSummary = { id: string; connectionId?: string | null } +type ReadabilityState = { client: RpcClient | null; worktreeId: string; readable: boolean } + +export function useMobileNativeChatReadability( + client: RpcClient | null, + worktreeId: string +): boolean { + const [state, setState] = useState({ + client: null, + worktreeId: '', + readable: false + }) + useEffect(() => { + let active = true + if (!client) { + setState({ client, worktreeId, readable: false }) + return + } + void client + .sendRequest('repo.list') + .then((response) => { + if (!active) { + return + } + const repos = response.ok + ? ((response.result as { repos?: RepoSummary[] }).repos ?? []) + : [] + const repoId = getRepoIdFromMobileWorktreeId(worktreeId) + const repo = repos.find((candidate) => candidate.id === repoId) + setState({ + client, + worktreeId, + readable: repo ? isMobileNativeChatTranscriptReadable(repo.connectionId ?? null) : false + }) + }) + .catch(() => { + if (active) { + setState({ client, worktreeId, readable: false }) + } + }) + return () => { + active = false + } + }, [client, worktreeId]) + // Why: route reuse renders before its new effect resolves; never expose the + // previous repo's readability under a different client/worktree key. + return state.client === client && state.worktreeId === worktreeId ? state.readable : false +} diff --git a/mobile/src/session/use-mobile-native-chat-session.test.ts b/mobile/src/session/use-mobile-native-chat-session.test.ts new file mode 100644 index 000000000000..584f34624f22 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-session.test.ts @@ -0,0 +1,220 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import type { RpcClient } from '../transport/rpc-client' +import { + useMobileNativeChatSession, + type MobileNativeChatSession +} from './use-mobile-native-chat-session' + +function message(id: string): NativeChatMessage { + return { + id, + role: 'assistant', + blocks: [{ type: 'text', text: id }], + timestamp: 1, + source: 'transcript' + } +} + +describe('useMobileNativeChatSession', () => { + let renderer: ReactTestRenderer | null = null + let state: MobileNativeChatSession | null = null + let emit: (frame: unknown) => void = () => {} + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + state = null + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + function Harness({ client }: { client: RpcClient | null }): null { + state = useMobileNativeChatSession({ + client, + agent: 'claude', + sessionId: 'session', + transcriptPath: null + }) + return null + } + + 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) + }) + try { + await act(async () => { + renderer = create(createElement(Harness, { client })) + }) + } finally { + consoleSpy.mockRestore() + } + } + + it('drops an older-page response captured before transcript replacement', async () => { + let resolveEarlier: (response: unknown) => void = () => {} + const sendRequest = vi.fn( + () => new Promise((resolve) => (resolveEarlier = resolve)) + ) as unknown as RpcClient['sendRequest'] + const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => { + emit = onData + onData({ + type: 'snapshot', + messages: Array.from({ length: 40 }, (_unused, index) => message(`old-${index}`)), + hasMore: true, + beforeOffset: 100 + }) + return () => {} + }) + await mount({ sendRequest, subscribe } as unknown as RpcClient) + act(() => state?.loadEarlier()) + + await act(async () => { + emit({ + type: 'replacement', + messages: [message('replacement')], + hasMore: false, + beforeOffset: 0 + }) + }) + await act(async () => { + resolveEarlier({ + ok: true, + result: { messages: [message('stale-page')], hasMore: false, beforeOffset: 0 } + }) + await Promise.resolve() + }) + + expect(state?.messages.map((entry) => entry.id)).toEqual(['replacement']) + expect(state?.loadingEarlier).toBe(false) + }) + + it('drops an older-page response after the client source disappears', async () => { + let resolveEarlier: (response: unknown) => void = () => {} + const sendRequest = vi.fn( + () => new Promise((resolve) => (resolveEarlier = resolve)) + ) as unknown as RpcClient['sendRequest'] + const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => { + onData({ + type: 'snapshot', + messages: Array.from({ length: 40 }, (_unused, index) => message(`old-${index}`)), + hasMore: true, + beforeOffset: 100 + }) + return () => {} + }) + await mount({ sendRequest, subscribe } as unknown as RpcClient) + act(() => state?.loadEarlier()) + await act(async () => renderer?.update(createElement(Harness, { client: null }))) + await act(async () => { + resolveEarlier({ ok: true, result: { messages: [message('stale-page')] } }) + await Promise.resolve() + }) + + expect(state?.messages).toEqual([]) + expect(state?.status).toBe('idle') + expect(state?.loadingEarlier).toBe(false) + }) + + it.each(['replacement', 'snapshot'] as const)( + 'can page again after an authoritative %s resets a maxed-out read window', + async (frameType) => { + const sendRequest = vi.fn().mockResolvedValue({ + ok: true, + result: { messages: [message('older')], hasMore: true, beforeOffset: 50 } + }) + const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => { + emit = onData + onData({ + type: 'snapshot', + messages: Array.from({ length: 40 }, (_unused, index) => message(`old-${index}`)), + hasMore: true, + beforeOffset: 100 + }) + return () => {} + }) + await mount({ sendRequest, subscribe } as unknown as RpcClient) + for (let page = 0; page < 33; page += 1) { + await act(async () => { + state?.loadEarlier() + await Promise.resolve() + }) + } + const requestsAtCap = sendRequest.mock.calls.length + + await act(async () => + emit({ + type: frameType, + messages: [message('authoritative')], + hasMore: true, + beforeOffset: 500 + }) + ) + await act(async () => { + state?.loadEarlier() + await Promise.resolve() + }) + + expect(sendRequest).toHaveBeenCalledTimes(requestsAtCap + 1) + expect(sendRequest).toHaveBeenLastCalledWith('nativeChat.readSession', { + agent: 'claude', + sessionId: 'session', + limit: 60, + beforeOffset: 500 + }) + } + ) + + it('rejects a cursor page invalidated by live trim and retries with a growing tail', async () => { + let resolveCursorPage: (response: unknown) => void = () => {} + const sendRequest = vi + .fn() + .mockImplementationOnce(() => new Promise((resolve) => (resolveCursorPage = resolve))) + .mockResolvedValueOnce({ + ok: true, + result: { messages: [message('fresh-growing-tail')], hasMore: false } + }) + const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => { + emit = onData + onData({ + type: 'snapshot', + messages: Array.from({ length: 40 }, (_unused, index) => message(`window-${index}`)), + hasMore: true, + beforeOffset: 100 + }) + return () => {} + }) + await mount({ sendRequest, subscribe } as unknown as RpcClient) + act(() => state?.loadEarlier()) + await act(async () => emit({ type: 'appended', messages: [message('live-trim')] })) + await act(async () => { + resolveCursorPage({ + ok: true, + result: { messages: [message('stale-cursor-page')], hasMore: true, beforeOffset: 50 } + }) + await Promise.resolve() + }) + expect(state?.messages.map((entry) => entry.id)).not.toContain('stale-cursor-page') + + await act(async () => { + state?.loadEarlier() + await Promise.resolve() + }) + + expect(sendRequest).toHaveBeenLastCalledWith('nativeChat.readSession', { + agent: 'claude', + sessionId: 'session', + limit: 100 + }) + expect(state?.messages.map((entry) => entry.id)).toEqual(['fresh-growing-tail']) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-session.ts b/mobile/src/session/use-mobile-native-chat-session.ts new file mode 100644 index 000000000000..2d381fdd602e --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-session.ts @@ -0,0 +1,219 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +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 './mobile-native-chat-stream-frame' + +export type MobileNativeChatStatus = 'idle' | 'loading' | 'waiting-session' | 'ready' | 'error' + +export type MobileNativeChatSession = { + messages: NativeChatMessage[] + status: MobileNativeChatStatus + error?: string + /** True when an older page may exist (the last read filled the window). */ + hasMore: boolean + /** Whether an older-history page is currently loading. */ + loadingEarlier: boolean + /** Grow the window to page in older history. */ + loadEarlier: () => void +} + +// Small first page for a fast first paint; grows by a page as the user scrolls. +const INITIAL_LIMIT = 40 +const PAGE = 60 +const MAX_MESSAGES = 2000 + +type ReadSessionResult = + | { messages: NativeChatMessage[]; hasMore?: boolean; beforeOffset?: number } + | { error: string } +/** Subscribe to an agent's native-chat transcript over the paired connection. + * Reads a small recent window for a fast first paint, tails it for live turns, + * and pages in older history on demand. Read results replace the list (they are + * an ordered tail); live appends merge by id so order stays stable. */ +export function useMobileNativeChatSession(args: { + client: RpcClient | null + agent: string | null + sessionId: string | null + transcriptPath: string | null +}): MobileNativeChatSession { + const { client, agent, sessionId, transcriptPath } = args + const [messages, setMessages] = useState([]) + const [status, setStatus] = useState('idle') + const [error, setError] = useState(undefined) + const [hasMore, setHasMore] = useState(false) + const [loadingEarlier, setLoadingEarlier] = useState(false) + const loadingEarlierRef = useRef(false) + const beforeOffsetRef = useRef(null) + // Stateful id-dedup merger: caches the id→index map so each live append frame + // costs O(incoming), not O(existing+incoming) (#18). `replaceList` resets the + // base (read / loadEarlier ordered tails); `applyAppend` folds live frames in. + const mergerRef = useRef(createNativeChatMerger()) + const limitRef = useRef(INITIAL_LIMIT) + // Tracks the live session so a late loadEarlier resolve can detect a swap. + const sessionIdRef = useRef(sessionId) + sessionIdRef.current = sessionId + const streamGenerationRef = useRef(0) + + // Replace the base list (read results are an ordered tail). Resets the merger + // cache so the index is rebuilt once over the new base. + const setList = useCallback((next: readonly NativeChatMessage[]) => { + replaceList(mergerRef.current, next) + setMessages(mergerRef.current.list) + }, []) + + useEffect(() => { + let cancelled = false + // Why: disconnect/agent/session loss must invalidate a page request before + // the early idle/waiting return can clear the visible source. + streamGenerationRef.current += 1 + limitRef.current = INITIAL_LIMIT + loadingEarlierRef.current = false + setLoadingEarlier(false) + setList([]) + setError(undefined) + setHasMore(false) + beforeOffsetRef.current = null + if (!client || !agent) { + setStatus('idle') + return + } + if (!sessionId) { + setStatus('waiting-session') + return + } + + setStatus('loading') + + const unsubscribe = client.subscribe( + 'nativeChat.subscribe', + { + agent, + sessionId, + limit: limitRef.current, + subscriptionId: buildNativeChatSubscriptionId(agent, sessionId), + ...(transcriptPath ? { transcriptPath } : {}) + }, + (raw) => { + if (cancelled) { + return + } + const frame = raw as MobileNativeChatStreamFrame + if (frame.type === 'replacement' || frame.type === 'snapshot') { + // Why: replacement and reconnect snapshots are authoritative windows; + // stale page limits/results must not constrain the fresh generation. + streamGenerationRef.current += 1 + limitRef.current = INITIAL_LIMIT + loadingEarlierRef.current = false + setLoadingEarlier(false) + } + const replaceSnapshot = frame.type === 'snapshot' + const applied = applyMobileNativeChatStreamFrame({ + merger: mergerRef.current, + frame, + limit: limitRef.current, + replaceSnapshot + }) + if (applied.kind === 'ignored') { + return + } + if (applied.kind === 'error') { + setStatus('error') + setError(applied.error) + return + } + setMessages(applied.messages) + if (applied.hasMore != null) { + setHasMore(applied.hasMore) + } + if (applied.beforeOffset != null) { + beforeOffsetRef.current = applied.beforeOffset + } + if (applied.cursorInvalidated) { + // Fall back to a growing-tail read so history trimmed by live appends + // cannot leave a gap between the retained window and the old cursor. + streamGenerationRef.current += 1 + loadingEarlierRef.current = false + setLoadingEarlier(false) + beforeOffsetRef.current = null + } + setStatus('ready') + } + ) + + return () => { + cancelled = true + unsubscribe() + } + }, [client, agent, sessionId, transcriptPath, setList]) + + const loadEarlier = useCallback(() => { + if (!client || !agent || !sessionId || loadingEarlierRef.current || !hasMore) { + return + } + // Capture the session this page belongs to; a swap underneath us must not + // apply this read's result onto the new session (mirrors desktop's guard). + const requestSessionId = sessionId + const requestGeneration = streamGenerationRef.current + const nextLimit = Math.min(limitRef.current + PAGE, MAX_MESSAGES) + const pageLimit = nextLimit - limitRef.current + if (pageLimit <= 0) { + setHasMore(false) + return + } + const beforeOffset = beforeOffsetRef.current + loadingEarlierRef.current = true + setLoadingEarlier(true) + void (async () => { + try { + const response = await client.sendRequest('nativeChat.readSession', { + agent, + sessionId, + limit: beforeOffset === null ? nextLimit : pageLimit, + ...(beforeOffset === null ? {} : { beforeOffset }), + ...(transcriptPath ? { transcriptPath } : {}) + }) + if (!response.ok) { + return + } + const result = response.result as ReadSessionResult + if ('error' in result) { + return + } + // Drop a stale resolve from a session that swapped underneath us. + if ( + sessionIdRef.current !== requestSessionId || + streamGenerationRef.current !== requestGeneration + ) { + return + } + limitRef.current = nextLimit + if (beforeOffset !== null && result.beforeOffset != null) { + beforeOffsetRef.current = result.beforeOffset + setList([...result.messages, ...mergerRef.current.list]) + setHasMore( + nextLimit < MAX_MESSAGES && (result.hasMore ?? result.messages.length >= pageLimit) + ) + } else { + // Older runtimes ignore the cursor and return the growing tail. + setList(result.messages) + setHasMore(result.messages.length >= nextLimit) + } + } finally { + // A late page from a prior tab must not unlock the current tab's request. + if ( + sessionIdRef.current === requestSessionId && + streamGenerationRef.current === requestGeneration + ) { + loadingEarlierRef.current = false + setLoadingEarlier(false) + } + } + })() + }, [client, agent, sessionId, transcriptPath, hasMore, setList]) + + return { messages, status, error, hasMore, loadingEarlier, loadEarlier } +} diff --git a/mobile/src/session/use-mobile-native-chat-stop.test.ts b/mobile/src/session/use-mobile-native-chat-stop.test.ts new file mode 100644 index 000000000000..27385f8d33ee --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-stop.test.ts @@ -0,0 +1,85 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { useMobileNativeChatStop } from './use-mobile-native-chat-stop' + +describe('useMobileNativeChatStop', () => { + let renderer: ReactTestRenderer | null = null + let stop: (() => void) | null = null + const sendRequest = vi.fn() + const onSendError = vi.fn() + + beforeEach(() => { + vi.useFakeTimers() + globalThis.IS_REACT_ACT_ENVIRONMENT = true + sendRequest.mockReset().mockResolvedValue({ ok: true }) + onSendError.mockReset() + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + stop = null + vi.useRealTimers() + }) + + function Harness({ + enabled, + streamIdentity + }: { + enabled: boolean + streamIdentity: string + }): null { + stop = useMobileNativeChatStop({ + client: { sendRequest } as unknown as RpcClient, + enabled, + handleRef: { current: 'terminal-1' }, + deviceTokenRef: { current: 'mobile-1' }, + streamIdentity, + cancelPending: vi.fn(), + onSendError + }) + return null + } + + async function render(enabled: boolean, streamIdentity: string): Promise { + await act(async () => { + const element = createElement(Harness, { enabled, streamIdentity }) + if (renderer) { + renderer.update(element) + } else { + renderer = create(element) + } + }) + } + + it.each([ + ['the acknowledged input lease is lost', false, 'stream-1'], + ['the active stream changes', true, 'stream-2'] + ])('cancels the delayed second Escape when %s', async (_case, enabled, streamIdentity) => { + await render(true, 'stream-1') + + act(() => stop?.()) + expect(sendRequest).toHaveBeenCalledTimes(1) + + await render(enabled as boolean, streamIdentity as string) + await act(async () => vi.runAllTimersAsync()) + + expect(sendRequest).toHaveBeenCalledTimes(1) + }) + + it('handles a rejected Escape without leaking an unhandled rejection', async () => { + sendRequest.mockRejectedValue(new Error('disconnected')) + await render(true, 'stream-1') + + act(() => stop?.()) + await act(async () => { + await Promise.resolve() + await vi.runAllTimersAsync() + }) + + expect(onSendError).toHaveBeenCalledOnce() + expect(onSendError).toHaveBeenCalledWith('Stop not sent') + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-stop.ts b/mobile/src/session/use-mobile-native-chat-stop.ts new file mode 100644 index 000000000000..c6d36ea34367 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-stop.ts @@ -0,0 +1,76 @@ +import { useCallback, useEffect, useRef, type MutableRefObject } from 'react' +import type { RpcClient } from '../transport/rpc-client' + +export function useMobileNativeChatStop(args: { + client: RpcClient | null + enabled: boolean + handleRef: MutableRefObject + deviceTokenRef: MutableRefObject + streamIdentity: string + cancelPending: () => void + onSendError: (message: string) => void +}): () => void { + const { client, enabled, handleRef, deviceTokenRef, streamIdentity, cancelPending, onSendError } = + args + const timerRef = useRef | null>(null) + const activeRouteRef = useRef({ client, enabled, streamIdentity }) + activeRouteRef.current = { client, enabled, streamIdentity } + useEffect(() => { + if (!enabled && timerRef.current) { + clearTimeout(timerRef.current) + timerRef.current = null + } + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current) + timerRef.current = null + } + } + }, [client, enabled, streamIdentity]) + return useCallback(() => { + const handle = handleRef.current + if (!client || !handle || !enabled) { + onSendError('Stop not sent (terminal not ready)') + return + } + cancelPending() + if (timerRef.current) { + clearTimeout(timerRef.current) + } + const stopStreamIdentity = streamIdentity + let failureReported = false + const sendEscape = (): void => { + const activeRoute = activeRouteRef.current + if ( + !activeRoute.enabled || + activeRoute.client !== client || + activeRoute.streamIdentity !== stopStreamIdentity || + handleRef.current !== handle + ) { + return + } + void client + .sendRequest('terminal.send', { + terminal: handle, + text: String.fromCharCode(27), + ...(deviceTokenRef.current + ? { client: { id: deviceTokenRef.current, type: 'mobile' as const } } + : {}) + }) + .catch(() => { + // Why: disconnect can race either fire-and-forget Escape; surface one + // failure instead of leaking an unhandled RPC rejection. + if (!failureReported) { + failureReported = true + onSendError('Stop not sent') + } + }) + } + sendEscape() + // Why: two paced Escape bytes reliably stop TUIs without remote coalescing. + timerRef.current = setTimeout(() => { + timerRef.current = null + sendEscape() + }, 80) + }, [cancelPending, client, deviceTokenRef, enabled, handleRef, onSendError, streamIdentity]) +} 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 new file mode 100644 index 000000000000..92320b9ff86e --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts @@ -0,0 +1,115 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useMobileNativeChatTerminalStream } from './use-mobile-native-chat-terminal-stream' + +describe('useMobileNativeChatTerminalStream', () => { + let renderer: ReactTestRenderer | null = null + let harnessRenderCount = 0 + const subscriptionsRef = { current: new Map void>() } + const subscribingRef = { current: new Set() } + const webReadyRef = { current: new Set(['terminal-1']) } + const initializedRef = { current: new Set(['terminal-1']) } + const subscribe = vi.fn((handle: string) => subscriptionsRef.current.set(handle, () => {})) + const unsubscribe = vi.fn((handle: string) => subscriptionsRef.current.delete(handle)) + const notifyWebReadyRef = { current: (_handle: string, _wasAlreadyReady: boolean): void => {} } + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + subscriptionsRef.current = new Map([['terminal-1', () => {}]]) + subscribingRef.current = new Set() + webReadyRef.current = new Set(['terminal-1']) + initializedRef.current = new Set(['terminal-1']) + harnessRenderCount = 0 + subscribe.mockClear() + unsubscribe.mockClear() + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + function Harness({ showNativeChat }: { showNativeChat: boolean }): null { + harnessRenderCount += 1 + notifyWebReadyRef.current = useMobileNativeChatTerminalStream({ + showNativeChat, + activeHandle: 'terminal-1', + activeTabType: 'terminal', + subscriptionsRef, + subscribingRef, + webReadyRef, + initializedRef, + subscribe, + unsubscribe + }) + return null + } + + 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) + }) + 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) + + await act(async () => { + renderer?.update(createElement(Harness, { showNativeChat: false })) + }) + + expect(unsubscribe).toHaveBeenNthCalledWith(2, 'terminal-1') + expect(subscribe).toHaveBeenNthCalledWith(2, 'terminal-1') + } finally { + consoleSpy.mockRestore() + } + }) + + 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) + }) + 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() + + 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() + } + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-terminal-stream.ts b/mobile/src/session/use-mobile-native-chat-terminal-stream.ts new file mode 100644 index 000000000000..c45980ae6e38 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-terminal-stream.ts @@ -0,0 +1,76 @@ +import { useCallback, useEffect, useRef, useState, type MutableRefObject } from 'react' +import { resolveMobileNativeChatTerminalStreamAction } from './mobile-native-chat-terminal-stream' + +/** Pauses the active terminal stream while native chat covers its mounted WebView, + * then resumes from a fresh scrollback snapshot when terminal view returns. */ +export function useMobileNativeChatTerminalStream(args: { + showNativeChat: boolean + activeHandle: string | null + activeTabType: string | null + subscriptionsRef: MutableRefObject void>> + subscribingRef: MutableRefObject> + webReadyRef: MutableRefObject> + initializedRef: MutableRefObject> + subscribe: (handle: string) => void + unsubscribe: (handle: string) => void +}): (handle: string, wasAlreadyReady: boolean) => void { + const coveredHandleRef = useRef(null) + const [webReadyRevision, setWebReadyRevision] = useState(0) + const notifyWebReady = useCallback((handle: string, wasAlreadyReady: boolean) => { + // Why: ordinary WebView startups must not rerender the large session route; + // only readiness that can release a native-chat lease needs reconciliation. + if (!wasAlreadyReady && coveredHandleRef.current === handle) { + setWebReadyRevision((revision) => revision + 1) + } + }, []) + useEffect(() => { + const handle = args.activeHandle + if (coveredHandleRef.current && coveredHandleRef.current !== handle) { + coveredHandleRef.current = null + } + const streamActive = + handle != null && + (args.subscriptionsRef.current.has(handle) || args.subscribingRef.current.has(handle)) + const action = resolveMobileNativeChatTerminalStreamAction({ + showNativeChat: args.showNativeChat, + activeHandle: handle, + activeTabType: args.activeTabType, + streamActive, + streamCovered: coveredHandleRef.current === handle, + webViewReady: handle != null && args.webReadyRef.current.has(handle) + }) + if (!handle || action === 'none') { + return + } + if (action === 'pause') { + coveredHandleRef.current = handle + // Why: returning to terminal must accept the fresh scrollback snapshot; + // the stream was paused while chat covered output that xterm never saw. + args.initializedRef.current.delete(handle) + // Why: covered chat needs the input-floor lease without paying to stream + // duplicate PTY output. Replace any view stream with a lease-only stream. + if (streamActive) { + args.unsubscribe(handle) + } + args.subscribe(handle) + return + } + if (coveredHandleRef.current === handle) { + args.unsubscribe(handle) + coveredHandleRef.current = null + } + args.subscribe(handle) + }, [ + args.activeHandle, + args.activeTabType, + args.initializedRef, + args.showNativeChat, + args.subscribe, + args.subscribingRef, + args.subscriptionsRef, + args.unsubscribe, + args.webReadyRef, + webReadyRevision + ]) + return notifyWebReady +} diff --git a/mobile/src/session/use-throttled-latest-value.test.ts b/mobile/src/session/use-throttled-latest-value.test.ts new file mode 100644 index 000000000000..bdea29c74597 --- /dev/null +++ b/mobile/src/session/use-throttled-latest-value.test.ts @@ -0,0 +1,69 @@ +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 { 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) + return null + } + + function render(value: string | undefined): void { + act(() => { + renderer = create(createElement(Harness, { value })) + }) + } + + function update(value: string | undefined): void { + act(() => renderer?.update(createElement(Harness, { value }))) + } + + 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', () => { + render('a') + expect(latest).toBe('a') + }) + + it('holds rapid updates but eventually emits the latest value', () => { + render('a') + update('ab') + update('abc') + expect(latest).toBe('a') + act(() => vi.advanceTimersByTime(50)) + expect(latest).toBe('abc') + }) + + it('clears immediately and drops the trailing emit when the value goes undefined', () => { + render('a') + update('ab') + expect(latest).toBe('a') + update(undefined) + expect(latest).toBeUndefined() + act(() => vi.advanceTimersByTime(50)) + expect(latest).toBeUndefined() + }) +}) diff --git a/mobile/src/session/use-throttled-latest-value.ts b/mobile/src/session/use-throttled-latest-value.ts new file mode 100644 index 000000000000..652b1e79119e --- /dev/null +++ b/mobile/src/session/use-throttled-latest-value.ts @@ -0,0 +1,52 @@ +import { useEffect, useRef, useState } from 'react' + +/** Rate-limits a rapidly-changing value to at most one emit per `intervalMs` + * while always surfacing the latest value. OpenCode publishes a streaming + * assistant frame per part; unthrottled, each re-parses the whole bubble. */ +export function useThrottledLatestValue(value: T, intervalMs: number): T { + const [throttled, setThrottled] = useState(value) + const valueRef = useRef(value) + valueRef.current = value + const lastEmitRef = useRef(0) + const timerRef = useRef | null>(null) + + useEffect(() => { + if (value == null) { + // Turn ended: drop any trailing emit and reset so the next stream's first + // frame shows at once instead of the stale bubble lingering. + if (timerRef.current) { + clearTimeout(timerRef.current) + timerRef.current = null + } + lastEmitRef.current = 0 + setThrottled(value) + return + } + const elapsed = Date.now() - lastEmitRef.current + if (elapsed >= intervalMs) { + lastEmitRef.current = Date.now() + setThrottled(value) + return + } + if (timerRef.current) { + return + } + timerRef.current = setTimeout(() => { + timerRef.current = null + lastEmitRef.current = Date.now() + setThrottled(valueRef.current) + }, intervalMs - elapsed) + }, [value, intervalMs]) + + useEffect( + () => () => { + if (timerRef.current) { + clearTimeout(timerRef.current) + timerRef.current = null + } + }, + [] + ) + + return throttled +} diff --git a/mobile/src/storage/preferences.test.ts b/mobile/src/storage/preferences.test.ts index f8e07fb4d98a..881391db0bd4 100644 --- a/mobile/src/storage/preferences.test.ts +++ b/mobile/src/storage/preferences.test.ts @@ -8,6 +8,7 @@ import { HOST_SIDEBAR_MIN_WIDTH, clampHostDockWidth, clampHostSidebarWidth, + loadNativeChatTabIds, loadDisabledTerminalLiveInputHandles, loadHostSidebarWidth, loadPushNotificationsEnabled, @@ -17,6 +18,7 @@ import { readDisabledTerminalLiveInputHandlesPreference, saveDisabledTerminalLiveInputHandles, saveHostSidebarWidth, + saveNativeChatTabIds, savePushNotificationsEnabled, saveTerminalAutocompleteEnabled, saveTerminalLinkOpenMode @@ -29,6 +31,31 @@ vi.mock('@react-native-async-storage/async-storage', () => ({ } })) +describe('native chat tab preference', () => { + beforeEach(() => { + vi.mocked(AsyncStorage.getItem).mockReset() + vi.mocked(AsyncStorage.setItem).mockReset() + }) + + it('loads and saves tab ids under a host-and-worktree scoped key', async () => { + vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(['tab-1', 42, 'tab-2'])) + + await expect(loadNativeChatTabIds('host/one', 'folder:C:\\repo')).resolves.toEqual([ + 'tab-1', + 'tab-2' + ]) + expect(AsyncStorage.getItem).toHaveBeenCalledWith( + 'orca:nativeChatTabs:host%2Fone:folder%3AC%3A%5Crepo' + ) + + await saveNativeChatTabIds('host/one', 'folder:C:\\repo', ['tab-2']) + expect(AsyncStorage.setItem).toHaveBeenCalledWith( + 'orca:nativeChatTabs:host%2Fone:folder%3AC%3A%5Crepo', + JSON.stringify(['tab-2']) + ) + }) +}) + describe('push notification preference', () => { beforeEach(() => { vi.mocked(AsyncStorage.getItem).mockReset() diff --git a/mobile/src/storage/preferences.ts b/mobile/src/storage/preferences.ts index 5173ac5bc8ad..a5af9bf350f9 100644 --- a/mobile/src/storage/preferences.ts +++ b/mobile/src/storage/preferences.ts @@ -2,6 +2,35 @@ import AsyncStorage from '@react-native-async-storage/async-storage' const PINS_PREFIX = 'orca:pins:' const NOTIF_KEY = 'orca:pushNotificationsEnabled' +const NATIVE_CHAT_TABS_PREFIX = 'orca:nativeChatTabs:' + +function nativeChatTabIdsKey(hostId: string, worktreeId: string): string { + return `${NATIVE_CHAT_TABS_PREFIX}${encodeURIComponent(hostId)}:${encodeURIComponent(worktreeId)}` +} + +/** Tab ids currently showing native chat, scoped to the paired host and worktree + * so colliding remote ids cannot activate a transcript watcher on another host. */ +export async function loadNativeChatTabIds(hostId: string, worktreeId: string): Promise { + try { + const raw = await AsyncStorage.getItem(nativeChatTabIdsKey(hostId, worktreeId)) + const parsed = raw ? (JSON.parse(raw) as unknown) : null + return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : [] + } catch { + return [] + } +} + +export async function saveNativeChatTabIds( + hostId: string, + worktreeId: string, + ids: string[] +): Promise { + try { + await AsyncStorage.setItem(nativeChatTabIdsKey(hostId, worktreeId), JSON.stringify(ids)) + } catch { + // Best-effort persistence; a failed write just forgets the view choice. + } +} export type PushNotificationsPreference = { readonly value: boolean | null diff --git a/mobile/src/theme/mobile-theme.ts b/mobile/src/theme/mobile-theme.ts index 76b14415a329..dbf09b745b90 100644 --- a/mobile/src/theme/mobile-theme.ts +++ b/mobile/src/theme/mobile-theme.ts @@ -18,6 +18,9 @@ export const colors = { surfaceBright: '#f5f5f5', accentBlue: '#3b82f6', + // Text/icon color on a filled accent (accentBlue) button, where the muted + // textPrimary would lack contrast against the saturated fill. + onAccent: '#ffffff', statusGreen: '#22c55e', statusAmber: '#f59e0b', diff --git a/mobile/src/transport/rpc-client-terminal-subscription.ts b/mobile/src/transport/rpc-client-terminal-subscription.ts index 9afc4a1fd0ca..471bc3c4a4e2 100644 --- a/mobile/src/transport/rpc-client-terminal-subscription.ts +++ b/mobile/src/transport/rpc-client-terminal-subscription.ts @@ -1,3 +1,5 @@ +import { buildNativeChatUnsubscribe } from '../../../src/shared/native-chat-stream-unsubscribe' + type TerminalStreamParams = { terminal?: unknown } @@ -31,6 +33,37 @@ export function updateTerminalSubscriptionViewport( } } +/** Build the unsubscribe RPC for a streaming method that needs the host told to + * tear down (session tabs, native chat), or null when none is required. Keeps + * the per-method echo logic out of the rpc-client teardown closure. */ +export function buildStreamUnsubscribe( + method: string | undefined, + params: unknown +): { method: string; params: Record } | null { + if (!params || typeof params !== 'object') { + return null + } + if (method === 'session.tabs.subscribe') { + const worktree = (params as { worktree?: unknown }).worktree + return typeof worktree === 'string' + ? { method: 'session.tabs.unsubscribe', params: { worktree } } + : null + } + if (method === 'nativeChat.subscribe') { + const subscriptionId = (params as { subscriptionId?: unknown }).subscriptionId + if (typeof subscriptionId === 'string') { + return { method: 'nativeChat.unsubscribe', params: { subscriptionId } } + } + // Backward compatibility for callers that predate explicit cleanup tokens. + const agent = (params as { agent?: unknown }).agent + const sessionId = (params as { sessionId?: unknown }).sessionId + return typeof agent === 'string' && typeof sessionId === 'string' + ? buildNativeChatUnsubscribe(agent, sessionId) + : null + } + return null +} + export function buildTerminalUnsubscribeParams( params: unknown ): { subscriptionId: string; client?: { id: string } } | null { diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts index 014bea955cd6..5420ba679a67 100644 --- a/mobile/src/transport/rpc-client.ts +++ b/mobile/src/transport/rpc-client.ts @@ -23,6 +23,7 @@ import { type BrowserScreencastFrame } from './browser-screencast-protocol' import { + buildStreamUnsubscribe, buildTerminalUnsubscribeParams, updateTerminalSubscriptionViewport as updateCachedTerminalSubscriptionViewport } from './rpc-client-terminal-subscription' @@ -1171,18 +1172,11 @@ export function connect( params: unsubscribeParams }) } - } else if ( - stream?.method === 'session.tabs.subscribe' && - stream.params && - typeof stream.params === 'object' && - typeof (stream.params as { worktree?: unknown }).worktree === 'string' - ) { - sendEncrypted({ - id: nextId(), - deviceToken, - method: 'session.tabs.unsubscribe', - params: { worktree: (stream.params as { worktree: string }).worktree } - }) + } else { + const unsub = buildStreamUnsubscribe(stream?.method, stream?.params) + if (unsub) { + sendEncrypted({ id: nextId(), deviceToken, method: unsub.method, params: unsub.params }) + } } removeStreamListener(id) } diff --git a/src/main/ipc/filesystem-list-files-git-fallback-real.test.ts b/src/main/ipc/filesystem-list-files-git-fallback-real.test.ts index 95315f6b00d3..0cf672fa30f1 100644 --- a/src/main/ipc/filesystem-list-files-git-fallback-real.test.ts +++ b/src/main/ipc/filesystem-list-files-git-fallback-real.test.ts @@ -126,6 +126,18 @@ describe('filesystem-list-files real git fallback', () => { ]) }) + it('bounds a non-git readdir fallback without treating the limit as an error', async () => { + checkRgAvailableMock.mockResolvedValue(false) + tempDir = await mkdtemp(join(tmpdir(), 'orca-quick-open-bounded-non-git-')) + await writeRel(tempDir, 'a.ts') + await writeRel(tempDir, 'b.ts') + + const files = await listQuickOpenFiles(tempDir, makeStore(tempDir), undefined, undefined, 1) + + expect(files).toHaveLength(1) + expect(['a.ts', 'b.ts']).toContain(files[0]) + }) + it('rejects abnormal git ls-files failures instead of resolving an empty list', async () => { checkRgAvailableMock.mockResolvedValue(false) tempDir = await mkdtemp(join(tmpdir(), 'orca-quick-open-bad-index-')) diff --git a/src/main/ipc/filesystem-list-files-git-fallback.ts b/src/main/ipc/filesystem-list-files-git-fallback.ts index 616a9a10c932..ff390d13cafd 100644 --- a/src/main/ipc/filesystem-list-files-git-fallback.ts +++ b/src/main/ipc/filesystem-list-files-git-fallback.ts @@ -1,10 +1,15 @@ import type { ChildProcess } from 'node:child_process' import { gitSpawn } from '../git/runner' -import { buildGitLsFilesArgsForQuickOpen } from '../../shared/quick-open-filter' +import { + buildGitLsFilesArgsForQuickOpen, + shouldExcludeQuickOpenRelPath, + shouldIncludeQuickOpenPath +} from '../../shared/quick-open-filter' import { createQuickOpenReaddirBudget, expandQuickOpenGitFileListing, - listQuickOpenFilesWithReaddir + listQuickOpenFilesWithReaddir, + parseQuickOpenGitLsFilesEntry } from '../../shared/quick-open-readdir-walk' import { fileListingCancellationError } from '../../shared/file-listing-cancellation' @@ -73,7 +78,8 @@ export async function listFilesWithGit( rootPath: string, excludePathPrefixes: readonly string[], localGitOptions: { wslDistro?: string }, - signal?: AbortSignal + signal?: AbortSignal, + maxResults?: number ): Promise { const isGitWorkTree = await isInsideGitWorkTree(rootPath, localGitOptions, signal) if (signal?.aborted) { @@ -83,17 +89,20 @@ export async function listFilesWithGit( return listQuickOpenFilesWithReaddir(rootPath, { excludePathPrefixes, budget: createQuickOpenReaddirBudget(), + maxResults, signal }) } const gitPaths = new Set() const directoryPaths = new Set() + const directFileCandidates = new Set() const { primary, ignoredPass } = buildGitLsFilesArgsForQuickOpen(excludePathPrefixes) const children: { child: ChildProcess isDone: () => boolean reject: (error: Error) => void + resolve: () => void }[] = [] const runGitLsFiles = (args: string[]): Promise => { @@ -101,15 +110,32 @@ export async function listFilesWithGit( let buf = '' let done = false - const processPath = (path: string): void => { + const processPath = (path: string): boolean => { if (!path) { - return + return false } if (path.endsWith('/')) { directoryPaths.add(path) } else { gitPaths.add(path) + if (maxResults !== undefined) { + // Why: this duplicate classification exists only to stop bounded + // scans; unbounded scans must not retain a second repo-sized set. + const parsed = parseQuickOpenGitLsFilesEntry(path) + const relPath = parsed.path.replace(/\/+$/, '') + if ( + !parsed.isGitlink && + !parsed.isUntrackedDir && + shouldIncludeQuickOpenPath(relPath) && + !shouldExcludeQuickOpenRelPath(relPath, excludePathPrefixes) + ) { + directFileCandidates.add(relPath) + } + } } + // Why: collapsed directories and gitlinks may be discarded during the + // later filesystem classification, so they cannot consume the stop cap. + return maxResults !== undefined && directFileCandidates.size >= maxResults } // Why: git ls-files outputs paths relative to cwd, so we set cwd to @@ -149,14 +175,19 @@ export async function listFilesWithGit( children.push({ child, isDone: () => done, - reject: rejectPass + reject: rejectPass, + resolve: resolvePass }) const handleStdoutData = (chunk: string): void => { buf += chunk let start = 0 let nulIdx = buf.indexOf('\0', start) while (nulIdx !== -1) { - processPath(buf.substring(start, nulIdx)) + if (processPath(buf.substring(start, nulIdx))) { + buf = '' + finishAtLimit() + return + } start = nulIdx + 1 nulIdx = buf.indexOf('\0', start) } @@ -176,8 +207,10 @@ export async function listFilesWithGit( rejectPass(new Error(`git ls-files killed by ${signal}`)) return } - if (buf) { - processPath(buf) + if (buf && processPath(buf)) { + buf = '' + finishAtLimit() + return } if (code === 0) { resolvePass() @@ -213,22 +246,39 @@ export async function listFilesWithGit( } } + function finishAtLimit(): void { + for (const entry of children) { + if (entry.isDone()) { + continue + } + entry.resolve() + if (entry.child.exitCode === null && entry.child.signalCode === null) { + entry.child.kill() + } + } + } + const onAbort = (): void => killSurvivors('git ls-files cancelled') signal?.addEventListener('abort', onAbort, { once: true }) try { - await Promise.all([ - runGitLsFiles(primary), + const runIgnoredPass = () => // Why: ignored files are supplementary — a failed or timed-out ignored - // pass must not discard the primary listing the user actually needs - // (#7719 root cause: the all-or-nothing failure showed zero files). - // Entries streamed before the failure are kept; a cancelled scan still - // rejects via the primary pass or the expansion's cancellation check. + // pass must not discard the primary listing the user actually needs. runGitLsFiles(ignoredPass).catch((err: Error) => { if (!signal?.aborted) { console.warn('[quick-open] git ignored-file pass failed; keeping primary results:', err) } }) - ]) + if (maxResults === undefined) { + await Promise.all([runGitLsFiles(primary), runIgnoredPass()]) + } else { + // Why: give ordinary source files first claim on a bounded autocomplete + // inventory; a large ignored tree must not win a parallel-output race. + await runGitLsFiles(primary) + if (directFileCandidates.size < maxResults) { + await runIgnoredPass() + } + } } catch (err) { killSurvivors() if (signal?.aborted) { @@ -244,9 +294,10 @@ export async function listFilesWithGit( gitPaths, directoryPaths, excludePathPrefixes, - signal + signal, + maxResults }) // Why: directory placeholders are expanded after Git exits; restore Git's // path order so empty queries and fuzzy-score ties remain stable. - return files.sort() + return files.sort().slice(0, maxResults) } diff --git a/src/main/ipc/filesystem-list-files.test.ts b/src/main/ipc/filesystem-list-files.test.ts index ba535a258681..946f8653dd67 100644 --- a/src/main/ipc/filesystem-list-files.test.ts +++ b/src/main/ipc/filesystem-list-files.test.ts @@ -71,6 +71,30 @@ describe('filesystem-list-files', () => { getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({}) }) + it('stops after the primary rg pass fills the result budget', async () => { + const p1 = createMockProcess() + const p2 = createMockProcess() + spawnMock.mockImplementation((_cmd, args: string[]) => (isIgnoredRgPass(args) ? p2 : p1)) + const promise = listQuickOpenFiles( + '/mock/root', + {} as unknown as Store, + undefined, + undefined, + 2 + ) + + setTimeout(() => { + ;(p1.stdout as unknown as EventEmitter).emit('data', 'one.ts\ntwo.ts') + p1.emit('close', 0, null) + }, 0) + const result = await promise + + expect(result).toEqual(['one.ts', 'two.ts']) + expect(p1.kill).toHaveBeenCalled() + expect(p2.kill).not.toHaveBeenCalled() + expect(spawnMock).toHaveBeenCalledTimes(1) + }) + it('merges normal files and ignored files and filters correctly', async () => { const p1 = createMockProcess() const p2 = createMockProcess() @@ -377,6 +401,73 @@ describe('filesystem-list-files', () => { expect(result).not.toContain('node_modules/dep/index.js') }) + it('stops after primary Git files fill the result budget', async () => { + checkRgAvailableMock.mockResolvedValue(false) + const revParseProc = createMockProcess() + const gitP1 = createMockProcess() + const gitP2 = createMockProcess() + let callIndex = 0 + + spawnMock.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'git' && args.includes('rev-parse')) { + return revParseProc + } + if (cmd === 'git' && args.includes('ls-files')) { + callIndex += 1 + return callIndex === 1 ? gitP1 : gitP2 + } + return createMockProcess() + }) + + const promise = listQuickOpenFiles( + '/mock/root', + {} as unknown as Store, + undefined, + undefined, + 2 + ) + setTimeout(() => revParseProc.emit('close', 0, null), 0) + setTimeout(() => { + ;(gitP1.stdout as unknown as EventEmitter).emit('data', 'one.ts\0two.ts') + gitP1.emit('close', 0, null) + }, 10) + + await expect(promise).resolves.toEqual(['one.ts', 'two.ts']) + expect(gitP1.kill).toHaveBeenCalled() + expect(gitP2.kill).not.toHaveBeenCalled() + expect(callIndex).toBe(1) + }) + + it('does not let a discarded Git directory placeholder consume the result budget', async () => { + checkRgAvailableMock.mockResolvedValue(false) + const revParseProc = createMockProcess() + const primary = createMockProcess() + spawnMock.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'git' && args.includes('rev-parse')) { + return revParseProc + } + return primary + }) + + const promise = listQuickOpenFiles( + '/mock/root', + {} as unknown as Store, + undefined, + undefined, + 1 + ) + setTimeout(() => revParseProc.emit('close', 0, null), 0) + setTimeout(() => { + ;(primary.stdout as unknown as EventEmitter).emit( + 'data', + `discarded/\0${staged('100644', 'src/kept.ts')}\0` + ) + }, 10) + + await expect(promise).resolves.toEqual(['src/kept.ts']) + expect(primary.kill).toHaveBeenCalled() + }) + it('git fallback applies hidden dir blocklist', async () => { checkRgAvailableMock.mockResolvedValue(false) diff --git a/src/main/ipc/filesystem-list-files.ts b/src/main/ipc/filesystem-list-files.ts index 9dcd2c450382..58b1c0c0cadc 100644 --- a/src/main/ipc/filesystem-list-files.ts +++ b/src/main/ipc/filesystem-list-files.ts @@ -20,7 +20,8 @@ export async function listQuickOpenFiles( rootPath: string, store: Store, excludePaths?: string[], - signal?: AbortSignal + signal?: AbortSignal, + maxResults?: number ): Promise { const authorizedRootPath = await resolveAuthorizedPath(rootPath, store) const localGitOptions = getLocalGitOptionsForRegisteredWorktree( @@ -41,11 +42,21 @@ export async function listQuickOpenFiles( // can run. const rgAvailable = await checkRgAvailable(authorizedRootPath, localGitOptions.wslDistro) if (!rgAvailable) { - return listFilesWithGit(authorizedRootPath, excludePathPrefixes, localGitOptions, signal) + return listFilesWithGit( + authorizedRootPath, + excludePathPrefixes, + localGitOptions, + signal, + maxResults + ) } const files = new Set() - const children: ChildProcess[] = [] + const children: { + child: ChildProcess + isDone: () => boolean + finish: () => void + }[] = [] // Why: WSL-routed rg can emit Linux-native absolute paths. UNC repos carry // their distro in the path; Windows-path repos carry it in project runtime. const wslDistroForOutput = parseWslPath(authorizedRootPath)?.distro ?? localGitOptions.wslDistro @@ -67,7 +78,7 @@ export async function listQuickOpenFiles( let done = false let parseablePathCount = 0 - const processLine = (rawLine: string): void => { + const processLine = (rawLine: string): boolean => { const translated = wslDistroForOutput && rawLine.startsWith('/') ? toWindowsWslPath(rawLine, wslDistroForOutput) @@ -77,16 +88,20 @@ export async function listQuickOpenFiles( getQuickOpenRgOutputMode(rawLine, translated, authorizedRootPath) ) if (relPath === null) { - return + return false } parseablePathCount++ if (!shouldIncludeQuickOpenPath(relPath)) { - return + return false } if (shouldExcludeQuickOpenRelPath(relPath, excludePathPrefixes)) { - return + return false + } + if (maxResults !== undefined && files.size >= maxResults) { + return true } files.add(relPath) + return maxResults !== undefined && files.size >= maxResults } const child = wslAwareSpawn('rg', args, { @@ -94,14 +109,17 @@ export async function listQuickOpenFiles( ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), stdio: ['ignore', 'pipe', 'pipe'] }) - children.push(child) let timer: ReturnType const handleStdoutData = (chunk: string): void => { buf += chunk let start = 0 let newlineIdx = buf.indexOf('\n', start) while (newlineIdx !== -1) { - processLine(buf.substring(start, newlineIdx)) + if (processLine(buf.substring(start, newlineIdx))) { + buf = '' + finishAtLimit() + return + } start = newlineIdx + 1 newlineIdx = buf.indexOf('\n', start) } @@ -125,8 +143,10 @@ export async function listQuickOpenFiles( finish(new Error(`rg killed by ${signal}`)) return } - if (buf) { - processLine(buf) + if (buf && processLine(buf)) { + buf = '' + finishAtLimit() + return } if (code === 0 || code === 1) { finish() @@ -157,6 +177,8 @@ export async function listQuickOpenFiles( } } + children.push({ child, isDone: () => done, finish }) + child.stdout!.setEncoding('utf-8') child.stdout!.on('data', handleStdoutData) child.stderr!.on('data', handleStderrData) @@ -176,20 +198,45 @@ export async function listQuickOpenFiles( // Why: if one rg pass fails, Promise.all rejects immediately while the // sibling scan can keep walking a huge tree until timeout. Stop it so // repeated Quick Open attempts do not accumulate local rg processes. - for (const child of children) { - if (child.exitCode === null && child.signalCode === null) { - child.kill() + for (const entry of children) { + if (entry.isDone()) { + continue + } + entry.finish() + if (entry.child.exitCode === null && entry.child.signalCode === null) { + entry.child.kill() + } + } + } + + function finishAtLimit(): void { + for (const entry of children) { + if (entry.isDone()) { + continue + } + entry.finish() + if (entry.child.exitCode === null && entry.child.signalCode === null) { + entry.child.kill() } } } try { - await Promise.all([runRg(primary), runRg(ignoredPass)]) + if (maxResults === undefined) { + await Promise.all([runRg(primary), runRg(ignoredPass)]) + } else { + // Why: ignored-file output can be much larger and faster than the primary + // pass; let source files claim the bounded autocomplete budget first. + await runRg(primary) + if (files.size < maxResults) { + await runRg(ignoredPass) + } + } } catch (err) { killSurvivors() throw err } - return Array.from(files) + return Array.from(files).slice(0, maxResults) } function getQuickOpenRgOutputMode( diff --git a/src/main/ipc/native-chat-subscribe-lifecycle.test.ts b/src/main/ipc/native-chat-subscribe-lifecycle.test.ts index 535888148488..516f839fca94 100644 --- a/src/main/ipc/native-chat-subscribe-lifecycle.test.ts +++ b/src/main/ipc/native-chat-subscribe-lifecycle.test.ts @@ -30,6 +30,7 @@ import { type TestSubscription = { unsubscribe: ReturnType + watching: boolean } type DeferredSubscription = { @@ -69,7 +70,7 @@ function deferredSubscription(): DeferredSubscription { return { promise, reject: rejectPromise, - resolve: () => resolvePromise({ unsubscribe }), + resolve: () => resolvePromise({ unsubscribe, watching: true }), unsubscribe } } @@ -106,6 +107,23 @@ function subscribe(sender: SenderHarness['sender'], subscriptionId: string): voi listener({ sender }, { subscriptionId, agent: 'claude', sessionId: `session-${subscriptionId}` }) } +type InitialSnapshotCallback = ( + messages: unknown[], + hasMore: boolean, + beforeOffset: number, + error?: string +) => void + +// The onInitialSnapshot callback the handler passed into the Nth subscribeTranscript +// call; transcript-watch fires it during setup, so tests invoke it directly. +function initialSnapshot(callIndex: number): InitialSnapshotCallback { + const call = subscribeTranscript.mock.calls[callIndex] + if (!call) { + throw new Error('subscribeTranscript was not called') + } + return (call[0] as { onInitialSnapshot: InitialSnapshotCallback }).onInitialSnapshot +} + function unsubscribe(sender: SenderHarness['sender'], subscriptionId: string): void { const listener = listeners.get('nativeChat:unsubscribe') if (!listener) { @@ -225,4 +243,39 @@ describe('nativeChat subscribe lifecycle', () => { expect(replacementPending.unsubscribe).toHaveBeenCalledOnce() expect(_getNativeChatSenderCleanupCountForTest()).toBe(0) }) + + it('forwards an initial-drain error onto the snapshot frame', () => { + const pending = deferredSubscription() + subscribeTranscript.mockReturnValueOnce(pending.promise) + const renderer = createSender(5) + + subscribe(renderer.sender, 'drain-error') + // transcript-watch delivers the drain error synchronously via onInitialSnapshot; + // invoke the captured callback to exercise the handler's forwarding closure. + initialSnapshot(0)([], false, 0, 'Transcript unavailable') + + expect(renderer.sender.send).toHaveBeenCalledWith('nativeChat:appended', { + subscriptionId: 'drain-error', + frame: { + type: 'snapshot', + messages: [], + hasMore: false, + error: 'Transcript unavailable' + } + }) + }) + + it('omits error from the snapshot frame on a clean initial drain', () => { + const pending = deferredSubscription() + subscribeTranscript.mockReturnValueOnce(pending.promise) + const renderer = createSender(6) + + subscribe(renderer.sender, 'drain-clean') + initialSnapshot(0)([], false, 0) + + expect(renderer.sender.send).toHaveBeenCalledWith('nativeChat:appended', { + subscriptionId: 'drain-clean', + frame: { type: 'snapshot', messages: [], hasMore: false } + }) + }) }) diff --git a/src/main/ipc/native-chat.test.ts b/src/main/ipc/native-chat.test.ts index d16a39b1f136..4d0e1765a44a 100644 --- a/src/main/ipc/native-chat.test.ts +++ b/src/main/ipc/native-chat.test.ts @@ -58,6 +58,7 @@ async function invokeReadSession(args: { agent: string sessionId: string limit?: number + transcriptPath?: string }): Promise { registerNativeChatHandlers() const handler = handlers.get('nativeChat:readSession') @@ -68,6 +69,17 @@ async function invokeReadSession(args: { } describe('nativeChat:readSession handler', () => { + it('preserves notFound so a just-created session stays in retry/loading', async () => { + const result = (await invokeReadSession({ + agent: 'claude', + sessionId: 'missing-session', + transcriptPath: join(tmpdir(), 'orca-native-chat-ipc-does-not-exist.jsonl') + })) as { error?: string; notFound?: true } + + expect(result.error).toBeDefined() + expect(result.notFound).toBe(true) + }) + it('resolves a Claude transcript and returns the full conversation', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-ipc-')) tempRoots.push(root) @@ -152,7 +164,7 @@ describe('nativeChat:readSession handler', () => { } }) - it('emits appended messages over nativeChat:appended and tears down on destroy', async () => { + it('emits snapshot and appended frames and tears down on destroy', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-ipc-sub-')) tempRoots.push(root) const projectsDir = join(root, '.claude', 'projects') @@ -214,13 +226,14 @@ describe('nativeChat:readSession handler', () => { })}\n` ) - // Seed-at-0 means the first appended event carries the whole-file re-read; - // the new turn 'a-1' arrives across one of the appended events. Collect ids - // from every appended event and assert the new turn shows up. + // The first frame is a bounded snapshot and later frames are appends. + // Collect ids across both and assert the new turn shows up. const appendedIds = (): string[] => sent .filter((s) => s.channel === 'nativeChat:appended') - .flatMap((s) => (s.payload as { messages: { id: string }[] }).messages.map((m) => m.id)) + .flatMap((s) => + (s.payload as { frame: { messages: { id: string }[] } }).frame.messages.map((m) => m.id) + ) await waitFor(() => appendedIds().includes('a-1')) const appendedEvent = sent.find((s) => s.channel === 'nativeChat:appended')! const payload = appendedEvent.payload as { subscriptionId: string } diff --git a/src/main/ipc/native-chat.ts b/src/main/ipc/native-chat.ts index 3ed4e9b74a6b..227918a68f35 100644 --- a/src/main/ipc/native-chat.ts +++ b/src/main/ipc/native-chat.ts @@ -1,12 +1,10 @@ import { ipcMain, type IpcMainEvent, type WebContents } from 'electron' import type { AgentType, NativeChatMessage } from '../../shared/native-chat-types' -import { - clearNativeChatTranscriptCache, - readNativeChatTranscriptCached -} from '../native-chat/transcript-read-cache' +import { clearNativeChatTranscriptCache } from '../native-chat/transcript-read-cache' import type { ReadTranscriptResult } from '../native-chat/transcript-reader' import { subscribeNativeChatTranscript, + readNativeChatTranscriptTail, type NativeChatTranscriptSubscription } from '../native-chat/transcript-watch' @@ -25,27 +23,20 @@ export type NativeChatReadSessionArgs = { transcriptPath?: string } -// Why: render only the most recent turns so switching to chat view on a long -// session (thousands of messages) doesn't stall on building that many message -// components. The full transcript is still cached; only the returned slice is -// capped. The renderer raises `limit` to page in older history; live appends -// extend it from there. +// Why: render and parse only the recent window so long transcripts do not stall +// either the main process or the message list. Pagination raises this limit. const DESKTOP_READ_WINDOW = 300 -function windowTranscript(result: ReadTranscriptResult, limit: number): ReadTranscriptResult { - if (!('messages' in result) || result.messages.length <= limit) { - return result - } - return { ...result, messages: result.messages.slice(-limit) } -} - async function readSession(args: NativeChatReadSessionArgs): Promise { const { agent, sessionId } = args // Clamp to a positive window; default to the desktop window for the first page. const limit = args.limit && args.limit > 0 ? Math.floor(args.limit) : DESKTOP_READ_WINDOW - // Desktop is full-class: window by count only, no char truncation. - const result = await readNativeChatTranscriptCached(agent, sessionId, args.transcriptPath) - return windowTranscript(result, limit) + return readNativeChatTranscriptTail({ + agent, + sessionId, + transcriptPath: args.transcriptPath, + limit + }) } export type NativeChatSubscribeArgs = { @@ -56,11 +47,15 @@ export type NativeChatSubscribeArgs = { sessionId: string /** Authoritative transcript path from the agent hook (providerSession). */ transcriptPath?: string + limit?: number } export type NativeChatAppendedPayload = { subscriptionId: string - messages: NativeChatMessage[] + frame: + | { type: 'snapshot'; messages: NativeChatMessage[]; hasMore: boolean; error?: string } + | { type: 'replacement'; messages: NativeChatMessage[]; hasMore: boolean } + | { type: 'appended'; messages: NativeChatMessage[] } } type LiveSubscription = { @@ -144,6 +139,7 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg return } const { subscriptionId, agent, sessionId, transcriptPath } = args + const limit = args.limit && args.limit > 0 ? Math.floor(args.limit) : DESKTOP_READ_WINDOW // Replace any prior subscription under the same id (session change/resubscribe). const pendingToken = beginPendingSubscription(sender.id, subscriptionId) registerSenderCleanup(sender) @@ -154,11 +150,36 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg agent, sessionId, transcriptPath, + initialLimit: limit, + onInitialSnapshot: (messages, hasMore, _beforeOffset, error) => { + if (sender.isDestroyed()) { + return + } + // Forward an initial-drain error so a watching client's first frame carries it + // instead of stranding the view at 'loading' when the read keeps throwing. + const payload: NativeChatAppendedPayload = { + subscriptionId, + frame: { type: 'snapshot', messages, hasMore, ...(error ? { error } : {}) } + } + sender.send('nativeChat:appended', payload) + }, + onReplace: (messages, hasMore) => { + if (sender.isDestroyed()) { + return + } + sender.send('nativeChat:appended', { + subscriptionId, + frame: { type: 'replacement', messages, hasMore } + } satisfies NativeChatAppendedPayload) + }, onAppend: (messages) => { if (sender.isDestroyed()) { return } - const payload: NativeChatAppendedPayload = { subscriptionId, messages } + const payload: NativeChatAppendedPayload = { + subscriptionId, + frame: { type: 'appended', messages } + } sender.send('nativeChat:appended', payload) } }) @@ -182,6 +203,18 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg } bySubId.set(subscriptionId, { subscription }) liveSubscriptions.set(sender.id, bySubId) + if (!subscription.watching && !sender.isDestroyed()) { + const payload: NativeChatAppendedPayload = { + subscriptionId, + frame: { + type: 'snapshot', + messages: [], + hasMore: false, + error: 'Transcript unavailable' + } + } + sender.send('nativeChat:appended', payload) + } } /** Test-only: drop all live and pending transcript subscriptions between runs. */ diff --git a/src/main/native-chat/session-file-resolver.test.ts b/src/main/native-chat/session-file-resolver.test.ts index 5dd3cbc8dab7..85cd653185ba 100644 --- a/src/main/native-chat/session-file-resolver.test.ts +++ b/src/main/native-chat/session-file-resolver.test.ts @@ -39,6 +39,19 @@ describe('resolveSessionFilePath', () => { expect(resolved).toBe(target) }) + it('resolves OpenClaude sessions from the Claude transcript layout', async () => { + const root = await makeRoot('orca-native-chat-resolve-openclaude-') + const claudeProjectsDir = join(root, 'claude-projects') + const projectDir = join(claudeProjectsDir, '-Users-ada-repo') + await mkdir(projectDir, { recursive: true }) + const target = join(projectDir, 'sess-openclaude.jsonl') + await writeFile(target, '{}\n') + + await expect( + resolveSessionFilePath('openclaude', 'sess-openclaude', { claudeProjectsDir }) + ).resolves.toBe(target) + }) + it('resolves Grok chat_history.jsonl under encodeURIComponent(cwd)/sessionId', async () => { const root = await makeRoot('orca-native-chat-resolve-grok-') const grokSessionsDir = join(root, 'grok-sessions') diff --git a/src/main/native-chat/session-file-resolver.ts b/src/main/native-chat/session-file-resolver.ts index adfa3856f0e7..33cf8c238238 100644 --- a/src/main/native-chat/session-file-resolver.ts +++ b/src/main/native-chat/session-file-resolver.ts @@ -2,6 +2,7 @@ import { existsSync } from 'node:fs' import { homedir } from 'node:os' import { basename, extname, join } from 'node:path' import type { AgentType } from '../../shared/native-chat-types' +import { resolveNativeChatTranscriptAgent } from '../../shared/native-chat-agent-support' import { walkSessionFiles } from '../ai-vault/session-scanner-discovery' import { getOrcaManagedCodexHomePath } from '../codex/codex-home-paths' import { @@ -66,6 +67,10 @@ export async function resolveSessionFilePath( sessionId: string, options: ResolveSessionFileOptions = {} ): Promise { + const transcriptAgent = resolveNativeChatTranscriptAgent(agent) + if (!transcriptAgent) { + return null + } // Why: the hook's transcript_path is the exact file the agent is writing, so it // beats reconstructing a path from the session id. Guard with existsSync so a // stale/remote path falls through to the id-based search rather than returning @@ -80,13 +85,13 @@ export async function resolveSessionFilePath( return null } - if (agent === 'claude') { + if (transcriptAgent === 'claude') { return resolveClaudeSessionFile(trimmedId, options.claudeProjectsDir ?? claudeProjectsDir()) } - if (agent === 'codex') { + if (transcriptAgent === 'codex') { return resolveCodexSessionFile(trimmedId, options.codexSessionsDirs ?? codexSessionsDirs()) } - if (agent === 'grok') { + if (transcriptAgent === 'grok') { return resolveGrokSessionFile(trimmedId, options.grokSessionsDir ?? grokSessionsDir()) } return null diff --git a/src/main/native-chat/transcript-incremental-reader.ts b/src/main/native-chat/transcript-incremental-reader.ts new file mode 100644 index 000000000000..ffb3ad83cae5 --- /dev/null +++ b/src/main/native-chat/transcript-incremental-reader.ts @@ -0,0 +1,103 @@ +import { open, stat } from 'node:fs/promises' +import type { NativeChatMessage } from '../../shared/native-chat-types' +import { transcriptFallbackId } from './transcript-fallback-id' +import { + MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES, + type NativeChatLineDecoder +} from './transcript-tail-reader' + +const APPEND_BATCH_MESSAGE_LIMIT = 40 + +export type IncrementalTranscriptState = { + offset: number + pendingChunks: Buffer[] + pendingStart: number + pendingBytes: number + droppingOversizedRecord: boolean +} + +export function resetIncrementalTranscriptState(state: IncrementalTranscriptState): void { + state.offset = 0 + state.pendingChunks.length = 0 + state.pendingStart = 0 + state.pendingBytes = 0 + state.droppingOversizedRecord = false +} + +export async function readIncrementalTranscriptMessages( + filePath: string, + state: IncrementalTranscriptState, + decode: NativeChatLineDecoder, + onBatch?: (messages: NativeChatMessage[]) => void +): Promise { + const end = (await stat(filePath)).size + if (end <= state.offset) { + return [] + } + const messages: NativeChatMessage[] = [] + const handle = await open(filePath, 'r') + try { + const stream = handle.createReadStream({ start: state.offset, end: end - 1, autoClose: false }) + let absoluteOffset = state.offset + for await (const rawChunk of stream) { + const chunk = Buffer.isBuffer(rawChunk) ? rawChunk : Buffer.from(rawChunk) + let segmentStart = 0 + let newline = chunk.indexOf(0x0a) + while (newline >= 0) { + retainPart(chunk.subarray(segmentStart, newline)) + if (!state.droppingOversizedRecord) { + decodeLine() + } + resetPendingLine(absoluteOffset + newline + 1) + segmentStart = newline + 1 + newline = chunk.indexOf(0x0a, segmentStart) + } + if (segmentStart < chunk.length) { + retainPart(chunk.subarray(segmentStart)) + } + absoluteOffset += chunk.length + state.offset = absoluteOffset + } + return messages + } finally { + await handle.close() + } + + function retainPart(part: Buffer): void { + if (state.droppingOversizedRecord) { + return + } + state.pendingBytes += part.length + if (state.pendingBytes > MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES) { + state.pendingChunks.length = 0 + state.droppingOversizedRecord = true + return + } + state.pendingChunks.push(part) + } + + function resetPendingLine(nextStart: number): void { + state.pendingChunks.length = 0 + state.pendingBytes = 0 + state.droppingOversizedRecord = false + state.pendingStart = nextStart + } + + function decodeLine(): void { + let line = Buffer.concat(state.pendingChunks).toString('utf8') + if (line.endsWith('\r')) { + line = line.slice(0, -1) + } + if (!line) { + return + } + const message = decode(line, transcriptFallbackId(filePath, state.pendingStart)) + if (!message) { + return + } + messages.push(message) + if (onBatch && messages.length >= APPEND_BATCH_MESSAGE_LIMIT) { + onBatch(messages.splice(0)) + } + } +} diff --git a/src/main/native-chat/transcript-reader.test.ts b/src/main/native-chat/transcript-reader.test.ts index cd87f2bdd598..ff48548804d8 100644 --- a/src/main/native-chat/transcript-reader.test.ts +++ b/src/main/native-chat/transcript-reader.test.ts @@ -3,6 +3,11 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { readNativeChatTranscript } from './transcript-reader' +import { + nativeChatLineDecoderForAgent, + readNativeChatTranscriptTail, + readNativeChatTranscriptTailFile +} from './transcript-tail-reader' let tempRoots: string[] = [] @@ -24,6 +29,20 @@ async function writeFixture(prefix: string, records: unknown[]): Promise } describe('readNativeChatTranscript (claude)', () => { + it('decodes OpenClaude with the Claude transcript format', async () => { + const filePath = await writeFixture('orca-native-chat-openclaude-', [ + { + type: 'assistant', + uuid: 'openclaude-assistant', + message: { role: 'assistant', content: [{ type: 'text', text: 'hello' }] } + } + ]) + + await expect( + readNativeChatTranscript('openclaude', 'session', { filePath }) + ).resolves.toMatchObject({ messages: [{ id: 'openclaude-assistant' }] }) + }) + it('returns ordered user/assistant/tool messages with no 5-message cap', async () => { const records: unknown[] = [] // 4 user/assistant turns = 8 messages, well past the AI-Vault preview cap. @@ -280,3 +299,37 @@ describe('readNativeChatTranscript (errors)', () => { } }) }) + +describe('readNativeChatTranscriptTailFile', () => { + it('keeps a missing tail retry-worthy for the live-session seed', async () => { + const result = await readNativeChatTranscriptTail({ + agent: 'claude', + sessionId: 'sess', + filePath: join(tmpdir(), 'orca-native-chat-tail-does-not-exist.jsonl'), + limit: 40 + }) + + expect(result).toMatchObject({ notFound: true }) + }) + + it('windows to nothing for a non-positive limit instead of the whole tail', async () => { + const decode = nativeChatLineDecoderForAgent('claude')! + const filePath = await writeFixture('orca-native-chat-tail-limit-', [ + { + type: 'assistant', + uuid: 'a-1', + message: { role: 'assistant', content: [{ type: 'text', text: 'one' }] } + }, + { + type: 'assistant', + uuid: 'a-2', + message: { role: 'assistant', content: [{ type: 'text', text: 'two' }] } + } + ]) + + const result = await readNativeChatTranscriptTailFile(filePath, 0, decode, true) + + expect(result.messages).toEqual([]) + expect(result.hasMore).toBe(false) + }) +}) diff --git a/src/main/native-chat/transcript-reader.ts b/src/main/native-chat/transcript-reader.ts index e2ea727c8091..182296bda1d9 100644 --- a/src/main/native-chat/transcript-reader.ts +++ b/src/main/native-chat/transcript-reader.ts @@ -1,5 +1,6 @@ import { createReadStream } from 'node:fs' import type { AgentType, NativeChatMessage } from '../../shared/native-chat-types' +import { resolveNativeChatTranscriptAgent } from '../../shared/native-chat-agent-support' import { errorMessage } from '../ai-vault/session-scanner-values' import { resolveSessionFilePath, type ResolveSessionFileOptions } from './session-file-resolver' import { @@ -37,13 +38,14 @@ export async function readNativeChatTranscript( return { error: `No transcript found for ${agent} session ${sessionId}`, notFound: true } } try { - if (agent === 'claude') { + const transcriptAgent = resolveNativeChatTranscriptAgent(agent) + if (transcriptAgent === 'claude') { return { messages: await readTranscript(filePath, decodeClaudeTranscriptLine) } } - if (agent === 'codex') { + if (transcriptAgent === 'codex') { return { messages: await readTranscript(filePath, decodeCodexTranscriptLine) } } - if (agent === 'grok') { + if (transcriptAgent === 'grok') { return { messages: await readTranscript(filePath, decodeGrokTranscriptLine) } } return { error: `Unsupported agent for native chat transcript: ${agent}` } diff --git a/src/main/native-chat/transcript-tail-reader.ts b/src/main/native-chat/transcript-tail-reader.ts new file mode 100644 index 000000000000..9ac029d59132 --- /dev/null +++ b/src/main/native-chat/transcript-tail-reader.ts @@ -0,0 +1,200 @@ +import { open, stat } from 'node:fs/promises' +import type { AgentType, NativeChatMessage } from '../../shared/native-chat-types' +import { resolveNativeChatTranscriptAgent } from '../../shared/native-chat-agent-support' +import { resolveSessionFilePath, type ResolveSessionFileOptions } from './session-file-resolver' +import { + decodeClaudeTranscriptLine, + decodeCodexTranscriptLine, + decodeGrokTranscriptLine +} from './transcript-line-decoders' +import { transcriptFallbackId } from './transcript-fallback-id' + +export const MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES = 2 * 1024 * 1024 +const TAIL_CHUNK_BYTES = 64 * 1024 + +export type NativeChatLineDecoder = (line: string, fallbackId: string) => NativeChatMessage | null + +export function nativeChatLineDecoderForAgent(agent: AgentType): NativeChatLineDecoder | null { + const transcriptAgent = resolveNativeChatTranscriptAgent(agent) + if (transcriptAgent === 'claude') { + return decodeClaudeTranscriptLine + } + if (transcriptAgent === 'codex') { + return decodeCodexTranscriptLine + } + if (transcriptAgent === 'grok') { + return decodeGrokTranscriptLine + } + return null +} + +export async function readNativeChatTranscriptTailFile( + filePath: string, + limit: number, + decode: NativeChatLineDecoder, + includeTrailingLine = false, + endOffset?: number +): Promise<{ + messages: NativeChatMessage[] + consumedTo: number + hasMore: boolean + beforeOffset: number +}> { + const end = Math.min((await stat(filePath)).size, endOffset ?? Number.MAX_SAFE_INTEGER) + if (end === 0) { + return { messages: [], consumedTo: 0, hasMore: false, beforeOffset: 0 } + } + const handle = await open(filePath, 'r') + const lineParts: Buffer[] = [] + let lineBytes = 0 + let lineOversized = false + try { + const consumedTo = includeTrailingLine ? end : await findLastCompleteLineEnd(handle, end) + if (consumedTo === 0) { + return { messages: [], consumedTo: 0, hasMore: false, beforeOffset: 0 } + } + const newestFirst: { message: NativeChatMessage; offset: number }[] = [] + const finalByte = Buffer.allocUnsafe(1) + await handle.read(finalByte, 0, 1, consumedTo - 1) + let cursor = consumedTo - (finalByte[0] === 0x0a ? 1 : 0) + while (cursor > 0 && newestFirst.length <= limit) { + const start = Math.max(0, cursor - TAIL_CHUNK_BYTES) + const buffer = Buffer.allocUnsafe(cursor - start) + const { bytesRead } = await handle.read(buffer, 0, buffer.length, start) + let segmentEnd = bytesRead + for (let index = bytesRead - 1; index >= 0 && newestFirst.length <= limit; index--) { + if (buffer[index] !== 0x0a) { + continue + } + retainPart(buffer.subarray(index + 1, segmentEnd)) + if (!lineOversized) { + decodeLine(start + index + 1, newestFirst) + } + resetLine() + segmentEnd = index + } + if (segmentEnd > 0) { + retainPart(buffer.subarray(0, segmentEnd)) + } + cursor = start + } + if (cursor === 0 && lineParts.length > 0 && newestFirst.length <= limit) { + decodeLine(0, newestFirst) + } + const chronological = newestFirst.toReversed() + // Why: slice(-0) returns the whole array, so a non-positive limit must + // window to nothing explicitly rather than leak every buffered record. + const selected = limit > 0 ? chronological.slice(Math.max(0, chronological.length - limit)) : [] + return { + messages: selected.map((entry) => entry.message), + consumedTo, + hasMore: limit > 0 && chronological.length > limit, + beforeOffset: selected[0]?.offset ?? end + } + } finally { + await handle.close() + } + + function retainPart(part: Buffer): void { + if (lineOversized) { + return + } + lineBytes += part.length + if (lineBytes > MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES) { + lineParts.length = 0 + lineOversized = true + return + } + lineParts.push(part) + } + + function resetLine(): void { + lineParts.length = 0 + lineBytes = 0 + lineOversized = false + } + + function decodeLine( + lineOffset: number, + messages: { message: NativeChatMessage; offset: number }[] + ): void { + let line = Buffer.concat([...lineParts].toReversed()).toString('utf8') + if (line.endsWith('\r')) { + line = line.slice(0, -1) + } + if (!line) { + return + } + const message = decode(line, transcriptFallbackId(filePath, lineOffset)) + if (message) { + messages.push({ message, offset: lineOffset }) + } + } +} + +async function findLastCompleteLineEnd( + handle: Awaited>, + end: number +): Promise { + const lastByte = Buffer.allocUnsafe(1) + await handle.read(lastByte, 0, 1, end - 1) + if (lastByte[0] === 0x0a) { + return end + } + let cursor = end + while (cursor > 0) { + const start = Math.max(0, cursor - TAIL_CHUNK_BYTES) + const buffer = Buffer.allocUnsafe(cursor - start) + const { bytesRead } = await handle.read(buffer, 0, buffer.length, start) + const newline = buffer.subarray(0, bytesRead).lastIndexOf(0x0a) + if (newline >= 0) { + return start + newline + 1 + } + cursor = start + } + return 0 +} + +export async function readNativeChatTranscriptTail( + args: ResolveSessionFileOptions & { + agent: AgentType + sessionId: string + transcriptPath?: string + filePath?: string + limit: number + beforeOffset?: number + } +): Promise< + | { messages: NativeChatMessage[]; hasMore: boolean; beforeOffset: number } + | { error: string; notFound?: true } +> { + const decode = nativeChatLineDecoderForAgent(args.agent) + const filePath = args.filePath ?? (await resolveSessionFilePath(args.agent, args.sessionId, args)) + if (!decode) { + return { error: 'Transcript unavailable' } + } + // Why: a new agent session can report its id before the first JSONL flush; + // callers keep that miss in loading/retry rather than showing a false error. + if (!filePath) { + return { error: 'Transcript unavailable', notFound: true } + } + try { + const result = await readNativeChatTranscriptTailFile( + filePath, + args.limit, + decode, + true, + args.beforeOffset + ) + return { + messages: result.messages, + hasMore: result.hasMore, + beforeOffset: result.beforeOffset + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' + ? { error: message, notFound: true } + : { error: message } + } +} diff --git a/src/main/native-chat/transcript-watch-engine.ts b/src/main/native-chat/transcript-watch-engine.ts new file mode 100644 index 000000000000..d610f5a1536b --- /dev/null +++ b/src/main/native-chat/transcript-watch-engine.ts @@ -0,0 +1,310 @@ +import { watch, type FSWatcher } from 'node:fs' +import { open, stat } from 'node:fs/promises' +import { basename, dirname } from 'node:path' +import type { AgentType, NativeChatMessage } from '../../shared/native-chat-types' +import type { ResolveSessionFileOptions } from './session-file-resolver' +import { + readIncrementalTranscriptMessages, + resetIncrementalTranscriptState, + type IncrementalTranscriptState +} from './transcript-incremental-reader' +import { readNativeChatTranscriptTailFile } from './transcript-tail-reader' + +export type SubscribeNativeChatTranscriptArgs = ResolveSessionFileOptions & { + agent: AgentType + sessionId: string + onAppend: (messages: NativeChatMessage[]) => void + onInitialSnapshot?: ( + messages: NativeChatMessage[], + hasMore: boolean, + beforeOffset: number, + /** Set when the initial drain could not deliver a transcript; the subscriber + * surfaces it as an error snapshot so a watching client never sticks on + * 'loading'. Empty messages accompany it. */ + error?: string + ) => void + onReplace?: (messages: NativeChatMessage[], hasMore: boolean, beforeOffset: number) => void + initialLimit?: number + filePath?: string + debounceMs?: number + /** Overrides the resolve-poll interval (see subscribeViaResolvePoll) so tests + * don't wait out the production backoff. Production ignores this and backs + * off from 500ms to a 5s cap. */ + resolvePollIntervalMs?: number +} + +export type NativeChatTranscriptSubscription = { + unsubscribe: () => void + watching: boolean +} + +const DEFAULT_DEBOUNCE_MS = 40 +const ROTATION_RETRY_MS = 25 +const MAX_ROTATION_RETRY_MS = 2_000 +let activeWatcherCount = 0 + +export function getActiveNativeChatWatcherCount(): number { + return activeWatcherCount +} + +async function fileVersion(filePath: string): Promise<{ identity: string; size: number }> { + const value = await stat(filePath) + return { identity: `${value.dev}:${value.ino}`, size: value.size } +} + +async function boundaryFingerprint(filePath: string, offset: number): Promise { + if (offset <= 0) { + return '' + } + const start = Math.max(0, offset - 64) + const handle = await open(filePath, 'r') + try { + const buffer = Buffer.allocUnsafe(offset - start) + const { bytesRead } = await handle.read(buffer, 0, buffer.length, start) + return buffer.subarray(0, bytesRead).toString('base64') + } finally { + await handle.close() + } +} + +/** + * Install the live-tail engine on an already-resolved file path. Returns null + * (rather than throwing) when the file doesn't exist yet or `watch()` fails — + * e.g. an explicit transcriptPath whose first JSONL line hasn't flushed, or a + * file that vanished between resolve and install — so the caller falls back to + * the resolve-poll below instead of surfacing a hard error (#8401). The + * existence gate matters because the engine watches the parent directory (see + * bindWatcher), which succeeds even when the file itself is missing. + */ +export async function installTranscriptWatcher( + filePath: string, + decode: (line: string, fallbackId: string) => NativeChatMessage | null, + args: SubscribeNativeChatTranscriptArgs +): Promise { + try { + await stat(filePath) + } catch { + return null + } + const { onAppend, onInitialSnapshot, onReplace, initialLimit, debounceMs } = args + + const state: IncrementalTranscriptState = { + offset: 0, + pendingChunks: [], + pendingStart: 0, + pendingBytes: 0, + droppingOversizedRecord: false + } + let watchedIdentity: string | null = null + let watchedBoundary = '' + let initialDrain = true + // Guards the one-time error snapshot emitted when the initial drain throws, so + // a persistently-failing retry loop can't spam the subscriber with error frames. + let initialErrorEmitted = false + let closed = false + let reading = false + let pendingReadRequested = false + let rotationRetryCount = 0 + let debounceTimer: ReturnType | null = null + let watcher: FSWatcher | null = null + let watcherNeedsRebind = false + + function scheduleDrain(): void { + if (closed) { + return + } + if (debounceTimer) { + clearTimeout(debounceTimer) + } + debounceTimer = setTimeout(() => { + debounceTimer = null + void drain() + }, debounceMs ?? DEFAULT_DEBOUNCE_MS) + } + + function scheduleRotationRetry(): void { + if (closed || debounceTimer) { + return + } + const retryDelay = Math.min( + ROTATION_RETRY_MS * 2 ** Math.min(rotationRetryCount, 7), + MAX_ROTATION_RETRY_MS + ) + rotationRetryCount += 1 + debounceTimer = setTimeout(() => { + debounceTimer = null + void drain() + }, retryDelay) + } + + async function readAndEmitAppends(): Promise { + const remaining = await readIncrementalTranscriptMessages( + filePath, + state, + decode, + (messages) => { + if (!closed) { + onAppend(messages) + } + } + ) + if (!closed && remaining.length > 0) { + onAppend(remaining) + } + } + + async function drainOnce(): Promise { + const current = await fileVersion(filePath) + const currentBoundary = await boundaryFingerprint(filePath, state.offset) + if (closed) { + return + } + if (watcherNeedsRebind) { + bindWatcher() + } + const identityChanged = watchedIdentity !== null && current.identity !== watchedIdentity + const contentReplaced = + identityChanged || + current.size < state.offset || + (state.offset > 0 && watchedBoundary !== currentBoundary) + if (contentReplaced) { + resetIncrementalTranscriptState(state) + } + watchedIdentity = current.identity + + const replacementSnapshot = + // Why: 0 is a valid window — an explicit undefined check keeps an empty + // snapshot empty instead of falling back to an unbounded incremental read. + contentReplaced && !initialDrain && onReplace && initialLimit !== undefined + ? await readNativeChatTranscriptTailFile(filePath, initialLimit, decode) + : null + if (closed) { + return + } + if (replacementSnapshot && onReplace) { + state.offset = replacementSnapshot.consumedTo + state.pendingStart = state.offset + onReplace( + replacementSnapshot.messages, + replacementSnapshot.hasMore, + replacementSnapshot.beforeOffset + ) + await readAndEmitAppends() + watchedBoundary = await boundaryFingerprint(filePath, state.offset) + rotationRetryCount = 0 + return + } + + const initialSnapshot = + initialDrain && onInitialSnapshot && initialLimit !== undefined + ? await readNativeChatTranscriptTailFile(filePath, initialLimit, decode) + : null + if (closed) { + return + } + if (initialDrain && onInitialSnapshot) { + initialDrain = false + if (initialSnapshot) { + state.offset = initialSnapshot.consumedTo + state.pendingStart = state.offset + onInitialSnapshot( + initialSnapshot.messages, + initialSnapshot.hasMore, + initialSnapshot.beforeOffset + ) + await readAndEmitAppends() + } else { + const messages = await readIncrementalTranscriptMessages(filePath, state, decode) + onInitialSnapshot(messages, false, 0) + } + } else { + initialDrain = false + await readAndEmitAppends() + } + watchedBoundary = await boundaryFingerprint(filePath, state.offset) + rotationRetryCount = 0 + } + + async function drain(): Promise { + if (closed) { + return + } + if (reading) { + pendingReadRequested = true + return + } + reading = true + try { + do { + pendingReadRequested = false + try { + await drainOnce() + } catch { + // Why: unlink/recreate can detach fs.watch from the pathname. Keep one + // capped-backoff retry alive until a successor appears or we unsubscribe. + // A still-pending initial drain also surfaces one error snapshot so a + // watching client isn't stranded at 'loading' when the read keeps + // throwing; initialDrain stays true so a recovered read can still win. + if (initialDrain && onInitialSnapshot && !initialErrorEmitted) { + initialErrorEmitted = true + onInitialSnapshot([], false, 0, 'Transcript unavailable') + } + scheduleRotationRetry() + break + } + } while (pendingReadRequested && !closed) + } finally { + reading = false + } + } + + function bindWatcher(): void { + const watchedName = basename(filePath) + // Why: file watchers stay bound to an unlinked inode on macOS. Watching + // the parent keeps observing a successor even after a long recreate gap. + const nextWatcher = watch(dirname(filePath), (_event, changedName) => { + if (changedName === null || changedName.toString() === watchedName) { + scheduleDrain() + } + }) + nextWatcher.on('error', () => { + // Why: Windows can emit EPERM when a watched directory disappears. An + // error listener prevents a process crash and retries against its successor. + if (closed || watcher !== nextWatcher) { + return + } + watcherNeedsRebind = true + nextWatcher.close() + watcher = null + scheduleRotationRetry() + }) + watcher = nextWatcher + watcherNeedsRebind = false + } + + try { + bindWatcher() + } catch { + // File vanished between resolve and watch. + return null + } + activeWatcherCount++ + scheduleDrain() + + return { + watching: true, + unsubscribe: () => { + if (closed) { + return + } + closed = true + if (debounceTimer) { + clearTimeout(debounceTimer) + debounceTimer = null + } + watcher?.close() + watcher = null + activeWatcherCount-- + } + } +} diff --git a/src/main/native-chat/transcript-watch-error.test.ts b/src/main/native-chat/transcript-watch-error.test.ts new file mode 100644 index 000000000000..2d3b08286646 --- /dev/null +++ b/src/main/native-chat/transcript-watch-error.test.ts @@ -0,0 +1,172 @@ +import { EventEmitter } from 'node:events' +import type { FSWatcher } from 'node:fs' +import type * as NodeFs from 'node:fs' +import { appendFile, mkdir, 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' + +const { watchers, watchCallbacks, watchMock } = vi.hoisted(() => ({ + watchers: [] as (EventEmitter & { close: ReturnType })[], + watchCallbacks: [] as ((event: string, filename: string | Buffer | null) => void)[], + watchMock: vi.fn() +})) + +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs') + watchMock.mockImplementation((_path, callback) => { + const watcher = Object.assign(new EventEmitter(), { close: vi.fn() }) + watchers.push(watcher) + watchCallbacks.push(callback) + return watcher as unknown as FSWatcher + }) + return { ...actual, watch: watchMock } +}) + +import { subscribeNativeChatTranscript } from './transcript-watch' + +const roots: string[] = [] + +afterEach(async () => { + watchers.length = 0 + watchCallbacks.length = 0 + watchMock.mockClear() + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('native chat transcript watcher errors', () => { + it('handles a watcher error and rebinds after the directory is readable', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-watch-error-')) + roots.push(root) + const filePath = join(root, 'transcript.jsonl') + await writeFile(filePath, '') + const subscription = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'session', + filePath, + onAppend: () => {}, + debounceMs: 0 + }) + + expect(() => watchers[0]!.emit('error', new Error('EPERM'))).not.toThrow() + await vi.waitFor(() => expect(watchMock).toHaveBeenCalledTimes(2)) + + subscription.unsubscribe() + expect(watchers[0]!.close).toHaveBeenCalledOnce() + expect(watchers[1]!.close).toHaveBeenCalledOnce() + }) + + it('keeps retrying after the old recovery window and tails a recreated directory', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-watch-gap-')) + roots.push(root) + const filePath = join(root, 'transcript.jsonl') + await writeFile(filePath, '') + const onInitialSnapshot = vi.fn() + const onAppend = vi.fn() + const subscription = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'session', + filePath, + onInitialSnapshot, + onAppend, + initialLimit: 40, + debounceMs: 0 + }) + await vi.waitFor(() => expect(onInitialSnapshot).toHaveBeenCalledOnce()) + + await rm(root, { recursive: true, force: true }) + watchers[0]!.emit('error', new Error('EPERM')) + await new Promise((resolve) => setTimeout(resolve, 1_200)) + expect(watchMock).toHaveBeenCalledTimes(1) + + await mkdir(root, { recursive: true }) + await writeFile(filePath, claudeLine('u-recreated', 'user', 'back')) + await vi.waitFor(() => expect(watchMock).toHaveBeenCalledTimes(2), { timeout: 2_000 }) + + await appendFile(filePath, claudeLine('a-recreated', 'assistant', 'reply')) + watchCallbacks[1]!('change', 'transcript.jsonl') + await vi.waitFor(() => + expect(onAppend.mock.calls.flat(2)).toEqual( + expect.arrayContaining([expect.objectContaining({ id: 'a-recreated' })]) + ) + ) + + subscription.unsubscribe() + expect(watchers[1]!.close).toHaveBeenCalledOnce() + }) + + it('surfaces an error snapshot when the initial drain throws', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-initial-error-')) + roots.push(root) + // A directory sitting at the transcript path: it exists (so install does not + // defer to the not-yet-flushed resolve poll, #8401) but every tail read + // throws EISDIR — a persistent real read error, not a missing file. + const filePath = join(root, 'transcript.jsonl') + await mkdir(filePath) + const onInitialSnapshot = vi.fn() + const onAppend = vi.fn() + const subscription = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'session', + filePath, + onInitialSnapshot, + onAppend, + initialLimit: 40, + debounceMs: 0 + }) + expect(subscription.watching).toBe(true) + + await vi.waitFor(() => + expect(onInitialSnapshot).toHaveBeenCalledWith([], false, 0, 'Transcript unavailable') + ) + // Surfaced once, not spammed by the capped rotation retry loop. + await new Promise((resolve) => setTimeout(resolve, 120)) + expect(onInitialSnapshot).toHaveBeenCalledOnce() + + subscription.unsubscribe() + }) + + it('still wins with a real initial snapshot once the transcript becomes readable', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-initial-recover-')) + roots.push(root) + // Same unreadable-directory setup as above; the error frame must not be + // terminal once the path is replaced with a readable transcript. + const filePath = join(root, 'transcript.jsonl') + await mkdir(filePath) + const onInitialSnapshot = vi.fn() + const subscription = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'session', + filePath, + onInitialSnapshot, + onAppend: () => {}, + initialLimit: 40, + debounceMs: 0 + }) + await vi.waitFor(() => + expect(onInitialSnapshot).toHaveBeenCalledWith([], false, 0, 'Transcript unavailable') + ) + + // initialDrain stays true after the error, so a recovered read delivers the + // real snapshot instead of stranding the client on the error frame. + await rm(filePath, { recursive: true, force: true }) + await writeFile(filePath, claudeLine('u-recovered', 'user', 'back')) + watchCallbacks[0]!('change', 'transcript.jsonl') + await vi.waitFor(() => + expect(onInitialSnapshot.mock.calls.flat(2)).toEqual( + expect.arrayContaining([expect.objectContaining({ id: 'u-recovered' })]) + ) + ) + + subscription.unsubscribe() + }) +}) + +function claudeLine(uuid: string, role: 'user' | 'assistant', text: string): string { + return `${JSON.stringify({ + type: role, + uuid, + timestamp: '2026-06-01T10:00:00.000Z', + message: { role, content: role === 'user' ? text : [{ type: 'text', text }] } + })}\n` +} diff --git a/src/main/native-chat/transcript-watch-resolve-poll.test.ts b/src/main/native-chat/transcript-watch-resolve-poll.test.ts new file mode 100644 index 000000000000..be4e60dede32 --- /dev/null +++ b/src/main/native-chat/transcript-watch-resolve-poll.test.ts @@ -0,0 +1,62 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + install: vi.fn(), + resolve: vi.fn() +})) + +vi.mock('./session-file-resolver', () => ({ + resolveSessionFilePath: mocks.resolve +})) +vi.mock('./transcript-watch-engine', () => ({ + getActiveNativeChatWatcherCount: vi.fn(() => 0), + installTranscriptWatcher: mocks.install +})) + +import { subscribeNativeChatTranscript } from './transcript-watch' + +describe('native chat transcript resolve polling', () => { + beforeEach(() => { + vi.useFakeTimers() + mocks.install.mockReset().mockReturnValue(null) + mocks.resolve.mockReset().mockResolvedValue(null) + }) + + afterEach(() => vi.useRealTimers()) + + it('fast-probes an exact hook path without repeatedly scanning the session tree', async () => { + const subscription = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'session-id', + transcriptPath: '/missing/exact.jsonl', + resolvePollIntervalMs: 10, + onAppend: () => {} + }) + expect(mocks.resolve).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(100) + expect(mocks.install.mock.calls.length).toBeGreaterThan(1) + expect(mocks.resolve).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(4_900) + expect(mocks.resolve).toHaveBeenCalledTimes(2) + + subscription.unsubscribe() + const callsAfterUnsubscribe = mocks.install.mock.calls.length + await vi.advanceTimersByTimeAsync(100) + expect(mocks.install).toHaveBeenCalledTimes(callsAfterUnsubscribe) + }) + + it('keeps resolving on every retry when no exact hook path is available', async () => { + const subscription = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'session-id', + resolvePollIntervalMs: 10, + onAppend: () => {} + }) + + await vi.advanceTimersByTimeAsync(35) + expect(mocks.resolve.mock.calls.length).toBeGreaterThan(1) + subscription.unsubscribe() + }) +}) diff --git a/src/main/native-chat/transcript-watch.test.ts b/src/main/native-chat/transcript-watch.test.ts index 4ff621d05bc3..512da6b014e8 100644 --- a/src/main/native-chat/transcript-watch.test.ts +++ b/src/main/native-chat/transcript-watch.test.ts @@ -1,9 +1,13 @@ -import { appendFile, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { appendFile, mkdtemp, rename, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { NativeChatMessage } from '../../shared/native-chat-types' -import { getActiveNativeChatWatcherCount, subscribeNativeChatTranscript } from './transcript-watch' +import { + getActiveNativeChatWatcherCount, + readNativeChatTranscriptTail, + subscribeNativeChatTranscript +} from './transcript-watch' let tempRoots: string[] = [] @@ -52,6 +56,191 @@ async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise { + it('delivers the offset-0 drain as one initial snapshot, then only live appends', async () => { + const filePath = await tempFile(claudeLine('u-1', 'user', 'first')) + const snapshots: NativeChatMessage[][] = [] + const appends: NativeChatMessage[][] = [] + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + onInitialSnapshot: (messages) => snapshots.push(messages), + onAppend: (messages) => appends.push(messages), + debounceMs: 5 + }) + + expect(sub.watching).toBe(true) + await waitFor(() => snapshots.length === 1) + expect(snapshots[0]?.map((message) => message.id)).toEqual(['u-1']) + expect(appends).toEqual([]) + + await appendFile(filePath, claudeLine('a-1', 'assistant', 'reply')) + await waitFor(() => appends.flat().some((message) => message.id === 'a-1')) + sub.unsubscribe() + + expect(snapshots).toHaveLength(1) + expect(appends.flat().map((message) => message.id)).toEqual(['a-1']) + }) + + it('delivers an empty initial snapshot so clients do not remain loading', async () => { + const filePath = await tempFile('') + const snapshots: NativeChatMessage[][] = [] + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + onInitialSnapshot: (messages) => snapshots.push(messages), + onAppend: () => {}, + debounceMs: 5 + }) + + await waitFor(() => snapshots.length === 1) + sub.unsubscribe() + expect(snapshots).toEqual([[]]) + }) + + it('emits a bulk append in bounded ordered batches', async () => { + const filePath = await tempFile('') + const batches: NativeChatMessage[][] = [] + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + onAppend: (messages) => batches.push(messages), + debounceMs: 5 + }) + await new Promise((resolve) => setTimeout(resolve, 20)) + await appendFile( + filePath, + Array.from({ length: 95 }, (_unused, index) => + claudeLine(`bulk-${index}`, 'user', `message-${index}`) + ).join('') + ) + + await waitFor(() => batches.flat().length === 95) + sub.unsubscribe() + expect(batches.map((batch) => batch.length)).toEqual([40, 40, 15]) + expect(batches.flat().map((message) => message.id)).toEqual( + Array.from({ length: 95 }, (_unused, index) => `bulk-${index}`) + ) + }) + + it('drops one oversized record without retaining or blocking the next append', async () => { + const filePath = await tempFile('') + const seen: NativeChatMessage[] = [] + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + onAppend: (messages) => seen.push(...messages), + debounceMs: 5 + }) + await new Promise((resolve) => setTimeout(resolve, 20)) + await appendFile( + filePath, + claudeLine('oversized', 'user', 'x'.repeat(2 * 1024 * 1024)) + + claudeLine('after-oversized', 'user', 'still delivered') + ) + + await waitFor(() => seen.some((message) => message.id === 'after-oversized')) + sub.unsubscribe() + expect(seen.some((message) => message.id === 'oversized')).toBe(false) + }) + + it('returns a bounded tail snapshot with exact pagination state', async () => { + const transcript = Array.from({ length: 800 }, (_unused, index) => + claudeLine(`u-${index}`, 'user', `message-${index}-${'x'.repeat(100)}`) + ).join('') + const filePath = await tempFile(transcript) + const snapshots: { ids: string[]; hasMore: boolean }[] = [] + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + initialLimit: 2, + onInitialSnapshot: (messages, hasMore) => + snapshots.push({ ids: messages.map((message) => message.id), hasMore }), + onAppend: () => {}, + debounceMs: 5 + }) + + await waitFor(() => snapshots.length === 1) + sub.unsubscribe() + expect(snapshots).toEqual([{ ids: ['u-798', 'u-799'], hasMore: true }]) + }) + + it('keeps an explicit zero-limit snapshot empty instead of reading unbounded', async () => { + const filePath = await tempFile(claudeLine('u-0', 'user', 'hello')) + const snapshots: { ids: string[]; hasMore: boolean }[] = [] + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + initialLimit: 0, + onInitialSnapshot: (messages, hasMore) => + snapshots.push({ ids: messages.map((message) => message.id), hasMore }), + onAppend: () => {}, + debounceMs: 5 + }) + + await waitFor(() => snapshots.length === 1) + sub.unsubscribe() + expect(snapshots).toEqual([{ ids: [], hasMore: false }]) + }) + + it('pages older history by byte cursor without resending the growing tail', async () => { + const filePath = await tempFile( + Array.from({ length: 10 }, (_unused, index) => + claudeLine(`page-${index}`, 'user', `message-${index}`) + ).join('') + ) + const newest = await readNativeChatTranscriptTail({ + agent: 'claude', + sessionId: 'ignored', + filePath, + limit: 3 + }) + if ('error' in newest) { + throw new Error(newest.error) + } + const older = await readNativeChatTranscriptTail({ + agent: 'claude', + sessionId: 'ignored', + filePath, + limit: 3, + beforeOffset: newest.beforeOffset + }) + + expect(newest.messages.map((message) => message.id)).toEqual(['page-7', 'page-8', 'page-9']) + expect('messages' in older && older.messages.map((message) => message.id)).toEqual([ + 'page-4', + 'page-5', + 'page-6' + ]) + }) + + it('decodes multi-chunk records once into the bounded tail order', async () => { + const large = 'x'.repeat(200_000) + const filePath = await tempFile( + claudeLine('u-large-1', 'user', large) + claudeLine('u-large-2', 'user', large) + ) + const snapshots: { ids: string[]; hasMore: boolean }[] = [] + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + initialLimit: 1, + onInitialSnapshot: (messages, hasMore) => + snapshots.push({ ids: messages.map((message) => message.id), hasMore }), + onAppend: () => {}, + debounceMs: 5 + }) + + await waitFor(() => snapshots.length === 1) + sub.unsubscribe() + expect(snapshots).toEqual([{ ids: ['u-large-2'], hasMore: true }]) + }) + it('re-emits from the top on first drain so appended turns are never dropped', async () => { const filePath = await tempFile(claudeLine('u-1', 'user', 'first')) const batches: NativeChatMessage[][] = [] @@ -233,6 +422,109 @@ describe('subscribeNativeChatTranscript', () => { expect(ids).toContain('a-2') }) + it('detects same-size and larger in-place transcript replacement', async () => { + const filePath = await tempFile(claudeLine('u-old', 'user', 'old')) + const seen: NativeChatMessage[] = [] + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + onAppend: (messages) => seen.push(...messages), + debounceMs: 5 + }) + await waitFor(() => seen.some((message) => message.id === 'u-old')) + + await writeFile(filePath, claudeLine('u-new', 'user', 'new')) + await waitFor(() => seen.some((message) => message.id === 'u-new')) + await writeFile(filePath, claudeLine('u-bigger', 'user', 'larger replacement text')) + await waitFor(() => seen.some((message) => message.id === 'u-bigger')) + sub.unsubscribe() + + expect(seen.filter((message) => message.id === 'u-new')).toHaveLength(1) + expect(seen.filter((message) => message.id === 'u-bigger')).toHaveLength(1) + }) + + it('keeps watching after atomic rename replacement', async () => { + const filePath = await tempFile(claudeLine('atomic-old', 'user', 'old')) + const replacementPath = `${filePath}.replacement` + const seen: NativeChatMessage[] = [] + const replacements: string[][] = [] + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + initialLimit: 40, + onInitialSnapshot: (messages) => seen.push(...messages), + onReplace: (messages) => { + replacements.push(messages.map((message) => message.id)) + seen.splice(0, seen.length, ...messages) + }, + onAppend: (messages) => seen.push(...messages), + debounceMs: 5 + }) + await waitFor(() => seen.some((message) => message.id === 'atomic-old')) + + await writeFile(replacementPath, claudeLine('atomic-new', 'user', 'replacement')) + await rename(replacementPath, filePath) + await waitFor(() => seen.some((message) => message.id === 'atomic-new')) + await appendFile(filePath, claudeLine('atomic-followup', 'assistant', 'still watched')) + await waitFor(() => seen.some((message) => message.id === 'atomic-followup')) + sub.unsubscribe() + + expect(replacements).toEqual([['atomic-new']]) + expect(seen.some((message) => message.id === 'atomic-old')).toBe(false) + expect(seen.filter((message) => message.id === 'atomic-followup')).toHaveLength(1) + }) + + it('does not resurrect the watcher when unsubscribe races an atomic replacement', async () => { + const filePath = await tempFile(claudeLine('race-old', 'user', 'old')) + const replacementPath = `${filePath}.replacement` + const before = getActiveNativeChatWatcherCount() + const seen: NativeChatMessage[] = [] + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + onAppend: (messages) => seen.push(...messages), + debounceMs: 0 + }) + await waitFor(() => seen.some((message) => message.id === 'race-old')) + + await writeFile(replacementPath, claudeLine('race-new', 'user', 'replacement')) + await rename(replacementPath, filePath) + await new Promise((resolve) => setTimeout(resolve, 0)) + sub.unsubscribe() + await new Promise((resolve) => setTimeout(resolve, 30)) + + expect(getActiveNativeChatWatcherCount()).toBe(before) + await appendFile(filePath, claudeLine('race-after', 'assistant', 'must stay closed')) + await new Promise((resolve) => setTimeout(resolve, 30)) + expect(seen.some((message) => message.id === 'race-after')).toBe(false) + }) + + it('recovers after an unlink/recreate gap outlasts the fast retry window', async () => { + const filePath = await tempFile(claudeLine('unlink-old', 'user', 'old')) + const seen: NativeChatMessage[] = [] + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + onAppend: (messages) => seen.push(...messages), + debounceMs: 0 + }) + await waitFor(() => seen.some((message) => message.id === 'unlink-old')) + + await rm(filePath) + await new Promise((resolve) => setTimeout(resolve, 1_200)) + await writeFile(filePath, claudeLine('unlink-new', 'user', 'replacement')) + await waitFor(() => seen.some((message) => message.id === 'unlink-new')) + await appendFile(filePath, claudeLine('unlink-after', 'assistant', 'still watched')) + await waitFor(() => seen.some((message) => message.id === 'unlink-after')) + sub.unsubscribe() + + expect(seen.filter((message) => message.id === 'unlink-after')).toHaveLength(1) + }) + it('returns a no-op unsubscribe when the file cannot be resolved', async () => { const before = getActiveNativeChatWatcherCount() const sub = await subscribeNativeChatTranscript({ @@ -240,6 +532,7 @@ describe('subscribeNativeChatTranscript', () => { sessionId: '', onAppend: () => {} }) + expect(sub.watching).toBe(false) expect(getActiveNativeChatWatcherCount()).toBe(before) // Must not throw. sub.unsubscribe() diff --git a/src/main/native-chat/transcript-watch.ts b/src/main/native-chat/transcript-watch.ts index 1f55c33124aa..f3228d970fc1 100644 --- a/src/main/native-chat/transcript-watch.ts +++ b/src/main/native-chat/transcript-watch.ts @@ -1,225 +1,19 @@ -// Live transcript tailing: watch a resolved session JSONL file and emit only -// the messages parsed from bytes appended since the last read. Modeled on the -// incremental byte-offset read in codex-usage/scanner.ts (parseCodexUsageFile's -// skipInitialBytes), but specialized to the NativeChatMessage record decoders. -// -// Teardown discipline (plan U4 risk: file-watch fd leaks): every subscription -// owns exactly one fs.FSWatcher and one debounce timer. unsubscribe() closes -// the watcher and clears the timer synchronously, and the module tracks the live -// watcher count so tests can assert no watcher survives teardown. - -import { watch, type FSWatcher } from 'node:fs' -import { open, stat } from 'node:fs/promises' -import type { AgentType, NativeChatMessage } from '../../shared/native-chat-types' -import { resolveSessionFilePath, type ResolveSessionFileOptions } from './session-file-resolver' +import { extname } from 'node:path' +import type { NativeChatMessage } from '../../shared/native-chat-types' +import { resolveSessionFilePath } from './session-file-resolver' import { - decodeClaudeTranscriptLine, - decodeCodexTranscriptLine, - decodeGrokTranscriptLine -} from './transcript-line-decoders' -import { decodeTranscriptStream } from './transcript-stream-lines' - -export type SubscribeNativeChatTranscriptArgs = ResolveSessionFileOptions & { - agent: AgentType - sessionId: string - /** Called with the newly-appended messages whenever the file grows. Never - * called with an empty array. */ - onAppend: (messages: NativeChatMessage[]) => void - /** Resolve directly to this file, skipping path discovery (used by tests). */ - filePath?: string - /** Coalesce window for rapid fs.watch events (ms). Defaults to 40ms. */ - debounceMs?: number - /** Overrides the resolve-poll interval (see subscribeViaResolvePoll) so tests - * don't wait out the production backoff. Production ignores this and backs - * off from 500ms to a 5s cap. */ - resolvePollIntervalMs?: number -} - -export type NativeChatTranscriptSubscription = { - /** Closes the watcher and releases the file handle. Idempotent. */ - unsubscribe: () => void -} - -// Why: a single watch event can fire several times for one append; we read from -// the last byte offset so re-entrant reads never re-emit prior messages. Each -// decoder is stateless per-line, so tailing reuses the same record→message -// mapping the full reader uses. -const DEFAULT_DEBOUNCE_MS = 40 - -// Why: process-wide count of live FSWatchers opened by this module. The U4 leak -// test asserts this returns to zero after unsubscribe so a forgotten handle is -// caught deterministically rather than relying on OS fd inspection. -let activeWatcherCount = 0 - -/** Test-only: number of fs watchers this module currently holds open. */ -export function getActiveNativeChatWatcherCount(): number { - return activeWatcherCount -} - -function lineDecoderForAgent( - agent: AgentType -): ((line: string, fallbackId: string) => NativeChatMessage | null) | null { - if (agent === 'claude') { - return decodeClaudeTranscriptLine - } - if (agent === 'codex') { - return decodeCodexTranscriptLine - } - if (agent === 'grok') { - return decodeGrokTranscriptLine - } - return null -} - -async function fileSize(filePath: string): Promise { - try { - return (await stat(filePath)).size - } catch { - return 0 - } -} - -/** - * Read bytes [start, end) of the file and decode each complete line into a - * NativeChatMessage. Opens its own fd and always closes it (no leak on the read - * path, distinct from the long-lived watcher). Returns the messages plus the - * byte offset actually consumed so a partially-written trailing line is re-read - * on the next append rather than dropped. - */ -async function readAppendedMessages( - filePath: string, - start: number, - decode: (line: string, fallbackId: string) => NativeChatMessage | null -): Promise<{ messages: NativeChatMessage[]; consumedTo: number }> { - const end = await fileSize(filePath) - if (end <= start) { - // File shrank (rotation/replacement) or unchanged — caller resets offset. - return { messages: [], consumedTo: end } - } - - const handle = await open(filePath, 'r') - try { - const stream = handle.createReadStream({ - encoding: 'utf-8', - start, - end: end - 1, - autoClose: false - }) - const { messages, consumedBytes } = await decodeTranscriptStream( - stream, - filePath, - start, - decode, - false - ) - return { messages, consumedTo: start + consumedBytes } - } finally { - await handle.close() - } -} - -/** - * Install a live-tail watcher on an already-resolved file path. Returns null - * (rather than throwing) when `watch()` fails — e.g. the file vanished between - * resolve and install — so the caller can fall back to the resolve-poll below - * instead of surfacing a hard error. - */ -function installTranscriptWatcher( - filePath: string, - decode: (line: string, fallbackId: string) => NativeChatMessage | null, - onAppend: (messages: NativeChatMessage[]) => void, - debounceMs?: number -): NativeChatTranscriptSubscription | null { - // Why: seed the offset at 0 so the FIRST drain re-reads the whole file. This - // closes the read/subscribe race — a turn appended between the caller's - // readSession EOF and the watcher install is still emitted. Re-emitted lines - // collapse by deterministic id in the assembler (no dup, no drop). Subsequent - // drains use the incremental offset so the full re-read happens only once. - let offset = 0 - let closed = false - let reading = false - let pendingReadRequested = false - let debounceTimer: ReturnType | null = null - - async function drain(): Promise { - if (closed) { - return - } - if (reading) { - // A read is already in flight; mark that another pass is needed so rapid - // successive appends coalesce without dropping the trailing one. - pendingReadRequested = true - return - } - reading = true - try { - do { - pendingReadRequested = false - try { - const currentSize = await fileSize(filePath) - if (currentSize < offset) { - // Rotation/replacement/truncation: re-read from the top. - offset = 0 - } - const { messages, consumedTo } = await readAppendedMessages(filePath, offset, decode) - offset = consumedTo - if (!closed && messages.length > 0) { - onAppend(messages) - } - } catch { - // Why: a transient read failure (EACCES/EIO/ENOENT during rotation) - // must not leave the subscription permanently deaf. Stop this drain; - // the finally resets `reading` so a later fs event re-arms the read. - break - } - } while (pendingReadRequested && !closed) - } finally { - reading = false - } - } - - function scheduleDrain(): void { - if (closed) { - return - } - if (debounceTimer) { - clearTimeout(debounceTimer) - } - debounceTimer = setTimeout(() => { - debounceTimer = null - void drain() - }, debounceMs ?? DEFAULT_DEBOUNCE_MS) - } - - let watcher: FSWatcher - try { - watcher = watch(filePath, scheduleDrain) - } catch { - // File vanished between resolve and watch. - return null - } - activeWatcherCount++ - - // Why: on some platforms fs.watch can miss the very first append that lands - // between offset-seed and watcher install. Kick one debounced drain so a - // turn written immediately after subscribe is still picked up. - scheduleDrain() - - return { - unsubscribe: () => { - if (closed) { - return - } - closed = true - if (debounceTimer) { - clearTimeout(debounceTimer) - debounceTimer = null - } - watcher.close() - activeWatcherCount-- - } - } -} + installTranscriptWatcher, + type NativeChatTranscriptSubscription, + type SubscribeNativeChatTranscriptArgs +} from './transcript-watch-engine' +import { nativeChatLineDecoderForAgent } from './transcript-tail-reader' + +export { readNativeChatTranscriptTail } from './transcript-tail-reader' +export { + getActiveNativeChatWatcherCount, + type NativeChatTranscriptSubscription, + type SubscribeNativeChatTranscriptArgs +} from './transcript-watch-engine' /** One resolve+install attempt. Returns null when the file isn't resolvable * yet, or vanished between resolve and `watch()` — either case is retried by @@ -232,22 +26,30 @@ async function attemptInstall( if (!filePath) { return null } - return installTranscriptWatcher(filePath, decode, args.onAppend, args.debounceMs) + return installTranscriptWatcher(filePath, decode, args) } // Why: Claude Code (and other agents) can take from ~3s to minutes to flush a // brand-new session's first JSONL line (#8401) — resolveSessionFilePath -// genuinely has nothing to find yet. Poll for it instead of going deaf; each -// attempt is a cheap glob, so the cost of polling is negligible next to the -// gap it covers. +// genuinely has nothing to find yet. Poll for it instead of going deaf. Exact +// hook paths are probed on every retry; the recursive +// session-id fallback runs less often because a large Claude tree is expensive. const INITIAL_RESOLVE_POLL_MS = 500 const MAX_RESOLVE_POLL_MS = 5_000 +const FALLBACK_RESOLVE_POLL_MS = 5_000 + +function exactTranscriptPath(args: SubscribeNativeChatTranscriptArgs): string | null { + const path = args.transcriptPath?.trim() + return path && extname(path) === '.jsonl' ? path : null +} /** * Background retry loop for a transcript that hasn't been resolvable yet. * Returns a subscription immediately (per subscribeNativeChatTranscript's * contract); the loop keeps retrying resolve+install until it succeeds or - * unsubscribe() cancels it. + * unsubscribe() cancels it. Reports watching:true — the engine's first drain + * delivers the initial snapshot once the file appears, so subscribers must not + * settle a merely not-yet-flushed transcript into a permanent error (#8401). */ function subscribeViaResolvePoll( args: SubscribeNativeChatTranscriptArgs, @@ -257,15 +59,23 @@ function subscribeViaResolvePoll( let installed: NativeChatTranscriptSubscription | null = null let pollTimer: ReturnType | null = null let delay = args.resolvePollIntervalMs ?? INITIAL_RESOLVE_POLL_MS + let lastFallbackResolveAt = Date.now() + const exactPath = exactTranscriptPath(args) function scheduleAttempt(): void { if (closed) { return } - pollTimer = setTimeout(() => { - pollTimer = null - void runAttempt() - }, delay) + const untilFallbackResolve = exactPath + ? Math.max(0, FALLBACK_RESOLVE_POLL_MS - (Date.now() - lastFallbackResolveAt)) + : delay + pollTimer = setTimeout( + () => { + pollTimer = null + void runAttempt() + }, + Math.min(delay, untilFallbackResolve) + ) // Why: never hold the event loop open (headless `orca serve` shutdown) for // a session that may genuinely never resolve. pollTimer.unref?.() @@ -282,7 +92,14 @@ function subscribeViaResolvePoll( } let result: NativeChatTranscriptSubscription | null try { - result = await attemptInstall(args, decode) + result = exactPath ? await attemptInstall({ ...args, filePath: exactPath }, decode) : null + if ( + !result && + (!exactPath || Date.now() - lastFallbackResolveAt >= FALLBACK_RESOLVE_POLL_MS) + ) { + lastFallbackResolveAt = Date.now() + result = await attemptInstall(args, decode) + } } catch { // Why: a transient resolve failure (EACCES/EIO during the glob) must not // kill the poll loop with an unhandled rejection — retry like a miss. @@ -303,6 +120,7 @@ function subscribeViaResolvePoll( scheduleAttempt() return { + watching: true, unsubscribe: () => { if (closed) { return @@ -334,16 +152,16 @@ function subscribeViaResolvePoll( export async function subscribeNativeChatTranscript( args: SubscribeNativeChatTranscriptArgs ): Promise { - const decode = lineDecoderForAgent(args.agent) + const decode = nativeChatLineDecoderForAgent(args.agent) if (!decode) { // Nothing watchable — return a no-op teardown so callers can unconditionally // unsubscribe without null-checks. - return { unsubscribe: () => {} } + return { unsubscribe: () => {}, watching: false } } // Why: a blank session id (and no explicit file) can never resolve — bail out // instead of resolve-polling an unresolvable target forever. if (!args.filePath && !args.sessionId.trim()) { - return { unsubscribe: () => {} } + return { unsubscribe: () => {}, watching: false } } const installed = await attemptInstall(args, decode) diff --git a/src/main/providers/ssh-filesystem-provider.test.ts b/src/main/providers/ssh-filesystem-provider.test.ts index 13ab2846baa0..55659b18c20a 100644 --- a/src/main/providers/ssh-filesystem-provider.test.ts +++ b/src/main/providers/ssh-filesystem-provider.test.ts @@ -497,16 +497,18 @@ describe('SshFilesystemProvider', () => { expect(result).toEqual(['src/index.ts', 'package.json']) }) - it('listFiles forwards excludePaths when provided', async () => { + it('listFiles forwards supported list options', async () => { mux.request.mockResolvedValue([]) await provider.listFiles('/home/user/project', { - excludePaths: ['/home/user/project/worktrees/b'] + excludePaths: ['/home/user/project/worktrees/b'], + maxResults: 20_000 }) expect(mux.request).toHaveBeenCalledWith( 'fs.listFiles', { rootPath: '/home/user/project', - excludePaths: ['/home/user/project/worktrees/b'] + excludePaths: ['/home/user/project/worktrees/b'], + maxResults: 20_000 }, { signal: undefined } ) diff --git a/src/main/providers/ssh-filesystem-provider.ts b/src/main/providers/ssh-filesystem-provider.ts index 507bfd98fdb5..b17b3c4f783f 100644 --- a/src/main/providers/ssh-filesystem-provider.ts +++ b/src/main/providers/ssh-filesystem-provider.ts @@ -293,12 +293,15 @@ export class SshFilesystemProvider implements IFilesystemProvider { async listFiles( rootPath: string, - options?: { excludePaths?: string[]; signal?: AbortSignal } + options?: { excludePaths?: string[]; signal?: AbortSignal; maxResults?: number } ): Promise { const params: Record = { rootPath } if (options?.excludePaths && options.excludePaths.length > 0) { params.excludePaths = options.excludePaths } + if (options?.maxResults !== undefined) { + params.maxResults = options.maxResults + } // Why #7721: the signal lets a workspace switch send rpc.cancel so the // relay aborts the full-tree scan instead of stacking abandoned scans // that starve interactive fs.readDir/fs.stat on the shared SSH channel. diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index fa694433616c..6dc7b6ec1a34 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -286,7 +286,7 @@ export type IFilesystemProvider = { search(opts: SearchOptions): Promise listFiles( rootPath: string, - options?: { excludePaths?: string[]; signal?: AbortSignal } + options?: { excludePaths?: string[]; signal?: AbortSignal; maxResults?: number } ): Promise scanWorkspaceSpace?( rootPath: string, diff --git a/src/main/runtime/mobile-presence-lock.test.ts b/src/main/runtime/mobile-presence-lock.test.ts index 6213765bb20d..b10a268ac799 100644 --- a/src/main/runtime/mobile-presence-lock.test.ts +++ b/src/main/runtime/mobile-presence-lock.test.ts @@ -193,6 +193,87 @@ describe('mobile presence lock — driver state machine', () => { expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) }) + it('restores the pre-write driver after overlapping claims from one phone both fail', async () => { + const { runtime } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + await runtime.reclaimTerminalForDesktop('pty-1') + + const first = runtime.beginMobileInputFloor('pty-1', 'phone-A')! + const second = runtime.beginMobileInputFloor('pty-1', 'phone-A')! + first.rollback() + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' }) + + second.rollback() + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'desktop' }) + }) + + it('keeps a successful overlapping claim as the rollback baseline', async () => { + const { runtime, ptySizes } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + await runtime.reclaimTerminalForDesktop('pty-1') + + const successful = runtime.beginMobileInputFloor('pty-1', 'phone-A')! + const rejected = runtime.beginMobileInputFloor('pty-1', 'phone-A')! + await successful.commit() + rejected.rollback() + + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' }) + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + }) + + it('does not let an older phone-fit completion retake a newer writer floor', async () => { + const { runtime } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + await runtime.handleMobileSubscribe('pty-1', 'phone-B', { cols: 38, rows: 18 }) + await runtime.reclaimTerminalForDesktop('pty-1') + + let releaseFirstLayout!: () => void + vi.spyOn(runtime, 'applyMobileDisplayMode').mockImplementationOnce( + () => + new Promise((resolve) => { + releaseFirstLayout = () => resolve(true) + }) + ) + const first = runtime.beginMobileInputFloor('pty-1', 'phone-A')! + const firstCommit = first.commit() + await vi.waitFor(() => expect(releaseFirstLayout).toBeTypeOf('function')) + + const second = runtime.beginMobileInputFloor('pty-1', 'phone-B')! + await second.commit() + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-B' }) + + releaseFirstLayout() + await firstCommit + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-B' }) + }) + + it('mobile input without an active subscriber cannot create an orphaned floor lock', async () => { + const { runtime } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + runtime.handleMobileUnsubscribe('pty-1', 'phone-A') + await vi.advanceTimersByTimeAsync(250) + + await runtime.mobileTookFloor('pty-1', 'phone-A') + + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'idle' }) + }) + + it('admits a soft-leaving client to reserve the input floor within the grace window', async () => { + const { runtime } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + runtime.handleMobileUnsubscribe('pty-1', 'phone-A') + + // Inside the soft-leave grace a late write still reserves and commits the floor. + const claim = runtime.beginMobileInputFloor('pty-1', 'phone-A') + expect(claim).not.toBeNull() + await claim!.commit() + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' }) + + // Past the grace the client is fully gone and is rejected. + await vi.advanceTimersByTimeAsync(250) + expect(runtime.beginMobileInputFloor('pty-1', 'phone-A')).toBeNull() + }) + it('handleMobileUnsubscribe last leaver flips driver to idle after soft-leave grace', async () => { const { runtime, driverEvents } = createRuntime() await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) diff --git a/src/main/runtime/orca-runtime-files.ts b/src/main/runtime/orca-runtime-files.ts index f261e98dbe3f..5af3d0d22a3f 100644 --- a/src/main/runtime/orca-runtime-files.ts +++ b/src/main/runtime/orca-runtime-files.ts @@ -81,9 +81,16 @@ import { } from '../ipc/parcel-watcher-process-failure' import { assertNoClobberRenameDestinationAvailable } from '../../shared/filesystem-rename-collision' import { joinWorktreeRelativePath, normalizeRuntimeRelativePath } from './runtime-relative-paths' +import { + rankRuntimeMobileFilePaths, + RuntimeMobileFilePathSearchCache +} from './runtime-mobile-file-path-search' import { beginWatcherInstall } from '../ipc/watcher-removal-gate' const MOBILE_FILE_LIST_LIMIT = 5000 +const MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT = 20_000 +const MOBILE_FILE_PATH_SEARCH_CACHE_ENTRIES = 8 +const MOBILE_FILE_PATH_SEARCH_CACHE_TTL_MS = 30_000 const MOBILE_FILE_READ_MAX_BYTES = 512 * 1024 const RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES = 10 * 1024 * 1024 const WINDOWS_RUNTIME_FILE_WATCH_DEBOUNCE_MS = 150 @@ -408,6 +415,10 @@ export type RuntimeFileCommandHost = { export class RuntimeFileCommands { private activeRuntimeTextSearches = new Map() private terminalFileGrants = new Map() + private mobileFilePathSearchCache = new RuntimeMobileFilePathSearchCache( + MOBILE_FILE_PATH_SEARCH_CACHE_ENTRIES, + MOBILE_FILE_PATH_SEARCH_CACHE_TTL_MS + ) constructor(private readonly host: RuntimeFileCommandHost) {} @@ -437,6 +448,52 @@ export class RuntimeFileCommands { } } + async searchMobileFilePaths( + worktreeSelector: string, + query: string, + limit: number + ): Promise { + const store = this.host.requireStore() + const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) + const { worktree, connectionId } = target + const cacheKey = `${connectionId ?? 'local'}:${worktree.id}:${worktree.path}` + const inventory = await this.mobileFilePathSearchCache.get(cacheKey, async () => { + const listed = connectionId + ? await this.listRemoteMobileFiles( + worktree.path, + connectionId, + MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT + 1 + ) + : await listQuickOpenFiles( + worktree.path, + store, + undefined, + undefined, + MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT + 1 + ) + const safePaths = listed + .filter((relativePath) => isSafeMobileRelativePath(relativePath)) + .sort((a, b) => a.localeCompare(b)) + return { + paths: safePaths.slice(0, MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT), + totalCount: safePaths.length, + truncated: safePaths.length > MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT + } + }) + const matches = rankRuntimeMobileFilePaths(inventory.paths, query, limit) + return { + worktree: worktree.id, + rootPath: worktree.path, + files: matches.paths.map((relativePath) => ({ + relativePath, + basename: basenameFromRelativePath(relativePath), + kind: isMobileBinaryPath(relativePath) ? ('binary' as const) : ('text' as const) + })), + totalCount: matches.totalCount, + truncated: inventory.truncated || matches.totalCount > limit + } + } + async openMobileFile( worktreeSelector: string, relativePath: string @@ -1730,12 +1787,16 @@ export class RuntimeFileCommands { } } - private async listRemoteMobileFiles(rootPath: string, connectionId: string): Promise { + private async listRemoteMobileFiles( + rootPath: string, + connectionId: string, + maxResults?: number + ): Promise { const provider = getSshFilesystemProvider(connectionId) if (!provider) { return [] } - return provider.listFiles(rootPath) + return provider.listFiles(rootPath, { maxResults }) } private async readRemoteMobileFile(filePath: string, connectionId: string): Promise { diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 50f196f26693..15a593214098 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -9652,6 +9652,8 @@ describe('OrcaRuntimeService', () => { it('keeps ordinary terminal send suffix failures on the existing not-writable contract', async () => { const writes: string[] = [] + const beforeWrite = vi.fn() + const afterWrite = vi.fn() const runtime = new OrcaRuntimeService(store) runtime.setPtyController({ spawn: vi.fn().mockResolvedValue({ id: 'pty-1' }), @@ -9687,9 +9689,15 @@ describe('OrcaRuntimeService', () => { const [terminal] = (await runtime.listTerminals()).terminals await expect( - runtime.sendTerminal(terminal.handle, { text: 'notes', enter: true }) + runtime.sendTerminal( + terminal.handle, + { text: 'notes', enter: true }, + { beforeWrite, afterWrite } + ) ).rejects.toThrow('terminal_not_writable') expect(writes).toEqual(['notes', '\r']) + expect(beforeWrite).toHaveBeenCalledTimes(2) + expect(afterWrite).toHaveBeenCalledOnce() }) it('creates visible terminal sessions without asking the renderer to focus a tab', async () => { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index da1bbfbe603c..e64143647edb 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -2409,6 +2409,15 @@ export class OrcaRuntimeService { // `applyMobileDisplayMode` to pick the active phone-fit viewport. See // docs/mobile-presence-lock.md. private currentDriver = new Map() + private mobileInputFloorClaims = new Map< + string, + { + base: DriverState + generation: number + committedGeneration: number + pending: Map + } + >() private currentBrowserDriver = new Map() // Why: remote (relay/shared-control) desktop viewers of a PTY are keyed by @@ -5466,6 +5475,8 @@ export class OrcaRuntimeService { listMobileFiles: RuntimeFileCommands['listMobileFiles'] = this.fileCommands.listMobileFiles.bind( this.fileCommands ) + searchMobileFilePaths: RuntimeFileCommands['searchMobileFilePaths'] = + this.fileCommands.searchMobileFilePaths.bind(this.fileCommands) openMobileFile: RuntimeFileCommands['openMobileFile'] = this.fileCommands.openMobileFile.bind( this.fileCommands ) @@ -9145,18 +9156,108 @@ export class OrcaRuntimeService { this.setDriver(ptyId, { kind: 'mobile', clientId }) } + beginMobileInputFloor( + ptyId: string, + clientId: string + ): { commit: () => Promise; rollback: () => void } | null { + // Why: admit a client still inside its soft-leave grace (mirrors + // mobileTookFloor) so a write landing in that window reserves the floor + // instead of being dropped; post-grace/orphaned writers stay rejected. + const softLeaver = this.pendingSoftLeavers.get(ptyId) + if (!this.mobileSubscribers.get(ptyId)?.has(clientId) && softLeaver?.clientId !== clientId) { + return null + } + const state = this.mobileInputFloorClaims.get(ptyId) ?? { + base: this.getDriver(ptyId), + generation: 0, + committedGeneration: 0, + pending: new Map() + } + this.mobileInputFloorClaims.set(ptyId, state) + const token = Symbol('mobile-input-floor') + const generation = ++state.generation + state.pending.set(token, { clientId, generation }) + this.setDriver(ptyId, { kind: 'mobile', clientId }) + let settled = false + return { + commit: async () => { + if (settled) { + return + } + settled = true + state.pending.delete(token) + // Why: a newer accepted write owns the floor; an older claim that was + // delayed before commit must not replace its rollback baseline or driver. + if (generation < state.committedGeneration) { + if (state.pending.size === 0 && this.mobileInputFloorClaims.get(ptyId) === state) { + this.mobileInputFloorClaims.delete(ptyId) + } + return + } + const previousFloor = state.base + // Why: a successful write becomes the rollback baseline for any + // overlapping reservations that have not reached the PTY yet. + state.committedGeneration = generation + state.base = { kind: 'mobile', clientId } + await this.mobileTookFloor( + ptyId, + clientId, + previousFloor, + () => + this.mobileInputFloorClaims.get(ptyId) === state && + state.committedGeneration === generation + ) + if (state.pending.size === 0 && this.mobileInputFloorClaims.get(ptyId) === state) { + this.mobileInputFloorClaims.delete(ptyId) + } + }, + rollback: () => { + if (settled) { + return + } + settled = true + state.pending.delete(token) + if (this.mobileInputFloorClaims.get(ptyId) !== state) { + return + } + const current = this.getDriver(ptyId) + if (current.kind === 'mobile' && current.clientId === clientId) { + const pendingClientId = Array.from(state.pending.values()).at(-1)?.clientId + this.setDriver( + ptyId, + pendingClientId ? { kind: 'mobile', clientId: pendingClientId } : state.base + ) + } + if (state.pending.size === 0) { + this.mobileInputFloorClaims.delete(ptyId) + } + } + } + } + // Why: invoked from mobile RPC method handlers (terminal.send / setDisplayMode / // resizeForClient / fresh subscribe with auto). Records the actor as the // most recent mobile driver and re-applies phone-fit if we were previously // in `desktop` mode (mobile reclaims a take-back). Mobile-to-mobile hand-offs // are no-ops for resize. - async mobileTookFloor(ptyId: string, clientId: string): Promise { + async mobileTookFloor( + ptyId: string, + clientId: string, + previousFloor?: DriverState, + isCurrent: () => boolean = () => true + ): Promise { const inner = this.mobileSubscribers.get(ptyId) const sub = inner?.get(clientId) + const softLeaver = this.pendingSoftLeavers.get(ptyId) + // Why: native chat pauses terminal output, so its later sends have no + // subscriber lifecycle that could release a newly-created desktop lock. + if (!sub && softLeaver?.clientId !== clientId) { + return + } if (sub) { sub.lastActedAt = Date.now() } - const prev = this.getDriver(ptyId) + const prev = previousFloor ?? this.getDriver(ptyId) const currentMode = this.mobileDisplayModes.get(ptyId) // Why: a deliberate mobile action implies mobile is resuming control. // If the display mode is currently 'desktop' (set by an earlier @@ -9168,6 +9269,11 @@ export class OrcaRuntimeService { } await this.applyMobileDisplayMode(ptyId) } + // Why: display changes are async; a later PTY write must keep the floor + // when an older phone-fit operation eventually completes. + if (!isCurrent()) { + return + } this.setDriver(ptyId, { kind: 'mobile', clientId }) } @@ -10819,6 +10925,8 @@ export class OrcaRuntimeService { }, options: { beforeWrite?: (ptyId: string) => void | Promise + reserveWrite?: (ptyId: string) => void + afterWrite?: (ptyId: string) => void | Promise suffixFailureError?: string } = {} ): Promise { @@ -11231,6 +11339,8 @@ export class OrcaRuntimeService { payload: string, options: { beforeWrite?: (ptyId: string) => void | Promise + reserveWrite?: (ptyId: string) => void + afterWrite?: (ptyId: string) => void | Promise suffixFailureError?: string } = {} ): Promise { @@ -11248,6 +11358,7 @@ export class OrcaRuntimeService { } try { await options.beforeWrite?.(ptyId) + options.reserveWrite?.(ptyId) } catch (error) { if (options.suffixFailureError) { throw new Error(options.suffixFailureError) @@ -11258,6 +11369,7 @@ export class OrcaRuntimeService { if (!suffixWrote) { throw new Error(options.suffixFailureError ?? 'terminal_not_writable') } + await options.afterWrite?.(ptyId) return } if (hasText) { @@ -11265,10 +11377,12 @@ export class OrcaRuntimeService { } await options.beforeWrite?.(ptyId) + options.reserveWrite?.(ptyId) const wrote = this.ptyController?.write(ptyId, payload) ?? false if (!wrote) { throw new Error('terminal_not_writable') } + await options.afterWrite?.(ptyId) } private async writeTerminalInputChunks( @@ -11276,16 +11390,20 @@ export class OrcaRuntimeService { text: string, options: { beforeWrite?: (ptyId: string) => void | Promise + reserveWrite?: (ptyId: string) => void + afterWrite?: (ptyId: string) => void | Promise } = {} ): Promise { const chunks = iterateTerminalInputChunks(text) let chunk = chunks.next() while (!chunk.done) { await options.beforeWrite?.(ptyId) + options.reserveWrite?.(ptyId) const wrote = this.ptyController?.write(ptyId, chunk.value) ?? false if (!wrote) { throw new Error('terminal_not_writable') } + await options.afterWrite?.(ptyId) chunk = chunks.next() if (!chunk.done) { await new Promise((resolve) => setTimeout(resolve, 0)) diff --git a/src/main/runtime/rpc/methods/files-path-search.test.ts b/src/main/runtime/rpc/methods/files-path-search.test.ts new file mode 100644 index 000000000000..cd47aa8fd7cd --- /dev/null +++ b/src/main/runtime/rpc/methods/files-path-search.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, vi } from 'vitest' +import type { OrcaRuntimeService } from '../../orca-runtime' +import type { RpcRequest } from '../core' +import { RpcDispatcher } from '../dispatcher' +import { FILE_METHODS } from './files' + +describe('file path search RPC method', () => { + it('returns a bounded server-side result for mobile autocomplete', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + searchMobileFilePaths: vi.fn().mockResolvedValue({ + worktree: 'wt-1', + rootPath: '/repo', + files: [{ relativePath: 'src/app.ts', basename: 'app.ts', kind: 'text' }], + totalCount: 1, + truncated: false + }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + const request: RpcRequest = { + id: 'req-1', + authToken: 'tok', + method: 'files.searchPaths', + params: { worktree: 'id:wt-1', query: 'app', limit: 8 } + } + + const response = await dispatcher.dispatch(request) + + expect(runtime.searchMobileFilePaths).toHaveBeenCalledWith('id:wt-1', 'app', 8) + expect(response).toMatchObject({ + ok: true, + result: { files: [{ relativePath: 'src/app.ts' }] } + }) + }) +}) diff --git a/src/main/runtime/rpc/methods/files.ts b/src/main/runtime/rpc/methods/files.ts index a46d24eb1b12..b9af390152bc 100644 --- a/src/main/runtime/rpc/methods/files.ts +++ b/src/main/runtime/rpc/methods/files.ts @@ -19,6 +19,11 @@ const WorktreeSelector = z.object({ .pipe(z.string().min(1, 'Missing worktree selector')) }) +const FilePathSearch = WorktreeSelector.extend({ + query: z.string().max(256).default(''), + limit: z.number().int().positive().max(32).default(16) +}) + const FileOpen = WorktreeSelector.extend({ relativePath: z .unknown() @@ -176,6 +181,12 @@ export const FILE_METHODS: RpcAnyMethod[] = [ params: WorktreeSelector, handler: async (params, { runtime }) => runtime.listMobileFiles(params.worktree) }), + defineMethod({ + name: 'files.searchPaths', + params: FilePathSearch, + handler: async (params, { runtime }) => + runtime.searchMobileFilePaths(params.worktree, params.query, params.limit) + }), defineMethod({ name: 'files.open', params: FileOpen, diff --git a/src/main/runtime/rpc/methods/native-chat.test.ts b/src/main/runtime/rpc/methods/native-chat.test.ts index 720ce127d2c3..15bb6b3a24a4 100644 --- a/src/main/runtime/rpc/methods/native-chat.test.ts +++ b/src/main/runtime/rpc/methods/native-chat.test.ts @@ -2,12 +2,35 @@ import { describe, expect, it, vi } from 'vitest' import type { NativeChatMessage } from '../../../../shared/native-chat-types' import type { RpcContext } from '../core' -// Stub the shared cache so the handler returns a deterministic transcript with +// Stub the bounded tail reader so the handler returns a deterministic transcript with // one oversized tool-result block; the test then asserts clip behavior per client. const OVERSIZED = 'x'.repeat(5000) const cachedResult = vi.hoisted(() => ({ value: { messages: [] as NativeChatMessage[] } })) -vi.mock('../../../native-chat/transcript-read-cache', () => ({ - readNativeChatTranscriptCached: () => Promise.resolve(cachedResult.value) +const watcher = vi.hoisted(() => ({ + args: null as null | { + onInitialSnapshot?: ( + messages: NativeChatMessage[], + hasMore: boolean, + beforeOffset: number, + error?: string + ) => void + onAppend: (messages: NativeChatMessage[]) => void + }, + watching: true +})) +vi.mock('../../../native-chat/transcript-watch', () => ({ + readNativeChatTranscriptTail: ({ limit }: { limit: number }) => { + const messages = cachedResult.value.messages + return Promise.resolve({ + messages: messages.slice(-limit), + hasMore: messages.length > limit, + beforeOffset: 123 + }) + }, + subscribeNativeChatTranscript: (args: NonNullable) => { + watcher.args = args + return Promise.resolve({ unsubscribe: vi.fn(), watching: watcher.watching }) + } })) import { NATIVE_CHAT_METHODS } from './native-chat' @@ -30,6 +53,34 @@ function readSessionHandler(): (params: unknown, ctx: RpcContext) => Promise Promise } +function subscribeHandler(): ( + params: unknown, + ctx: RpcContext, + emit: (value: unknown) => void +) => Promise { + const method = NATIVE_CHAT_METHODS.find((candidate) => candidate.name === 'nativeChat.subscribe') + if (!method) { + throw new Error('subscribe method not registered') + } + return method.handler as ( + params: unknown, + ctx: RpcContext, + emit: (value: unknown) => void + ) => Promise +} + +function streamingContext(clientKind: RpcContext['clientKind']): RpcContext { + return { + runtime: { + registerSubscriptionCleanup: vi.fn(), + cleanupSubscription: vi.fn(), + cleanupSubscriptionsByPrefix: vi.fn() + } as unknown as RpcContext['runtime'], + connectionId: 'connection-1', + clientKind + } +} + function ctxWith(clientKind: RpcContext['clientKind']): RpcContext { return { runtime: {} as RpcContext['runtime'], clientKind } } @@ -40,6 +91,13 @@ function firstOutput(result: unknown): string { return block.output } +function activeWatcherArgs(): NonNullable { + if (!watcher.args) { + throw new Error('native-chat transcript watcher was not subscribed') + } + return watcher.args +} + describe('nativeChat.readSession clientKind truncation gating', () => { it('clips oversized tool output for mobile clients', async () => { cachedResult.value = { messages: [makeMessage(OVERSIZED)] } @@ -52,6 +110,131 @@ describe('nativeChat.readSession clientKind truncation gating', () => { expect(output).toContain('truncated') }) + it('bounds raw tool-call inputs before sending them to mobile', async () => { + cachedResult.value = { + messages: [ + { + ...makeMessage('ignored'), + blocks: [ + { + type: 'tool-call', + name: 'Write', + input: { file_path: 'src/a.ts', content: OVERSIZED } + } + ] + } + ] + } + const result = await readSessionHandler()( + { agent: 'claude', sessionId: 's' }, + ctxWith('mobile') + ) + const input = (result as { messages: NativeChatMessage[] }).messages[0].blocks[0] + + expect(JSON.stringify(input).length).toBeLessThan(OVERSIZED.length) + expect(JSON.stringify(input)).toContain('truncated') + }) + + it('preserves AskUserQuestion option objects at the supported nesting depth', async () => { + cachedResult.value = { + messages: [ + { + ...makeMessage('ignored'), + blocks: [ + { + type: 'tool-call', + name: 'AskUserQuestion', + input: { + questions: [ + { + question: 'Pick one', + header: 'Choice', + options: [ + { label: 'Alpha', description: 'First option' }, + { label: 'Beta', description: 'Second option' } + ] + } + ] + } + } + ] + } + ] + } + + const result = await readSessionHandler()( + { agent: 'claude', sessionId: 's' }, + ctxWith('mobile') + ) + const block = (result as { messages: NativeChatMessage[] }).messages[0].blocks[0] + + expect(block).toMatchObject({ + type: 'tool-call', + input: { + questions: [ + { + options: [ + { label: 'Alpha', description: 'First option' }, + { label: 'Beta', description: 'Second option' } + ] + } + ] + } + }) + }) + + it('bounds tool-call keys and primitive node fanout', async () => { + const wide = Object.fromEntries( + Array.from({ length: 200 }, (_unused, index) => [`${'k'.repeat(200)}-${index}`, index]) + ) + cachedResult.value = { + messages: [ + { + ...makeMessage('ignored'), + blocks: [{ type: 'tool-call', name: 'Write', input: wide }] + } + ] + } + const result = await readSessionHandler()( + { agent: 'claude', sessionId: 's' }, + ctxWith('mobile') + ) + const encoded = JSON.stringify((result as { messages: NativeChatMessage[] }).messages[0]) + + expect(encoded.length).toBeLessThan(10_000) + expect(encoded).toContain('truncated') + }) + + it('keeps sibling tool-call keys that share a 128-char prefix distinct', async () => { + const prefix = 'p'.repeat(128) + cachedResult.value = { + messages: [ + { + ...makeMessage('ignored'), + blocks: [ + { + type: 'tool-call', + name: 'Write', + input: { [`${prefix}A`]: 'first', [`${prefix}B`]: 'second' } + } + ] + } + ] + } + const result = await readSessionHandler()( + { agent: 'claude', sessionId: 's' }, + ctxWith('mobile') + ) + const block = (result as { messages: NativeChatMessage[] }).messages[0].blocks[0] as { + input: Record + } + const keys = Object.keys(block.input) + + expect(keys).toHaveLength(2) + expect(new Set(keys).size).toBe(2) + expect(Object.values(block.input)).toEqual(expect.arrayContaining(['first', 'second'])) + }) + it('passes oversized tool output through intact for runtime (web/desktop) clients', async () => { cachedResult.value = { messages: [makeMessage(OVERSIZED)] } const result = await readSessionHandler()( @@ -98,5 +281,102 @@ describe('nativeChat.readSession clientKind truncation gating', () => { // Tail-only: the last id survives, the first is dropped. expect(messages.at(-1)?.id).toBe('m-59') expect(messages[0].id).toBe('m-20') + expect(result).toMatchObject({ hasMore: true, beforeOffset: 123 }) + }) +}) + +describe('nativeChat.subscribe initial snapshot', () => { + it('emits one windowed snapshot with pagination state before live appends', async () => { + watcher.watching = true + watcher.args = null + const emitted: unknown[] = [] + await subscribeHandler()( + { agent: 'claude', sessionId: 's', limit: 40 }, + streamingContext('mobile'), + (value) => emitted.push(value) + ) + const messages = Array.from({ length: 60 }, (_unused, index) => ({ + ...makeMessage('small'), + id: `m-${index}` + })) + + const callbacks = activeWatcherArgs() + callbacks.onInitialSnapshot?.(messages, true, 123) + const live = Array.from({ length: 60 }, (_unused, index) => ({ + ...makeMessage('live'), + id: `m-live-${index}` + })) + callbacks.onAppend(live) + + expect(emitted[0]).toMatchObject({ + type: 'snapshot', + hasMore: true, + beforeOffset: 123 + }) + const snapshotMessages = (emitted[0] as { messages: NativeChatMessage[] }).messages + expect(snapshotMessages).toHaveLength(40) + expect(snapshotMessages[0].id).toBe('m-20') + expect(emitted[1]).toMatchObject({ type: 'appended' }) + expect((emitted[1] as { messages: NativeChatMessage[] }).messages).toHaveLength(60) + }) + + it('emits an explicit error snapshot when no transcript can be watched', async () => { + watcher.watching = false + watcher.args = null + const emitted: unknown[] = [] + await subscribeHandler()( + { agent: 'claude', sessionId: 'missing' }, + streamingContext('mobile'), + (value) => emitted.push(value) + ) + + expect(emitted).toEqual([ + { type: 'snapshot', messages: [], hasMore: false, error: 'Transcript unavailable' } + ]) + }) + + it('forwards an initial-drain error onto the snapshot frame', async () => { + watcher.watching = true + watcher.args = null + const emitted: unknown[] = [] + await subscribeHandler()( + { agent: 'claude', sessionId: 's' }, + streamingContext('mobile'), + (value) => emitted.push(value) + ) + + activeWatcherArgs().onInitialSnapshot?.([], false, 0, 'Transcript unavailable') + + expect(emitted).toEqual([ + { + type: 'snapshot', + messages: [], + hasMore: false, + beforeOffset: 0, + error: 'Transcript unavailable' + } + ]) + }) + + it('omits error from the snapshot frame on a clean initial drain', async () => { + watcher.watching = true + watcher.args = null + const emitted: unknown[] = [] + await subscribeHandler()( + { agent: 'claude', sessionId: 's' }, + streamingContext('mobile'), + (value) => emitted.push(value) + ) + + activeWatcherArgs().onInitialSnapshot?.([makeMessage('hi')], false, 7) + + expect(emitted).toEqual([ + { + type: 'snapshot', + messages: [expect.objectContaining({ id: 'a-1' })], + hasMore: false, + beforeOffset: 7 + } + ]) }) }) diff --git a/src/main/runtime/rpc/methods/native-chat.ts b/src/main/runtime/rpc/methods/native-chat.ts index 05558c8b76c0..513a21340113 100644 --- a/src/main/runtime/rpc/methods/native-chat.ts +++ b/src/main/runtime/rpc/methods/native-chat.ts @@ -1,8 +1,10 @@ import { z } from 'zod' import type { NativeChatBlock, NativeChatMessage } from '../../../../shared/native-chat-types' import type { AgentType } from '../../../../shared/native-chat-types' -import { readNativeChatTranscriptCached } from '../../../native-chat/transcript-read-cache' -import { subscribeNativeChatTranscript } from '../../../native-chat/transcript-watch' +import { + readNativeChatTranscriptTail, + subscribeNativeChatTranscript +} from '../../../native-chat/transcript-watch' import { defineMethod, defineStreamingMethod, type RpcAnyMethod, type RpcContext } from '../core' // Why: native chat renders an agent's own transcript (Claude/Codex JSONL). The @@ -39,7 +41,8 @@ const NativeChatSession = z.object({ // Authoritative transcript path from the agent hook (providerSession), used to // locate the file directly when the session id no longer names it (recent // Claude Code). Optional for back-compat with older clients. - transcriptPath: z.string().min(1).optional() + transcriptPath: z.string().min(1).optional(), + beforeOffset: z.number().int().nonnegative().optional() }) const NativeChatUnsubscribe = z.object({ @@ -59,6 +62,8 @@ const MOBILE_NATIVE_CHAT_MAX_WINDOW = 2000 // The mobile view only previews block bodies, so truncate them on the wire to // keep the payload small; the marker tells the user content was clipped. const MOBILE_BLOCK_CHAR_CAP = 4000 +const MOBILE_TOOL_INPUT_ITEMS_CAP = 20 +const MOBILE_TOOL_INPUT_NODE_CAP = 100 const TRUNCATION_MARKER = '\n… (truncated)' function clip(text: string): string { @@ -76,13 +81,77 @@ function clipBlock(block: NativeChatBlock): NativeChatBlock { ? { ...block, output: clip(block.output) } : block } + if (block.type === 'tool-call') { + const budget = { remaining: MOBILE_BLOCK_CHAR_CAP, nodes: MOBILE_TOOL_INPUT_NODE_CAP } + return { ...block, input: sanitizeToolInput(block.input, budget, 0) } + } return block } +function sanitizeToolInput( + value: unknown, + budget: { remaining: number; nodes: number }, + depth: number +): unknown { + budget.nodes-- + if (budget.nodes < 0 || budget.remaining <= 0) { + return '… (truncated)' + } + if (typeof value === 'string') { + const length = Math.min(value.length, budget.remaining) + budget.remaining -= length + return length < value.length ? `${value.slice(0, length)}… (truncated)` : value + } + if (!value || typeof value !== 'object' || depth >= 5) { + return value && typeof value === 'object' ? '… (truncated)' : value + } + if (Array.isArray(value)) { + const result = value + .slice(0, MOBILE_TOOL_INPUT_ITEMS_CAP) + .map((item) => sanitizeToolInput(item, budget, depth + 1)) + if (value.length > MOBILE_TOOL_INPUT_ITEMS_CAP) { + result.push('… (truncated)') + } + return result + } + const result: Record = {} + let count = 0 + for (const key in value) { + if (!Object.prototype.hasOwnProperty.call(value, key)) { + continue + } + if (count >= MOBILE_TOOL_INPUT_ITEMS_CAP || budget.remaining <= 0) { + result['…'] = 'truncated' + break + } + let boundedKey = key.slice(0, Math.min(key.length, budget.remaining, 128)) + // Why: sibling keys sharing a >=128-char (or budget-truncated) prefix collapse + // to the same bounded key; suffix collisions so neither field is silently lost. + if (Object.prototype.hasOwnProperty.call(result, boundedKey)) { + boundedKey = `${boundedKey}~${count}` + } + budget.remaining -= boundedKey.length + result[boundedKey] = sanitizeToolInput( + (value as Record)[key], + budget, + depth + 1 + ) + count++ + } + return result +} + function sanitizeMessage(message: NativeChatMessage): NativeChatMessage { return { ...message, blocks: message.blocks.map(clipBlock) } } +function sanitizeAppendForClient( + messages: readonly NativeChatMessage[], + clientKind: RpcContext['clientKind'] +): NativeChatMessage[] { + return clientKind === 'mobile' ? messages.map(sanitizeMessage) : messages.slice() +} + /** Window a transcript to its most recent `limit` messages so a long session * can't freeze the client. Windowing by count applies to ALL RPC clients — * shipping thousands of turns over the paired link is bad for web and mobile @@ -113,14 +182,20 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [ name: 'nativeChat.readSession', params: NativeChatSession, handler: async (params, { clientKind }) => { - const result = await readNativeChatTranscriptCached( - params.agent, - params.sessionId, - params.transcriptPath - ) - // Window to the conversation tail (all clients); clip blocks for mobile only. + const limit = params.limit ?? MOBILE_NATIVE_CHAT_DEFAULT_WINDOW + const result = await readNativeChatTranscriptTail({ + agent: params.agent, + sessionId: params.sessionId, + transcriptPath: params.transcriptPath, + limit, + beforeOffset: params.beforeOffset + }) return 'messages' in result - ? { messages: windowForClient(result.messages, clientKind, params.limit) } + ? { + messages: windowForClient(result.messages, clientKind, limit), + hasMore: result.hasMore, + beforeOffset: result.beforeOffset + } : result } }), @@ -130,10 +205,8 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [ handler: async (params, { runtime, connectionId, clientKind }, emit) => { let closed = false let unsubscribe = (): void => {} - // Why: the subscriber seeds its read offset at 0, so the first drain emits - // the whole transcript and later drains emit only appended turns. The first - // batch is windowed to the tail (a full transcript would freeze mobile); - // later incremental batches are smaller than the window so they pass through. + // Why: the first drain is a bounded tail snapshot; later drains emit only + // appended turns. This avoids parsing or shipping full long transcripts. // Clients merge by message id, so the initial windowed batch doubles as the // snapshot. Keyed by the client-supplied subscriptionId when present so // registration and unsubscribe derive from the same token; otherwise by @@ -141,6 +214,7 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [ // unsubscribe (no wire break). const cleanupToken = params.subscriptionId ?? `${params.agent}:${params.sessionId}` const subscriptionId = `nativeChat:${connectionId ?? 'local'}:${cleanupToken}` + const limit = params.limit ?? MOBILE_NATIVE_CHAT_DEFAULT_WINDOW runtime.registerSubscriptionCleanup( subscriptionId, () => { @@ -157,11 +231,37 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [ agent: params.agent, sessionId: params.sessionId, transcriptPath: params.transcriptPath, + initialLimit: limit, + onInitialSnapshot: (messages, hasMore, beforeOffset, error) => { + if (closed) { + return + } + // Forward an initial-drain error so a watching client's first frame carries it + // instead of stranding the view at 'loading' when the read keeps throwing. + emit({ + type: 'snapshot', + messages: windowForClient(messages, clientKind, limit), + hasMore, + beforeOffset, + ...(error ? { error } : {}) + }) + }, + onReplace: (messages, hasMore, beforeOffset) => { + if (closed) { + return + } + emit({ + type: 'replacement', + messages: windowForClient(messages, clientKind, limit), + hasMore, + beforeOffset + }) + }, onAppend: (messages) => { if (closed) { return } - emit({ type: 'appended', messages: windowForClient(messages, clientKind) }) + emit({ type: 'appended', messages: sanitizeAppendForClient(messages, clientKind) }) } }) // The connection may have closed while the file was being resolved. @@ -169,6 +269,9 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [ subscription.unsubscribe() return } + if (!subscription.watching) { + emit({ type: 'snapshot', messages: [], hasMore: false, error: 'Transcript unavailable' }) + } unsubscribe = subscription.unsubscribe } }), diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index ec85e873b9f7..6d5879d9cf89 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -335,6 +335,61 @@ function resolveMobileFloorClientId( return null } +async function sendTerminalStreamInput( + runtime: OrcaRuntimeService, + args: { + terminal: string + text: string + client: TerminalViewportClient | undefined + isMobile: boolean + } +): Promise { + const action = { text: args.text, enter: false, interrupt: false } + const clientId = args.isMobile ? args.client?.id : undefined + const floorClaim: MobileInputFloorClaimHolder = { current: null } + try { + if (!clientId) { + await runtime.sendTerminal(args.terminal, action) + return + } + const result = await runtime.sendTerminal(args.terminal, action, { + reserveWrite: (writePtyId) => { + const claim = runtime.beginMobileInputFloor(writePtyId, clientId) + if (!claim) { + throw new Error('mobile_input_floor_unavailable') + } + floorClaim.current = claim + }, + afterWrite: () => commitMobileInputFloorClaim(floorClaim) + }) + if (!result.accepted) { + floorClaim.current?.rollback() + } + } catch { + floorClaim.current?.rollback() + } +} + +type MobileInputFloorClaimHolder = { + current: ReturnType +} + +async function commitMobileInputFloorClaim(claim: MobileInputFloorClaimHolder): Promise { + const current = claim.current + if (!current) { + return + } + try { + await current.commit() + } finally { + // Why: the runtime may yield before the next chunk/suffix, so that write + // needs a fresh reservation if desktop reclaimed the floor meanwhile. + if (claim.current === current) { + claim.current = null + } + } +} + function getTerminalSendGuardRefusedReason(error: unknown): 'no-agent' | 'permission' | undefined { const message = error instanceof Error ? error.message : String(error) if (message.includes('terminal_guard_permission')) { @@ -946,7 +1001,8 @@ const TerminalSubscribe = TerminalHandle.extend({ capabilities: z .object({ terminalBinaryStream: z.literal(1).optional(), - desktopViewportClaims: z.literal(1).optional() + desktopViewportClaims: z.literal(1).optional(), + mobileInputLeaseOnly: z.literal(1).optional() }) .optional() }) @@ -1265,6 +1321,19 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ } } } + const mobileFloorClientId = resolveMobileFloorClientId(driver, params.client) + const mobileFloorClaim: MobileInputFloorClaimHolder = { current: null } + const beforeWrite = assertSendPreconditions + const reserveWrite = + params.inputKind !== 'query-reply' && leaf?.ptyId && mobileFloorClientId + ? (ptyId: string): void => { + const claim = runtime.beginMobileInputFloor(ptyId, mobileFloorClientId) + if (!claim) { + throw new Error('mobile_input_floor_unavailable') + } + mobileFloorClaim.current = claim + } + : undefined let result try { result = await runtime.sendTerminal( @@ -1274,9 +1343,16 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ enter: params.enter === true, interrupt: params.interrupt === true }, - { beforeWrite: assertSendPreconditions } + { + beforeWrite, + ...(reserveWrite ? { reserveWrite } : {}), + ...(params.inputKind !== 'query-reply' && mobileFloorClientId + ? { afterWrite: () => commitMobileInputFloorClaim(mobileFloorClaim) } + : {}) + } ) } catch (error) { + mobileFloorClaim.current?.rollback() const refusedReason = getTerminalSendGuardRefusedReason(error) if (refusedReason) { return { @@ -1299,15 +1375,14 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ } throw error } + if (result.accepted !== true) { + mobileFloorClaim.current?.rollback() + } // Why: deliberate mobile input is a take-floor action. Drives the // `* → mobile{clientId}` driver transition so the desktop banner // remounts (if previously reclaimed) and active phone-fit dims follow // the most recent actor. Clientless sends are old mobile builds, so use // the current mobile driver as their compatibility identity. - const mobileFloorClientId = resolveMobileFloorClientId(driver, params.client) - if (params.inputKind !== 'query-reply' && leaf?.ptyId && mobileFloorClientId) { - await runtime.mobileTookFloor(leaf.ptyId, mobileFloorClientId) - } return { send: result } } }), @@ -1799,22 +1874,17 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ // Mobile already has the higher-priority floor; a rejected desktop // viewport claim must never suppress later phone input. const inputClaimTail = stream.isMobile ? Promise.resolve(true) : stream.desktopClaimTail - void inputClaimTail - .then((claimed) => - !claimed || isTerminalInputLockedForClient(runtime, stream.ptyId, stream.client) - ? null - : runtime.sendTerminal(stream.terminal, { - text, - enter: false, - interrupt: false - }) - ) - .then(async () => { - if (stream.isMobile && stream.client?.id) { - await runtime.mobileTookFloor(stream.ptyId, stream.client.id) - } + void inputClaimTail.then((claimed) => { + if (!claimed || isTerminalInputLockedForClient(runtime, stream.ptyId, stream.client)) { + return + } + return sendTerminalStreamInput(runtime, { + terminal: stream.terminal, + text, + client: stream.client, + isMobile: stream.isMobile }) - .catch(() => {}) + }) return } if (frame.opcode === TerminalStreamOpcode.Resize && stream.client) { @@ -2451,6 +2521,8 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ const ptyId = leaf.ptyId const clientId = params.client?.id + const mobileInputLeaseOnly = + isMobile && params.capabilities?.mobileInputLeaseOnly === 1 && Boolean(clientId) // Why: the initial mount/PTY wait and phone-fit can both emit a redraw // that creates suffix-only state, so preserve the pre-mount absence signal. const missingHeadlessStateBeforeMobileFit = @@ -2462,6 +2534,48 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ : runtime.getRendererTerminalSerializerGeneration(ptyId) : 0 const supportsDesktopViewportClaims = params.capabilities?.desktopViewportClaims === 1 + if (mobileInputLeaseOnly && clientId) { + let closed = false + let resolveStream = (): void => {} + const streamClosed = new Promise((resolve) => { + resolveStream = resolve + }) + const subscriptionId = `${params.terminal}:${clientId}` + // Why: chat needs the input-floor acknowledgement without registering + // a view subscriber or transporting duplicate PTY output. + runtime.registerSubscriptionCleanup( + subscriptionId, + () => { + closed = true + runtime.handleMobileUnsubscribe(ptyId, clientId) + emit({ type: 'end' }) + resolveStream() + }, + connectionId + ) + void runtime + .waitForTerminal(params.terminal, { condition: 'exit', signal }) + .then(() => runtime.cleanupSubscription(subscriptionId)) + .catch(() => runtime.cleanupSubscription(subscriptionId)) + try { + await runtime.handleMobileSubscribe(ptyId, clientId, params.viewport) + if (closed || signal?.aborted) { + // Why: a disconnect can win the awaited subscribe and otherwise + // resurrect mobile presence after cleanup already released it. + runtime.handleMobileUnsubscribe(ptyId, clientId) + if (!closed) { + runtime.cleanupSubscription(subscriptionId) + } + return + } + emit({ type: 'subscribed', streamId: null, lines: [], truncated: false }) + await streamClosed + } catch (error) { + runtime.cleanupSubscription(subscriptionId) + throw error + } + return + } // Why: only unregister the width floor this subscription took (see the // multiplex stream's registeredRemoteDesktopDriver note). let registeredRemoteDesktopDriver = false @@ -2675,22 +2789,17 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ if (isTerminalInputLockedForClient(runtime, ptyId, params.client)) { return } - void desktopClaimTail - .then((claimed) => - !claimed || isTerminalInputLockedForClient(runtime, ptyId, params.client) - ? null - : runtime.sendTerminal(params.terminal, { - text, - enter: false, - interrupt: false - }) - ) - .then(async () => { - if (isMobile && clientId) { - await runtime.mobileTookFloor(ptyId, clientId) - } + void desktopClaimTail.then(async (claimed) => { + if (!claimed || isTerminalInputLockedForClient(runtime, ptyId, params.client)) { + return + } + await sendTerminalStreamInput(runtime, { + terminal: params.terminal, + text, + client: params.client, + isMobile }) - .catch(() => {}) + }) return } if (frame.opcode === TerminalStreamOpcode.Resize && params.client) { diff --git a/src/main/runtime/rpc/terminal-multiplex.test.ts b/src/main/runtime/rpc/terminal-multiplex.test.ts index 20b608c94c2b..99a77fb9afd7 100644 --- a/src/main/runtime/rpc/terminal-multiplex.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex.test.ts @@ -692,6 +692,9 @@ describe('terminal multiplex RPC', () => { }) => void) | undefined const restreamResolves: ((value: { data: string; cols: number; rows: number }) => void)[] = [] + const write = vi.fn() + const commit = vi.fn().mockResolvedValue(undefined) + const beginMobileInputFloor = vi.fn(() => ({ commit, rollback: vi.fn() })) const runtime = stubRuntime({ resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), @@ -723,7 +726,13 @@ describe('terminal multiplex RPC', () => { cleanups.set(id, cleanup) }), waitForTerminal: vi.fn(() => new Promise(() => {})), - sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), + sendTerminal: vi.fn().mockImplementation(async (_handle, _action, options) => { + options.reserveWrite('pty-1') + write() + await options.afterWrite('pty-1') + return { accepted: true } + }), + beginMobileInputFloor, updateMobileViewport: vi.fn().mockResolvedValue({ updated: false, applied: false }) }) const dispatcher = new RpcDispatcher({ @@ -787,12 +796,16 @@ describe('terminal multiplex RPC', () => { )! ) await vi.waitFor(() => - expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', { - text: 'x', - enter: false, - interrupt: false - }) + expect(runtime.sendTerminal).toHaveBeenCalledWith( + 'terminal-1', + { text: 'x', enter: false, interrupt: false }, + { reserveWrite: expect.any(Function), afterWrite: expect.any(Function) } + ) + ) + expect(beginMobileInputFloor.mock.invocationCallOrder[0]).toBeLessThan( + write.mock.invocationCallOrder[0]! ) + expect(write.mock.invocationCallOrder[0]).toBeLessThan(commit.mock.invocationCallOrder[0]!) binaryFrames.splice(0) resizeListener?.({ diff --git a/src/main/runtime/rpc/terminal-output-batching.test.ts b/src/main/runtime/rpc/terminal-output-batching.test.ts index d10456c8fa69..c1ee755ee831 100644 --- a/src/main/runtime/rpc/terminal-output-batching.test.ts +++ b/src/main/runtime/rpc/terminal-output-batching.test.ts @@ -277,12 +277,18 @@ describe('terminal output batching', () => { } }) - it('routes binary terminal input frames back to the subscribed PTY', async () => { + it('claims the mobile floor before binary terminal input and rolls back rejection', async () => { const handlers = new Map< number, (frame: NonNullable>) => void >() const cleanups = new Map void>() + const write = vi.fn() + const commit = vi.fn().mockResolvedValue(undefined) + const rollback = vi.fn() + const beginMobileInputFloor = vi.fn( + (): ReturnType => ({ commit, rollback }) + ) const runtime = stubRuntime({ resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), @@ -293,7 +299,11 @@ describe('terminal output batching', () => { subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + subscribeToDriverChanges: vi.fn().mockReturnValue(vi.fn()), + getTerminalFitOverride: vi.fn().mockReturnValue(null), getDriver: vi.fn().mockReturnValue({ kind: 'idle' }), + handleMobileSubscribe: vi.fn().mockResolvedValue(undefined), + handleMobileUnsubscribe: vi.fn(), registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { cleanups.set(id, cleanup) }), @@ -302,7 +312,13 @@ describe('terminal output batching', () => { cleanups.delete(id) }), waitForTerminal: vi.fn(() => new Promise(() => {})), - sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), + sendTerminal: vi.fn().mockImplementation(async (_handle, _action, options) => { + options.reserveWrite('pty-1') + write() + await options.afterWrite('pty-1') + return { accepted: true } + }), + beginMobileInputFloor, updateMobileViewport: vi.fn().mockResolvedValue(false) }) const dispatcher = new RpcDispatcher({ @@ -314,7 +330,7 @@ describe('terminal output batching', () => { const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.subscribe', { terminal: 'terminal-1', - client: { id: 'desktop-1', type: 'desktop' }, + client: { id: 'mobile-1', type: 'mobile' }, capabilities: { terminalBinaryStream: 1 } }), (msg) => messages.push(msg), @@ -345,14 +361,54 @@ describe('terminal output batching', () => { ) await vi.waitFor(() => - expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', { - text: 'ls\r', - enter: false, - interrupt: false - }) + expect(runtime.sendTerminal).toHaveBeenCalledWith( + 'terminal-1', + { text: 'ls\r', enter: false, interrupt: false }, + { reserveWrite: expect.any(Function), afterWrite: expect.any(Function) } + ) + ) + expect(beginMobileInputFloor).toHaveBeenCalledWith('pty-1', 'mobile-1') + expect(beginMobileInputFloor.mock.invocationCallOrder[0]).toBeLessThan( + write.mock.invocationCallOrder[0]! + ) + expect(write.mock.invocationCallOrder[0]).toBeLessThan(commit.mock.invocationCallOrder[0]!) + + vi.mocked(runtime.sendTerminal).mockImplementationOnce(async (_handle, _action, options) => { + options?.reserveWrite?.('pty-1') + return { handle: 'terminal-1', accepted: false, bytesWritten: 0 } + }) + handlers.get(streamId)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Input, + streamId, + seq: 2, + payload: encodeTerminalStreamText('rejected') + }) + )! + ) + await vi.waitFor(() => expect(rollback).toHaveBeenCalledOnce()) + expect(commit).toHaveBeenCalledOnce() + + // Post-unsubscribe: capture the still-registered handler, tear down the + // subscription, then replay a frame. The closed guard must drop it, so no + // further sendTerminal dispatch and no write reach the PTY. + const staleHandler = handlers.get(streamId)! + runtime.cleanupSubscription('terminal-1:mobile-1') + staleHandler( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Input, + streamId, + seq: 3, + payload: encodeTerminalStreamText('after-unsubscribe') + }) + )! ) + await Promise.resolve() + expect(runtime.sendTerminal).toHaveBeenCalledTimes(2) + expect(write).toHaveBeenCalledOnce() - runtime.cleanupSubscription('terminal-1:desktop-1') await dispatchPromise }) }) diff --git a/src/main/runtime/rpc/terminal-send.test.ts b/src/main/runtime/rpc/terminal-send.test.ts index 58b37bcd7fc3..e9fb481aae06 100644 --- a/src/main/runtime/rpc/terminal-send.test.ts +++ b/src/main/runtime/rpc/terminal-send.test.ts @@ -16,6 +16,12 @@ import { function stubRuntime(overrides: Partial = {}): OrcaRuntimeService { return { getRuntimeId: () => 'test-runtime', + beginMobileInputFloor: vi.fn((ptyId: string, clientId: string) => ({ + commit: async () => { + await overrides.mobileTookFloor?.(ptyId, clientId) + }, + rollback: vi.fn() + })), ...overrides } as OrcaRuntimeService } @@ -144,10 +150,9 @@ describe('terminal send RPC', () => { releaseClaim = () => resolve(true) }) ) - const sendTerminal = vi.fn().mockResolvedValue({ - handle: 'terminal-1', - accepted: true, - bytesWritten: 1 + const sendTerminal = vi.fn().mockImplementation(async (_handle, _action, options) => { + await options.beforeWrite?.('pty-1') + return { handle: 'terminal-1', accepted: true, bytesWritten: 1 } }) const runtime = stubRuntime({ resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), @@ -177,15 +182,33 @@ describe('terminal send RPC', () => { }) it('accepts legacy clientless mobile input when the current driver is mobile', async () => { - const runtime = stubRuntime({ - resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), - getDriver: vi.fn().mockReturnValue({ kind: 'mobile', clientId: 'mobile-1' }), - sendTerminal: vi.fn().mockResolvedValue({ + const write = vi.fn() + const sendTerminal = vi.fn().mockImplementation(async (_handle, _action, options) => { + await options.beforeWrite?.('pty-1') + options.reserveWrite?.('pty-1') + write() + await options.afterWrite?.('pty-1') + await options.beforeWrite?.('pty-1') + options.reserveWrite?.('pty-1') + write() + await options.afterWrite?.('pty-1') + return { handle: 'terminal-1', accepted: true, bytesWritten: 1 - }), - mobileTookFloor: vi.fn().mockResolvedValue(undefined) + } + }) + const mobileTookFloor = vi.fn().mockResolvedValue(undefined) + const beginMobileInputFloor = vi.fn(() => ({ + commit: async () => mobileTookFloor('pty-1', 'mobile-1'), + rollback: vi.fn() + })) + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + getDriver: vi.fn().mockReturnValue({ kind: 'mobile', clientId: 'mobile-1' }), + sendTerminal, + beginMobileInputFloor, + mobileTookFloor }) const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) @@ -208,9 +231,115 @@ describe('terminal send RPC', () => { enter: false, interrupt: false }, - { beforeWrite: undefined } + { + beforeWrite: undefined, + reserveWrite: expect.any(Function), + afterWrite: expect.any(Function) + } ) expect(runtime.mobileTookFloor).toHaveBeenCalledWith('pty-1', 'mobile-1') + expect(beginMobileInputFloor).toHaveBeenCalledTimes(2) + expect(runtime.mobileTookFloor).toHaveBeenCalledTimes(2) + expect(beginMobileInputFloor.mock.invocationCallOrder[0]).toBeLessThan( + write.mock.invocationCallOrder[0]! + ) + expect(write.mock.invocationCallOrder[0]).toBeLessThan( + mobileTookFloor.mock.invocationCallOrder[0]! + ) + }) + + it.each([ + ['rejects', async () => ({ handle: 'terminal-1', accepted: false, bytesWritten: 0 })], + [ + 'throws', + async () => { + throw new Error('write failed') + } + ] + ])('rolls back a mobile floor claim when the terminal write %s', async (_case, finishWrite) => { + const commit = vi.fn() + const rollback = vi.fn() + const beginMobileInputFloor = vi.fn(() => ({ commit, rollback })) + const sendTerminal = vi.fn().mockImplementation(async (_handle, _action, options) => { + await options.beforeWrite?.('pty-1') + options.reserveWrite?.('pty-1') + return finishWrite() + }) + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }), + sendTerminal, + beginMobileInputFloor + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + await dispatcher.dispatch( + makeRequest('terminal.send', { + terminal: 'terminal-1', + text: 'x', + client: { id: 'mobile-1', type: 'mobile' } + }) + ) + + expect(beginMobileInputFloor).toHaveBeenCalledWith('pty-1', 'mobile-1') + expect(beginMobileInputFloor).toHaveBeenCalledOnce() + expect(rollback).toHaveBeenCalledOnce() + expect(commit).not.toHaveBeenCalled() + }) + + it('writes zero bytes when the mobile subscriber disappeared before reservation', async () => { + const write = vi.fn() + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + getDriver: vi.fn().mockReturnValue({ kind: 'mobile', clientId: 'mobile-1' }), + beginMobileInputFloor: vi.fn().mockReturnValue(null), + sendTerminal: vi.fn().mockImplementation(async (_handle, _action, options) => { + options.reserveWrite('pty-1') + write() + return { handle: 'terminal-1', accepted: true, bytesWritten: 1 } + }) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('terminal.send', { terminal: 'terminal-1', text: 'stale' }) + ) + + expect(response.ok).toBe(false) + expect(write).not.toHaveBeenCalled() + }) + + it('reclaims the mobile floor for a delayed suffix and preserves the earlier commit', async () => { + const commit = vi.fn() + const rollback = vi.fn() + const beginMobileInputFloor = vi.fn(() => ({ commit: async () => commit(), rollback })) + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }), + beginMobileInputFloor, + sendTerminal: vi.fn().mockImplementation(async (_handle, _action, options) => { + await options.beforeWrite?.('pty-1') + options.reserveWrite('pty-1') + await options.afterWrite('pty-1') + await options.beforeWrite?.('pty-1') + options.reserveWrite('pty-1') + throw new Error('suffix failed') + }) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + await dispatcher.dispatch( + makeRequest('terminal.send', { + terminal: 'terminal-1', + text: 'partial', + enter: true, + client: { id: 'mobile-1', type: 'mobile' } + }) + ) + + expect(beginMobileInputFloor).toHaveBeenCalledTimes(2) + expect(commit).toHaveBeenCalledOnce() + expect(rollback).toHaveBeenCalledOnce() }) it('writes a validated terminal query reply without taking the mobile floor', async () => { @@ -446,6 +575,7 @@ describe('terminal send RPC', () => { }) it('refuses guarded terminal sends when the agent needs permission', async () => { + const beginMobileInputFloor = vi.fn() const runtime = stubRuntime({ resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }), @@ -454,7 +584,8 @@ describe('terminal send RPC', () => { isRunningAgent: true, status: 'permission' }), - sendTerminal: vi.fn() + sendTerminal: vi.fn(), + beginMobileInputFloor }) const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) @@ -463,7 +594,7 @@ describe('terminal send RPC', () => { terminal: 'terminal-1', enter: true, requireAgentStatus: 'sendable', - client: { id: 'desktop-1', type: 'desktop' } + client: { id: 'mobile-1', type: 'mobile' } }) ) @@ -481,6 +612,7 @@ describe('terminal send RPC', () => { }) expect(runtime.getTerminalAgentStatus).toHaveBeenCalledWith('terminal-1') expect(runtime.sendTerminal).not.toHaveBeenCalled() + expect(beginMobileInputFloor).not.toHaveBeenCalled() }) it('allows guarded terminal sends when the agent is sendable', async () => { diff --git a/src/main/runtime/rpc/terminal-subscribe-lease-only.test.ts b/src/main/runtime/rpc/terminal-subscribe-lease-only.test.ts new file mode 100644 index 000000000000..fab3d5a09cd1 --- /dev/null +++ b/src/main/runtime/rpc/terminal-subscribe-lease-only.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RuntimeTerminalWait } from '../../../shared/runtime-types' +import type { OrcaRuntimeService } from '../orca-runtime' +import type { RpcRequest } from './core' +import { RpcDispatcher } from './dispatcher' +import { TERMINAL_METHODS } from './methods/terminal' + +const request: RpcRequest = { + id: 'req-1', + authToken: 'tok', + method: 'terminal.subscribe', + params: { + terminal: 'terminal-1', + client: { id: 'phone-1', type: 'mobile' }, + capabilities: { terminalBinaryStream: 1, mobileInputLeaseOnly: 1 } + } +} + +describe('terminal lease-only subscription', () => { + it('keeps mobile input ownership without registering output delivery', async () => { + const messages: string[] = [] + const cleanups = new Map void>() + const runtime = { + getRuntimeId: () => 'test-runtime', + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + handleMobileSubscribe: vi.fn().mockResolvedValue(true), + handleMobileUnsubscribe: vi.fn(), + subscribeToTerminalData: vi.fn(), + registerRemoteTerminalViewSubscriber: vi.fn(), + readTerminal: vi.fn(), + serializeTerminalBuffer: vi.fn(), + subscribeToTerminalResize: vi.fn(), + subscribeToFitOverrideChanges: vi.fn(), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + cleanups.get(id)?.() + cleanups.delete(id) + }), + waitForTerminal: vi.fn(() => new Promise(() => {})) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + request, + (message) => messages.push(message), + { + connectionId: 'conn-phone', + sendBinary: vi.fn(), + registerBinaryStreamHandler: vi.fn(() => vi.fn()) + } + ) + + await vi.waitFor(() => + expect(messages.some((message) => JSON.parse(message).result?.type === 'subscribed')).toBe( + true + ) + ) + expect(runtime.handleMobileSubscribe).toHaveBeenCalledWith('pty-1', 'phone-1', undefined) + expect(runtime.subscribeToTerminalData).not.toHaveBeenCalled() + expect(runtime.registerRemoteTerminalViewSubscriber).not.toHaveBeenCalled() + expect(runtime.readTerminal).not.toHaveBeenCalled() + expect(runtime.serializeTerminalBuffer).not.toHaveBeenCalled() + expect(runtime.subscribeToTerminalResize).not.toHaveBeenCalled() + expect(runtime.subscribeToFitOverrideChanges).not.toHaveBeenCalled() + + runtime.cleanupSubscription('terminal-1:phone-1') + await dispatchPromise + expect(runtime.handleMobileUnsubscribe).toHaveBeenCalledWith('pty-1', 'phone-1') + }) +}) diff --git a/src/main/runtime/runtime-mobile-file-path-search.test.ts b/src/main/runtime/runtime-mobile-file-path-search.test.ts new file mode 100644 index 000000000000..495a79338424 --- /dev/null +++ b/src/main/runtime/runtime-mobile-file-path-search.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from 'vitest' +import { + rankRuntimeMobileFilePaths, + type RuntimeMobileFilePathInventory, + RuntimeMobileFilePathSearchCache +} from './runtime-mobile-file-path-search' + +describe('rankRuntimeMobileFilePaths', () => { + it('ranks path and basename prefixes before substrings and caps output', () => { + expect( + rankRuntimeMobileFilePaths( + ['docs/apple.md', 'src/apple.ts', 'src/deep/pineapple.ts', 'z/apple.test.ts'], + 'apple', + 3 + ) + ).toEqual({ + paths: ['docs/apple.md', 'src/apple.ts', 'z/apple.test.ts'], + totalCount: 4 + }) + }) +}) + +describe('RuntimeMobileFilePathSearchCache', () => { + it('reuses a live entry, reloads after expiry, and evicts the oldest key', async () => { + const cache = new RuntimeMobileFilePathSearchCache(2, 100) + const load = vi.fn(async (path: string) => ({ + paths: [path], + totalCount: 1, + truncated: false + })) + + await cache.get('a', () => load('a'), 0) + await cache.get('a', () => load('a-again'), 50) + await cache.get('b', () => load('b'), 50) + await cache.get('c', () => load('c'), 50) + await cache.get('a', () => load('a-reloaded'), 50) + + expect(load.mock.calls.map((call) => call[0])).toEqual(['a', 'b', 'c', 'a-reloaded']) + }) + + it('reloads an unevicted key once its TTL has elapsed', async () => { + // TTL expiry without capacity pressure — exercises the now >= expiresAt path + // that LRU eviction otherwise masks. + const cache = new RuntimeMobileFilePathSearchCache(2, 100) + const load = vi.fn(async (path: string) => ({ paths: [path], totalCount: 1, truncated: false })) + + await cache.get('a', () => load('a'), 0) + await cache.get('a', () => load('a-live'), 50) + await cache.get('a', () => load('a-expired'), 150) + + expect(load.mock.calls.map((call) => call[0])).toEqual(['a', 'a-expired']) + }) + + it('coalesces concurrent cold loads for the same worktree', async () => { + const cache = new RuntimeMobileFilePathSearchCache(2, 100) + let resolveLoad: (value: RuntimeMobileFilePathInventory) => void = () => {} + const load = vi.fn( + () => + new Promise((resolve) => { + resolveLoad = resolve + }) + ) + + const first = cache.get('a', load, 0) + const second = cache.get('a', load, 0) + expect(load).toHaveBeenCalledOnce() + + resolveLoad({ paths: ['a'], totalCount: 1, truncated: false }) + await expect(Promise.all([first, second])).resolves.toEqual([ + { paths: ['a'], totalCount: 1, truncated: false }, + { paths: ['a'], totalCount: 1, truncated: false } + ]) + }) + + it('starts the TTL when a slow inventory becomes usable', async () => { + vi.useFakeTimers() + try { + vi.setSystemTime(0) + const cache = new RuntimeMobileFilePathSearchCache(2, 100) + let resolveLoad: (value: RuntimeMobileFilePathInventory) => void = () => {} + const load = vi.fn( + () => + new Promise((resolve) => { + resolveLoad = resolve + }) + ) + const first = cache.get('slow-ssh', load) + vi.setSystemTime(200) + resolveLoad({ paths: ['a'], totalCount: 1, truncated: false }) + await first + + await cache.get('slow-ssh', load) + expect(load).toHaveBeenCalledOnce() + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/main/runtime/runtime-mobile-file-path-search.ts b/src/main/runtime/runtime-mobile-file-path-search.ts new file mode 100644 index 000000000000..13213d6dfcf3 --- /dev/null +++ b/src/main/runtime/runtime-mobile-file-path-search.ts @@ -0,0 +1,93 @@ +export type RuntimeMobileFilePathInventory = { + paths: string[] + totalCount: number + truncated: boolean +} + +type CacheEntry = RuntimeMobileFilePathInventory & { expiresAt: number } + +/** Lazy TTL/LRU cache for autocomplete inventories. It avoids launching rg for + * every mobile keystroke while bounding retained worktrees and paths. */ +export class RuntimeMobileFilePathSearchCache { + private readonly entries = new Map() + private readonly inFlight = new Map>() + + constructor( + private readonly maxEntries: number, + private readonly ttlMs: number + ) {} + + async get( + key: string, + load: () => Promise, + now?: number + ): Promise { + const requestedAt = now ?? Date.now() + const cached = this.entries.get(key) + if (cached && cached.expiresAt > requestedAt) { + this.entries.delete(key) + this.entries.set(key, cached) + return cached + } + this.entries.delete(key) + const pending = this.inFlight.get(key) + if (pending) { + return pending + } + const next = load() + .then((loaded) => { + // Why: a slow SSH scan should receive a full TTL after it becomes usable, + // not arrive already expired because the clock started before its I/O. + this.entries.set(key, { ...loaded, expiresAt: (now ?? Date.now()) + this.ttlMs }) + while (this.entries.size > this.maxEntries) { + const oldest = this.entries.keys().next().value as string | undefined + if (!oldest) { + break + } + this.entries.delete(oldest) + } + return loaded + }) + .finally(() => { + if (this.inFlight.get(key) === next) { + this.inFlight.delete(key) + } + }) + // Why: debounced clients can overlap on a cold key; sharing this promise + // prevents duplicate local rg or SSH inventory scans. + this.inFlight.set(key, next) + return next + } +} + +/** Preserves composer ranking: full-path/basename prefixes first, then substring + * matches, while returning only the requested bounded candidate slice. */ +export function rankRuntimeMobileFilePaths( + paths: readonly string[], + query: string, + limit: number +): { paths: string[]; totalCount: number } { + const normalizedQuery = query.trim().toLowerCase() + if (!normalizedQuery) { + return { paths: paths.slice(0, limit), totalCount: paths.length } + } + const prefix: string[] = [] + const substring: string[] = [] + let totalCount = 0 + for (const path of paths) { + const lower = path.toLowerCase() + const basename = lower.split('/').pop() ?? lower + if (lower.startsWith(normalizedQuery) || basename.startsWith(normalizedQuery)) { + totalCount++ + if (prefix.length < limit) { + prefix.push(path) + } + } else if (lower.includes(normalizedQuery)) { + totalCount++ + if (substring.length < limit) { + substring.push(path) + } + } + } + return { paths: [...prefix, ...substring].slice(0, limit), totalCount } +} diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 6dd4856b738c..d76230656349 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -199,6 +199,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'files.readTerminalArtifact', 'files.readTerminalArtifactPreview', 'files.resolveTerminalPath', + 'files.searchPaths', 'files.writeTerminalArtifact', 'folderWorkspace.list', 'git.abortMerge', diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 7e228f8495ff..a8793c5f8b10 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -832,10 +832,15 @@ export type NativeChatReadSessionResult = /** Messages appended to a live-tailed transcript since the previous emit. */ export type NativeChatAppendedMessages = NativeChatMessage[] +export type NativeChatSubscriptionFrame = + | { type: 'snapshot'; messages: NativeChatMessage[]; hasMore: boolean; error?: string } + | { type: 'replacement'; messages: NativeChatMessage[]; hasMore: boolean } + | { type: 'appended'; messages: NativeChatMessage[] } + /** Wire payload for the `nativeChat:appended` push channel. */ export type NativeChatAppendedPayload = { subscriptionId: string - messages: NativeChatAppendedMessages + frame: NativeChatSubscriptionFrame } export type NativeChatSubscribeArgs = { @@ -846,6 +851,8 @@ export type NativeChatSubscribeArgs = { sessionId: string /** Authoritative transcript path from the agent hook (providerSession). */ transcriptPath?: string + /** First snapshot size; later readSession calls grow this for pagination. */ + limit?: number } export type NativeChatApi = { @@ -859,11 +866,11 @@ export type NativeChatApi = { limit?: number, transcriptPath?: string ) => Promise - /** Live-tail a transcript: `onAppended` fires with only newly-appended - * messages. Returns an unsubscribe fn that closes the main-process watcher. */ + /** Live-tail a transcript. The first frame is a bounded race-safe snapshot; + * later frames contain only newly appended messages. */ subscribe: ( args: NativeChatSubscribeArgs, - onAppended: (messages: NativeChatAppendedMessages) => void + onFrame: (frame: NativeChatSubscriptionFrame) => void ) => () => void } diff --git a/src/preload/index.ts b/src/preload/index.ts index f4f816596711..d1a6dfb5c926 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -176,9 +176,9 @@ import type { KeybindingActionId, KeybindingFileSnapshot } from '../shared/keybi import type { AiVaultListArgs, AiVaultSubagentListArgs } from '../shared/ai-vault-types' import type { AgentType } from '../shared/native-chat-types' import type { - NativeChatAppendedMessages, NativeChatAppendedPayload, - NativeChatReadSessionResult + NativeChatReadSessionResult, + NativeChatSubscriptionFrame } from './api-types' import { ORCA_EDITOR_PREPARE_HOT_EXIT_EVENT, @@ -3944,12 +3944,13 @@ const api = { agent: AgentType sessionId: string transcriptPath?: string + limit?: number }, - onAppended: (messages: NativeChatAppendedMessages) => void + onFrame: (frame: NativeChatSubscriptionFrame) => void ): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, payload: NativeChatAppendedPayload) => { if (payload.subscriptionId === args.subscriptionId) { - onAppended(payload.messages) + onFrame(payload.frame) } } ipcRenderer.on('nativeChat:appended', listener) diff --git a/src/relay/fs-handler-git-fallback.ts b/src/relay/fs-handler-git-fallback.ts index ae418f9e8814..8de2ad3394ca 100644 --- a/src/relay/fs-handler-git-fallback.ts +++ b/src/relay/fs-handler-git-fallback.ts @@ -9,8 +9,15 @@ import { spawn } from 'node:child_process' import { fileListingCancellationError } from '../shared/file-listing-cancellation' import type { SearchOptions, SearchResult } from './fs-handler-utils' -import { buildGitLsFilesArgsForQuickOpen } from '../shared/quick-open-filter' -import { expandQuickOpenGitFileListing } from '../shared/quick-open-readdir-walk' +import { + buildGitLsFilesArgsForQuickOpen, + shouldExcludeQuickOpenRelPath, + shouldIncludeQuickOpenPath +} from '../shared/quick-open-filter' +import { + expandQuickOpenGitFileListing, + parseQuickOpenGitLsFilesEntry +} from '../shared/quick-open-readdir-walk' import { buildGitGrepArgs, buildSubmatchRegex, @@ -33,19 +40,21 @@ import { buildRelayGitEnv } from './relay-command-env' export function listFilesWithGit( rootPath: string, excludePathPrefixes: readonly string[] = [], - options: { signal?: AbortSignal } = {} + options: { signal?: AbortSignal; maxResults?: number } = {} ): Promise { - const { signal } = options + const { signal, maxResults } = options if (signal?.aborted) { return Promise.reject(fileListingCancellationError(signal)) } const gitPaths = new Set() const directoryPaths = new Set() + const directFileCandidates = new Set() const { primary, ignoredPass } = buildGitLsFilesArgsForQuickOpen(excludePathPrefixes) const children: { child: ReturnType isDone: () => boolean reject: (error: Error) => void + resolve: () => void }[] = [] const runGitLsFiles = (args: string[]): Promise => { @@ -53,15 +62,32 @@ export function listFilesWithGit( let buf = '' let done = false - const processPath = (path: string): void => { + const processPath = (path: string): boolean => { if (!path) { - return + return false } if (path.endsWith('/')) { directoryPaths.add(path) } else { gitPaths.add(path) + if (maxResults !== undefined) { + // Why: this duplicate classification exists only to stop bounded + // scans; unbounded SSH scans must not retain another full listing. + const parsed = parseQuickOpenGitLsFilesEntry(path) + const relPath = parsed.path.replace(/\/+$/, '') + if ( + !parsed.isGitlink && + !parsed.isUntrackedDir && + shouldIncludeQuickOpenPath(relPath) && + !shouldExcludeQuickOpenRelPath(relPath, excludePathPrefixes) + ) { + directFileCandidates.add(relPath) + } + } } + // Why: placeholders need IO classification and can disappear; only + // guaranteed final files are allowed to stop the remote Git processes. + return maxResults !== undefined && directFileCandidates.size >= maxResults } const child = spawn('git', ['ls-files', ...args], { @@ -100,7 +126,8 @@ export function listFilesWithGit( children.push({ child, isDone: () => done, - reject: rejectPass + reject: rejectPass, + resolve: resolvePass }) function handleStdoutData(chunk: string): void { @@ -108,7 +135,11 @@ export function listFilesWithGit( let start = 0 let idx = buf.indexOf('\0', start) while (idx !== -1) { - processPath(buf.substring(start, idx)) + if (processPath(buf.substring(start, idx))) { + buf = '' + finishAtLimit() + return + } start = idx + 1 idx = buf.indexOf('\0', start) } @@ -131,8 +162,10 @@ export function listFilesWithGit( rejectPass(new Error(`git ls-files killed by ${signal}`)) return } - if (buf) { - processPath(buf) + if (buf && processPath(buf)) { + buf = '' + finishAtLimit() + return } if (code === 0) { resolvePass() @@ -170,19 +203,27 @@ export function listFilesWithGit( } } + function finishAtLimit(): void { + for (const entry of children) { + if (entry.isDone()) { + continue + } + entry.resolve() + if (entry.child.exitCode === null && entry.child.signalCode === null) { + entry.child.kill() + } + } + } + // Why: a cancelled scan (workspace switch, superseded request) must stop // its git children right away instead of streaming a huge tree the caller // has already abandoned over the shared SSH channel. const onAbort = (): void => killSurvivors('git ls-files cancelled') signal?.addEventListener('abort', onAbort, { once: true }) - return Promise.all([ - runGitLsFiles(primary), + const runIgnoredPass = () => // Why: ignored files are supplementary — a failed or timed-out ignored - // pass must not discard the primary listing the user actually needs - // (#7719 root cause: the all-or-nothing failure showed zero files). - // Entries streamed before the failure are kept; a cancelled scan still - // rejects via the primary pass or the expansion's cancellation check. + // pass must not discard the primary listing the user actually needs. runGitLsFiles(ignoredPass).catch((err: Error) => { if (!signal?.aborted) { console.warn( @@ -191,18 +232,26 @@ export function listFilesWithGit( ) } }) - ]) + const passes = + maxResults === undefined + ? Promise.all([runGitLsFiles(primary), runIgnoredPass()]) + : runGitLsFiles(primary).then(() => + directFileCandidates.size < maxResults ? runIgnoredPass() : Promise.resolve() + ) + + return passes .then(async () => { const files = await expandQuickOpenGitFileListing({ rootPath, gitPaths, directoryPaths, excludePathPrefixes, - signal + signal, + maxResults }) // Why: directory placeholders are expanded after Git exits; restore // Git's path order for empty queries and fuzzy-score ties over SSH. - return files.sort() + return files.sort().slice(0, maxResults) }) .catch((err) => { killSurvivors('git ls-files canceled after sibling failure') diff --git a/src/relay/fs-handler-list-files-ignored.test.ts b/src/relay/fs-handler-list-files-ignored.test.ts index d76fe40b5178..1ca5c2f53277 100644 --- a/src/relay/fs-handler-list-files-ignored.test.ts +++ b/src/relay/fs-handler-list-files-ignored.test.ts @@ -97,6 +97,24 @@ describe('relay quick open ignored file listing', () => { expect(ignoredArgs).toContain('!packages/other/**') }) + it('stops after the primary relay rg pass fills the result budget', async () => { + const primaryProc = createMockProcess() + const ignoredProc = createMockProcess() + let callIndex = 0 + spawnMock.mockImplementation(() => (++callIndex === 1 ? primaryProc : ignoredProc)) + + const promise = listFilesWithRg('/remote/root', [], { maxResults: 2 }) + ;(primaryProc.stdout as unknown as EventEmitter).emit( + 'data', + 'src/one.ts\nsrc/two.ts\nsrc/three.ts\n' + ) + + await expect(promise).resolves.toEqual(['src/one.ts', 'src/two.ts']) + expect(primaryProc.kill).toHaveBeenCalled() + expect(ignoredProc.kill).not.toHaveBeenCalled() + expect(callIndex).toBe(1) + }) + it('git fallback ignored pass includes ignored non-env files', async () => { const root = await makeTempRoot() await writeRel(root, 'dist/generated.js') @@ -146,6 +164,35 @@ describe('relay quick open ignored file listing', () => { expect(ignoredArgs).toContain(':(exclude,glob)packages/other/**') }) + it('stops after primary relay Git files fill the result budget', async () => { + const primaryProc = createMockProcess() + const ignoredProc = createMockProcess() + let callIndex = 0 + spawnMock.mockImplementation(() => (++callIndex === 1 ? primaryProc : ignoredProc)) + + const promise = listFilesWithGit('/remote/root', [], { maxResults: 2 }) + ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/one.ts\0src/two.ts') + primaryProc.emit('close', 0, null) + await expect(promise).resolves.toEqual(['src/one.ts', 'src/two.ts']) + expect(primaryProc.kill).toHaveBeenCalled() + expect(ignoredProc.kill).not.toHaveBeenCalled() + expect(callIndex).toBe(1) + }) + + it('does not let a discarded relay Git placeholder consume the result budget', async () => { + const primaryProc = createMockProcess() + spawnMock.mockReturnValue(primaryProc) + + const promise = listFilesWithGit('/remote/root', [], { maxResults: 1 }) + ;(primaryProc.stdout as unknown as EventEmitter).emit( + 'data', + `discarded/\0${staged('100644', 'src/kept.ts')}\0` + ) + + await expect(promise).resolves.toEqual(['src/kept.ts']) + expect(primaryProc.kill).toHaveBeenCalled() + }) + it('git fallback fills nested git repos returned as root-relative placeholders', async () => { const root = await makeTempRoot() await writeRel(root, 'README.md') diff --git a/src/relay/fs-handler-list-files.ts b/src/relay/fs-handler-list-files.ts index bbe7912ee61c..c63a352786e0 100644 --- a/src/relay/fs-handler-list-files.ts +++ b/src/relay/fs-handler-list-files.ts @@ -29,9 +29,9 @@ export const LIST_FILES_TIMEOUT_MS = 25_000 export function listFilesWithRg( rootPath: string, excludePathPrefixes: readonly string[] = [], - options: { signal?: AbortSignal } = {} + options: { signal?: AbortSignal; maxResults?: number } = {} ): Promise { - const { signal } = options + const { signal, maxResults } = options if (signal?.aborted) { return Promise.reject(fileListingCancellationError(signal)) } @@ -67,6 +67,9 @@ export function listFilesWithRg( return true } files.add(relPath) + if (maxResults !== undefined && files.size >= maxResults) { + finishAtLimit() + } return true } @@ -134,6 +137,9 @@ export function listFilesWithRg( if (processLine(passBuf.substring(start, idx))) { passFileCount++ } + if (done) { + return + } start = idx + 1 idx = passBuf.indexOf('\n', start) } @@ -199,6 +205,16 @@ export function listFilesWithRg( } } + function finishAtLimit(): void { + if (done) { + return + } + done = true + signal?.removeEventListener('abort', onAbort) + killSurvivors('rg list reached bounded result limit') + resolve(Array.from(files).slice(0, maxResults)) + } + // Why: a cancelled scan (workspace switch, superseded request) must stop // its rg children immediately instead of letting them walk the tree to // completion and flood the relay with stdout it will only discard. @@ -212,7 +228,16 @@ export function listFilesWithRg( } signal?.addEventListener('abort', onAbort, { once: true }) - Promise.all([runPass(primary), runPass(ignoredPass)]) + const passes = + maxResults === undefined + ? Promise.all([runPass(primary), runPass(ignoredPass)]) + : // Why: deterministic primary-first budgeting prevents a large ignored + // tree from starving ordinary source paths on a remote host. + runPass(primary).then(() => + files.size < maxResults ? runPass(ignoredPass) : Promise.resolve() + ) + + passes .then(() => { if (done) { return diff --git a/src/relay/fs-handler-readdir-fallback.test.ts b/src/relay/fs-handler-readdir-fallback.test.ts new file mode 100644 index 000000000000..d3e5591ab32a --- /dev/null +++ b/src/relay/fs-handler-readdir-fallback.test.ts @@ -0,0 +1,26 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { listFilesWithReaddir } from './fs-handler-readdir-fallback' + +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('relay readdir file-list fallback', () => { + it('returns a bounded prefix instead of rejecting at the requested result limit', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-relay-readdir-')) + roots.push(root) + await mkdir(join(root, 'src')) + await writeFile(join(root, 'src', 'a.ts'), 'a') + await writeFile(join(root, 'src', 'b.ts'), 'b') + + const files = await listFilesWithReaddir(root, [], { maxResults: 1 }) + + expect(files).toHaveLength(1) + expect(['src/a.ts', 'src/b.ts']).toContain(files[0]) + }) +}) diff --git a/src/relay/fs-handler-readdir-fallback.ts b/src/relay/fs-handler-readdir-fallback.ts index d7c9172183b3..25006788f1b9 100644 --- a/src/relay/fs-handler-readdir-fallback.ts +++ b/src/relay/fs-handler-readdir-fallback.ts @@ -24,11 +24,12 @@ import { export async function listFilesWithReaddir( rootPath: string, excludePathPrefixes: readonly string[] = [], - options: { signal?: AbortSignal } = {} + options: { signal?: AbortSignal; maxResults?: number } = {} ): Promise { return listQuickOpenFilesWithReaddir(rootPath, { excludePathPrefixes, budget: createQuickOpenReaddirBudget(), + maxResults: options.maxResults, signal: options.signal }) } diff --git a/src/relay/fs-handler.ts b/src/relay/fs-handler.ts index baea79a88c84..9c146af14505 100644 --- a/src/relay/fs-handler.ts +++ b/src/relay/fs-handler.ts @@ -356,6 +356,12 @@ export class FsHandler { private listFiles(params: Record, context?: RequestContext): Promise { const rootPath = expandTilde(params.rootPath as string) + const maxResults = + typeof params.maxResults === 'number' && + Number.isInteger(params.maxResults) && + params.maxResults > 0 + ? Math.min(params.maxResults, 20_001) + : undefined // Why: the main-to-relay RPC adds excludePaths so nested linked worktrees // don't get double-scanned. The shared helper validates the shape and // normalizes into root-relative prefixes; malformed input yields [] so @@ -366,21 +372,22 @@ export class FsHandler { // aborting a stale scan when the workspace changes or the host cancels. return this.listFilesScans.run({ clientId: context?.clientId ?? 0, - key: JSON.stringify([rootPath, excludePathPrefixes]), + key: JSON.stringify([rootPath, excludePathPrefixes, maxResults]), signal: context?.signal, - start: (signal) => this.runListFilesScan(rootPath, excludePathPrefixes, signal) + start: (signal) => this.runListFilesScan(rootPath, excludePathPrefixes, signal, maxResults) }) } private async runListFilesScan( rootPath: string, excludePathPrefixes: string[], - signal: AbortSignal + signal: AbortSignal, + maxResults?: number ): Promise { const rgAvailable = await checkRgAvailable() throwIfFileListingCancelled(signal) if (rgAvailable) { - return listFilesWithRg(rootPath, excludePathPrefixes, { signal }) + return listFilesWithRg(rootPath, excludePathPrefixes, { signal, maxResults }) } // Why: git ls-files only works inside git repos. Use rev-parse to detect // git ancestry — unlike checking for a local .git entry, this works from @@ -401,7 +408,7 @@ export class FsHandler { // budget errors into install-rg guidance; genuine git failures keep // their own messages. try { - return await listFilesWithGit(rootPath, excludePathPrefixes, { signal }) + return await listFilesWithGit(rootPath, excludePathPrefixes, { signal, maxResults }) } catch (err) { if (isQuickOpenReaddirBudgetError(err)) { throw new Error(await buildInstallRgMessage(err)) @@ -415,7 +422,7 @@ export class FsHandler { // problem, so translate the opaque cap error into actionable guidance // the user can act on directly from the error toast. try { - return await listFilesWithReaddir(rootPath, excludePathPrefixes, { signal }) + return await listFilesWithReaddir(rootPath, excludePathPrefixes, { signal, maxResults }) } catch (err) { // Why: a cancelled scan is not an rg-availability problem; wrapping it // in install-rg guidance would surface bogus advice on the client. diff --git a/src/renderer/src/components/native-chat/NativeChatEmptyState.tsx b/src/renderer/src/components/native-chat/NativeChatEmptyState.tsx index 8ddb4a46c9b2..f7c20ef51f45 100644 --- a/src/renderer/src/components/native-chat/NativeChatEmptyState.tsx +++ b/src/renderer/src/components/native-chat/NativeChatEmptyState.tsx @@ -1,6 +1,7 @@ import { MessageSquare, TriangleAlert } from 'lucide-react' import { translate } from '@/i18n/i18n' import { formatAgentTypeLabel } from '@/lib/agent-status' +import { NATIVE_CHAT_EMPTY_STATE_COPY } from '../../../../shared/native-chat-empty-state' import type { NativeChatSession } from '../../../../shared/native-chat-types' export function NativeChatEmptyState({ @@ -44,28 +45,37 @@ function emptyStateCopy( switch (kind) { case 'loading': return { - title: translate('components.native-chat.state.loading.title', 'Loading conversation…'), + title: translate( + 'components.native-chat.state.loading.title', + NATIVE_CHAT_EMPTY_STATE_COPY.loading.title + ), subtitle: translate( 'components.native-chat.state.loading.subtitle', - 'Reading the agent transcript.' + NATIVE_CHAT_EMPTY_STATE_COPY.loading.subtitle ) } case 'error': return { - title: translate('components.native-chat.state.error.title', 'Could not load conversation'), + title: translate( + 'components.native-chat.state.error.title', + NATIVE_CHAT_EMPTY_STATE_COPY.error.title + ), subtitle: message ?? translate( 'components.native-chat.state.error.subtitle', - 'The transcript could not be read. Toggle back to the terminal to keep working.' + NATIVE_CHAT_EMPTY_STATE_COPY.error.subtitle ) } case 'not-agent': return { - title: translate('components.native-chat.state.notAgent.title', 'No conversation here'), + title: translate( + 'components.native-chat.state.notAgent.title', + NATIVE_CHAT_EMPTY_STATE_COPY.notAgent.title + ), subtitle: translate( 'components.native-chat.state.notAgent.subtitle', - 'This terminal is not running a recognized coding agent.' + NATIVE_CHAT_EMPTY_STATE_COPY.notAgent.subtitle ) } case 'empty': { @@ -73,12 +83,12 @@ function emptyStateCopy( return { title: translate( 'components.native-chat.state.empty.title', - 'Start a chat with {{value0}}', + NATIVE_CHAT_EMPTY_STATE_COPY.empty.title, { value0: agentName } ), subtitle: translate( 'components.native-chat.state.empty.subtitle', - 'Ask {{value0}} to inspect code, explain output, or make a change.', + NATIVE_CHAT_EMPTY_STATE_COPY.empty.subtitle, { value0: agentName } ) } diff --git a/src/renderer/src/components/native-chat/native-chat-diff.ts b/src/renderer/src/components/native-chat/native-chat-diff.ts index 0b0eb791490d..fd9bba9a5c65 100644 --- a/src/renderer/src/components/native-chat/native-chat-diff.ts +++ b/src/renderer/src/components/native-chat/native-chat-diff.ts @@ -1,70 +1,6 @@ -// Why: agent edits show up as tool calls (Edit/Write with old/new strings) and -// tool results that contain unified-diff text. The chat renders these as inline -// coloured diffs like the terminal, so detection/parsing is pure and testable. -// Ported from the mobile diffFromToolCall/diffFromText (desktop parity). - -export type DiffLineKind = 'add' | 'del' | 'context' | 'meta' - -export type DiffLine = { - kind: DiffLineKind - text: string -} - -const EDIT_TOOL_NAMES = new Set(['Edit', 'MultiEdit', 'Write', 'str_replace', 'apply_patch']) - -function toLines(value: unknown): string[] { - return typeof value === 'string' ? value.replace(/\n$/, '').split('\n') : [] -} - -/** Build diff lines from an Edit-style tool call input (old_string/new_string), - * or null when the input isn't an editing payload. Old lines render as deletes, - * new lines as adds — a simple, readable before/after rather than a full LCS. */ -export function diffFromToolCall(name: string, input: unknown): DiffLine[] | null { - if (!EDIT_TOOL_NAMES.has(name) || typeof input !== 'object' || input === null) { - return null - } - const obj = input as Record - const oldText = obj.old_string ?? obj.oldString ?? obj.old - const newText = obj.new_string ?? obj.newString ?? obj.new ?? obj.content ?? obj.file_text - const dels = toLines(oldText).map((text): DiffLine => ({ kind: 'del', text })) - const adds = toLines(newText).map((text): DiffLine => ({ kind: 'add', text })) - if (dels.length === 0 && adds.length === 0) { - return null - } - const lines: DiffLine[] = [] - if (typeof obj.file_path === 'string' || typeof obj.path === 'string') { - lines.push({ kind: 'meta', text: String(obj.file_path ?? obj.path) }) - } - return [...lines, ...dels, ...adds] -} - -/** Parse unified-diff-looking text into coloured lines, or null when the text - * doesn't read as a diff (no +/- lines). */ -export function diffFromText(text: string): DiffLine[] | null { - if (typeof text !== 'string' || text.length === 0) { - return null - } - const raw = text.split('\n') - let added = 0 - let removed = 0 - const lines: DiffLine[] = raw.map((line): DiffLine => { - if (line.startsWith('@@') || line.startsWith('diff ') || line.startsWith('index ')) { - return { kind: 'meta', text: line } - } - if (line.startsWith('+') && !line.startsWith('+++')) { - added++ - return { kind: 'add', text: line.slice(1) } - } - if (line.startsWith('-') && !line.startsWith('---')) { - removed++ - return { kind: 'del', text: line.slice(1) } - } - return { kind: 'context', text: line } - }) - // Require a meaningful amount of diff signal so ordinary prose isn't mistaken - // for a diff (a stray leading '-' bullet shouldn't trigger diff rendering). - if (added + removed < 2) { - return null - } - return lines -} +export { + diffFromText, + diffFromToolCall, + type NativeChatDiffLine as DiffLine, + type NativeChatDiffLineKind as DiffLineKind +} from '../../../../shared/native-chat-diff' diff --git a/src/renderer/src/components/native-chat/native-chat-interactive-prompt.ts b/src/renderer/src/components/native-chat/native-chat-interactive-prompt.ts index 8a995a7580db..f8da57021909 100644 --- a/src/renderer/src/components/native-chat/native-chat-interactive-prompt.ts +++ b/src/renderer/src/components/native-chat/native-chat-interactive-prompt.ts @@ -1,141 +1,46 @@ -// Pure parser for the live `agentStatus.interactivePrompt` envelope (JSON the -// host captures from the agent's hook). It resolves to either a structured -// question prompt (AskUserQuestion) or a tool-approval (PermissionRequest), the -// two interactive cards the native chat renders just above the composer. Kept -// pure (no React/IO) so the envelope rules are unit-testable. -// -// Why: the Ask question parser (parseQuestionsShape/parseOptions/the -// QUESTION_TOOL_PARSERS registry/parseToolInput/formatAskAnswer) is a byte-for-byte -// mirror of mobile's `mobile-native-chat-ask.ts` — Metro can't import these runtime -// values from src/shared, so both copies must stay in sync; parity is asserted by -// `src/shared/native-chat-ask-parser-parity.test.ts`. The approval-card logic below -// (ChatApproval/parseApprovalFromStatus/ESCAPE) is desktop-only. - -import type { - AskOption, - AskPrompt, - AskQuestion, - InteractiveQuestionParser -} from '../../../../shared/native-chat-ask-types' import { translate } from '@/i18n/i18n' +import { + buildAskAnswerKeys, + formatAskAnswer, + hasAskAnswer, + parseAskFromStatus, + registerQuestionTool, + type AskAnswerKeyGroup, + type AskAnswerSelection, + type AskOption, + type AskPrompt, + type AskQuestion, + type InteractiveQuestionParser +} from '../../../../shared/native-chat-ask' + +export { + buildAskAnswerKeys, + formatAskAnswer, + hasAskAnswer, + parseAskFromStatus, + registerQuestionTool, + type AskAnswerKeyGroup, + type AskAnswerSelection, + type AskOption, + type AskPrompt, + type AskQuestion, + type InteractiveQuestionParser +} -export type { AskOption, AskPrompt, AskQuestion, InteractiveQuestionParser } - -/** A detected tool-approval, rendered as an Allow/Deny card. Each option's - * `send` is the literal string written back to the agent's PTY when chosen - * (a number to allow; the ESC char to deny). */ export type ChatApproval = { title: string detail?: string options: { label: string; send: string }[] } -/** The interactive card to render for the current live status, or null. A - * question takes precedence over an approval when both somehow parse. */ export type InteractivePromptCard = | { kind: 'question'; prompt: AskPrompt } | { kind: 'approval'; approval: ChatApproval } | null -// ESC interrupts the agent over the PTY (matches how the composer forwards -// Escape), so "Cancel"/"Deny" sends this byte. const ESCAPE = String.fromCharCode(27) -// Registry of question-tool parsers keyed by the tool name the agent reports. -// To support a new terminal/agent's question tool, register its parser here (or -// via registerQuestionTool) — the renderer and wiring stay unchanged. -const QUESTION_TOOL_PARSERS = new Map() - -export function registerQuestionTool(toolName: string, parser: InteractiveQuestionParser): void { - QUESTION_TOOL_PARSERS.set(toolName, parser) -} - -/** Claude's AskUserQuestion shape: `{ questions: [{ question, header, - * multiSelect, options: [{ label, description }] }] }`. Also the de-facto - * default shape, so a new agent that reuses it works without registration. */ -function parseQuestionsShape(input: unknown): AskPrompt | null { - if (!input || typeof input !== 'object') { - return null - } - const rawQuestions = (input as { questions?: unknown }).questions - if (!Array.isArray(rawQuestions) || rawQuestions.length === 0) { - return null - } - const questions: AskQuestion[] = [] - for (const raw of rawQuestions) { - if (!raw || typeof raw !== 'object') { - continue - } - const q = raw as Record - const question = typeof q.question === 'string' ? q.question : '' - const options = parseOptions(q.options) - if (question || options.length > 0) { - questions.push({ - question, - header: typeof q.header === 'string' ? q.header : undefined, - multiSelect: q.multiSelect === true, - options - }) - } - } - return questions.length > 0 ? { questions } : null -} - -function parseOptions(raw: unknown): AskOption[] { - if (!Array.isArray(raw)) { - return [] - } - return raw - .map((o): AskOption | null => { - if (typeof o === 'string') { - return { label: o } - } - if (o && typeof o === 'object' && typeof (o as { label?: unknown }).label === 'string') { - const obj = o as { label: string; description?: unknown } - return { - label: obj.label, - description: typeof obj.description === 'string' ? obj.description : undefined - } - } - return null - }) - .filter((o): o is AskOption => o !== null) -} - -// Claude's AskUserQuestion (and aliases) ship the canonical questions shape. -for (const name of ['AskUserQuestion', 'ask_user_question', 'askUserQuestion']) { - QUESTION_TOOL_PARSERS.set(name, parseQuestionsShape) -} - -/** Resolve an interactive-prompt payload to an AskPrompt: try the tool's - * registered parser first, then fall back to the canonical questions shape so a - * new agent that happens to use the same structure works without registration. */ -function parseToolInput(toolName: string | undefined, input: unknown): AskPrompt | null { - const parser = toolName ? QUESTION_TOOL_PARSERS.get(toolName) : undefined - return (parser ? parser(input) : null) ?? parseQuestionsShape(input) -} - -/** Parse the live `agentStatus.interactivePrompt` (the agent's untruncated - * question-tool input as JSON) into an AskPrompt, or null. Dispatches through - * the tool's registered parser (keyed by `toolName`) with the canonical - * questions shape as the fallback. */ -export function parseAskFromStatus( - interactivePrompt: string | undefined | null, - toolName?: string -): AskPrompt | null { - if (!interactivePrompt) { - return null - } - try { - return parseToolInput(toolName, JSON.parse(interactivePrompt)) - } catch { - return null - } -} - -/** Parse the `{ approval: { tool, summary } }` envelope (emitted by the host on - * a PermissionRequest) into an Allow/Deny card, or null. Allow sends "1"; Deny - * sends ESC — matching the common TUI approval prompt. */ +/** Parse the desktop-only approval envelope; question parsing stays cross-platform. */ export function parseApprovalFromStatus( interactivePrompt: string | undefined | null ): ChatApproval | null { @@ -172,9 +77,6 @@ export function parseApprovalFromStatus( } } -/** Resolve the live `interactivePrompt` to the single card to render. A question - * takes precedence over an approval. `toolName` is forwarded to the Ask branch - * for registry dispatch; the approval branch ignores it. */ export function parseInteractivePrompt( interactivePrompt: string | undefined | null, toolName?: string @@ -184,118 +86,5 @@ export function parseInteractivePrompt( return { kind: 'question', prompt } } const approval = parseApprovalFromStatus(interactivePrompt) - if (approval) { - return { kind: 'approval', approval } - } - return null -} - -/** One question's chosen answer, normalized for delivery: the selected option - * indices (in option order) plus any free-text "other" answer. Index-based (not - * label text) so the answer can be delivered by the selector's stable option - * number — see `buildAskAnswerKeys`. */ -export type AskAnswerSelection = { indices: number[]; other?: string } - -/** A single keystroke group to write to the agent PTY. `raw` bytes (option - * numbers, Enter, arrows) are written verbatim as keystrokes; `text` is a - * free-text answer the caller runs through its paste sanitizer before writing. */ -export type AskAnswerKeyGroup = { raw: string } | { text: string } - -/** True when this question is answered (a picked option or typed free text). */ -function isAnswered(sel: AskAnswerSelection | undefined): boolean { - return (sel?.indices.length ?? 0) > 0 || (sel?.other ?? '').trim().length > 0 -} - -/** The picked labels + trimmed free text for one question, in option order. */ -function answerLabels(question: AskQuestion, sel: AskAnswerSelection | undefined): string[] { - const labels = (sel?.indices ?? []) - .map((i) => question.options[i]?.label ?? '') - .filter((l) => l.length > 0) - const other = (sel?.other ?? '').trim() - return other ? [...labels, other] : labels -} - -/** Build the human-readable answer text: one line per question, in question - * order, each the selected label(s) + free text joined by ", ". Empty answers - * stay empty lines so N lines always == N questions. Used for agents whose - * question tool commits a pasted answer (not Claude's arrow-navigate selector). */ -export function formatAskAnswer(prompt: AskPrompt, selections: AskAnswerSelection[]): string { - return prompt.questions.map((q, i) => answerLabels(q, selections[i]).join(', ')).join('\n') -} - -// Claude's AskUserQuestion is an arrow-navigate selector: a bare Enter commits -// the HIGHLIGHTED default (the first option), and pasted label text does not move -// the highlight — so answering by label silently delivered every non-first pick -// as the first option (STA-1860). Instead we drive the selector by each option's -// stable 1-based number (which matches the card's badge), the marker it commits -// on. Right-arrow steps to the next question / the Submit tab. Verified live -// against Claude Code's TUI; groups are written spaced apart (see the desktop -// sender) because a navigation keystroke batched with Enter commits before the -// selector has applied it. -const ASK_ENTER = '\r' -const ASK_NEXT_TAB = '\x1b[C' - -/** Build the ordered keystroke groups that answer a Claude Code AskUserQuestion. - * Each group is written a step apart so the selector applies it before the next. - * - * - single-select pick → the option number (selects AND commits; in a - * multi-question prompt it auto-advances to the next question) - * - free-text answer → the "Type something" row number, the text, then Enter - * - multi-select → each option number TOGGLES its checkbox, then a step - * to the Submit tab - * - a multi-question prompt (and a lone multi-select) finishes on a Submit - * confirmation, so it ends with one Enter - * - * (Option counts are ≤ the tool's cap of a few, so single-digit numbers always - * address every row.) */ -export function buildAskAnswerKeys( - prompt: AskPrompt, - selections: AskAnswerSelection[] -): AskAnswerKeyGroup[] { - const questions = prompt.questions - const multiQuestion = questions.length > 1 - const groups: AskAnswerKeyGroup[] = [] - - questions.forEach((q, qi) => { - const sel = selections[qi] - const other = (sel?.other ?? '').trim() - const typeSomething = String(q.options.length + 1) - - if (q.multiSelect) { - for (const i of sel?.indices ?? []) { - groups.push({ raw: String(i + 1) }) - } - if (other) { - groups.push({ raw: typeSomething }, { text: other }, { raw: ASK_ENTER }) - } - // A multi-select never auto-advances; step to the next tab (the Submit tab - // when this is the last question). - groups.push({ raw: ASK_NEXT_TAB }) - } else if (other) { - // Single-select can only carry one value, so route any answer that - // includes free text through the "Type something" row as one string. - groups.push( - { raw: typeSomething }, - { text: answerLabels(q, sel).join(', ') }, - { raw: ASK_ENTER } - ) - } else if ((sel?.indices.length ?? 0) > 0) { - groups.push({ raw: String(sel!.indices[0]! + 1) }) - } else if (multiQuestion) { - // Unanswered question in a multi-question prompt: step past it. - groups.push({ raw: ASK_NEXT_TAB }) - } - }) - - const endsOnSubmitTab = - multiQuestion || (questions.length === 1 && questions[0]!.multiSelect === true) - if (endsOnSubmitTab && groups.length > 0) { - groups.push({ raw: ASK_ENTER }) - } - return groups -} - -/** Whether any question in `selections` carries an answer worth submitting. */ -export function hasAskAnswer(prompt: AskPrompt, selections: AskAnswerSelection[]): boolean { - return prompt.questions.some((_, i) => isAnswered(selections[i])) + return approval ? { kind: 'approval', approval } : null } diff --git a/src/renderer/src/components/native-chat/native-chat-live-status.ts b/src/renderer/src/components/native-chat/native-chat-live-status.ts index bd31627f8ce1..7936fd7e2310 100644 --- a/src/renderer/src/components/native-chat/native-chat-live-status.ts +++ b/src/renderer/src/components/native-chat/native-chat-live-status.ts @@ -17,9 +17,12 @@ export type NativeChatLiveMergeInput = { agent: AgentType /** Live hook state for the pane, or null when no hook entry exists. */ hookState: AgentStatusState | null - /** True before the initial readSession resolves; forces 'loading'. */ + /** Epoch ms when the current hook state began, or null when unknown. Lets a + * stale 'working' self-heal once this turn's own assistant reply has landed. */ + stateStartedAt?: number | null + /** True before the initial snapshot resolves; forces 'loading'. */ loading?: boolean - /** Set when the initial read failed; forces 'error'. */ + /** Set when the initial snapshot failed; forces 'error'. */ error?: string } @@ -30,13 +33,12 @@ export type NativeChatLiveMergeInput = { * * Precedence: * - error / loading overrides win outright. - * - hook 'working' shows a live working indicator BEFORE the assistant turn - * lands in the transcript. Once the transcript's last message is an - * assistant reply (the turn flushed), 'working' is no longer asserted and - * the derived 'ready' status from the assembler stands. + * - hook 'working' stays authoritative until the hook exits that state OR this + * turn's own assistant reply lands (a trailing reply newer than + * stateStartedAt); a trailing reply from an EARLIER turn does not suppress it. */ export function mergeNativeChatLiveSession(input: NativeChatLiveMergeInput): NativeChatSession { - const { sources, sessionId, agent, hookState, loading, error } = input + const { sources, sessionId, agent, hookState, stateStartedAt, loading, error } = input if (error) { return assembleNativeChatSession({ sources, sessionId, agent, status: 'error', error }) } @@ -44,7 +46,7 @@ export function mergeNativeChatLiveSession(input: NativeChatLiveMergeInput): Nat return assembleNativeChatSession({ sources, sessionId, agent, status: 'loading' }) } - const status = liveStatusOverride(sources, hookState) + const status = liveStatusOverride(hookState, sources, stateStartedAt) return assembleNativeChatSession({ sources, sessionId, @@ -54,24 +56,38 @@ export function mergeNativeChatLiveSession(input: NativeChatLiveMergeInput): Nat } function liveStatusOverride( + hookState: AgentStatusState | null, sources: NativeChatSources, - hookState: AgentStatusState | null + stateStartedAt: number | null | undefined ): NativeChatSessionStatus | undefined { // Only 'working' drives a live override; blocked/waiting/done leave the // derived (ready/empty) status alone so completed turns render normally. if (hookState !== 'working') { return undefined } - // If the transcript has already flushed the in-flight assistant reply, the - // turn is effectively visible — don't keep asserting 'working' on top of it. - if (lastMessageIsFreshAssistant(sources)) { + // Self-heal a stale 'working' (dropped/late Stop hook): if this turn's own + // assistant reply has already landed, the turn is effectively visible — stop + // asserting 'working'. A trailing reply from a PRIOR turn (older than the + // working turn's start) must not suppress it: the agent is working again. + if (trailingAssistantPostDates(sources, stateStartedAt)) { return undefined } return 'working' } -function lastMessageIsFreshAssistant(sources: NativeChatSources): boolean { - const transcript = sources.transcript ?? [] - const last = transcript.at(-1) - return last?.role === 'assistant' +/** True when the transcript's last message is an assistant reply that landed at + * or after `stateStartedAt`. Unknown timings (no start, no message timestamp) + * return false so the caller keeps 'working' — the safe, non-regressing default. */ +function trailingAssistantPostDates( + sources: NativeChatSources, + stateStartedAt: number | null | undefined +): boolean { + if (stateStartedAt == null) { + return false + } + const last = (sources.transcript ?? []).at(-1) + if (last?.role !== 'assistant' || last.timestamp == null) { + return false + } + return last.timestamp >= stateStartedAt } diff --git a/src/renderer/src/components/native-chat/native-chat-noise.ts b/src/renderer/src/components/native-chat/native-chat-noise.ts index 70e7a1d8cd32..6ef06b130a68 100644 --- a/src/renderer/src/components/native-chat/native-chat-noise.ts +++ b/src/renderer/src/components/native-chat/native-chat-noise.ts @@ -1,36 +1 @@ -import { isTextBlock, type NativeChatMessage } from '../../../../shared/native-chat-types' -import { isKnownHarnessInjectedUserTurnText } from '../../../../shared/harness-injected-user-turns' - -// Why: harness machinery turns land in the transcript but are not real user -// messages, so the chat filters them out (they were confusingly rendered as -// the user's own bubbles). The classifier lives in -// src/shared/harness-injected-user-turns.ts, shared with the agent-status -// prompt pipeline; structurally marked turns (isMeta etc.) are already -// dropped by the Claude transcript decoder. - -function messageText(message: NativeChatMessage): string { - return message.blocks - .filter(isTextBlock) - .map((b) => b.text) - .join('') - .trim() -} - -/** True when a message is harness machinery rather than real conversation. Only - * user/system turns qualify — assistant/tool turns and any turn carrying real - * tool activity are always kept. */ -export function isNoiseMessage(message: NativeChatMessage): boolean { - if (message.role !== 'user' && message.role !== 'system') { - return false - } - // Keep turns that carry tool activity (e.g. a user turn with tool results). - if (message.blocks.some((b) => b.type === 'tool-call' || b.type === 'tool-result')) { - return false - } - return isKnownHarnessInjectedUserTurnText(messageText(message)) -} - -/** Drop harness-noise messages from a transcript. */ -export function stripNoiseMessages(messages: readonly NativeChatMessage[]): NativeChatMessage[] { - return messages.filter((m) => !isNoiseMessage(m)) -} +export { isNoiseMessage, stripNoiseMessages } from '../../../../shared/native-chat-noise' diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts index fa07de934862..59ff443fd241 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts @@ -5,37 +5,21 @@ import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection' import type { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' import type { AskAnswerKeyGroup } from './native-chat-interactive-prompt' +import { + NATIVE_CHAT_ADVANCE_BUFFER_MS, + NATIVE_CHAT_QUESTION_STEP_MS, + NATIVE_CHAT_SUBMIT_DELAY_MS +} from '../../../../shared/native-chat-answer-stepping' import { buildNativeChatImagePasteBytes, buildNativeChatPasteBytes, NATIVE_CHAT_SUBMIT } from './native-chat-send' -// Why: agent TUIs swallow a `\r` bundled into the same pty write as a framed -// paste, so a one-shot send leaves the text sitting in the input box, unsent. -// Write the body first, then the Enter after a delay so the agent processes the -// paste before the submit. The gap must clear the agent's paste-handling latency -// even while it's BUSY (Codex): a short gap (60ms) fires Enter before a busy -// Codex has landed the paste into its input, so the submit hits an empty box and -// the message sits "Queued" forever. 500ms is orca-runtime's proven value in -// writeTerminalAction({enter:true}), so match it here. -export const NATIVE_CHAT_SUBMIT_DELAY_MS = 500 -export const NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS = 300 +export { NATIVE_CHAT_ADVANCE_BUFFER_MS, NATIVE_CHAT_QUESTION_STEP_MS, NATIVE_CHAT_SUBMIT_DELAY_MS } -// Why: answering Claude's AskUserQuestion is a MULTI-STEP keystroke sequence -// (select an option, advance to the next question, land on Submit). Each step -// re-renders the selector, and a keystroke sent before the next step has -// rendered — or an Enter batched with the navigation before it — lands on the -// wrong (or no) row. This buffer pads the between-keystroke gap so each step -// renders first; kept generous so it still holds on slower machines / under SSH -// round-trip latency, where a tighter cadence misfires the answer. -export const NATIVE_CHAT_ADVANCE_BUFFER_MS = 500 +export const NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS = 300 -/** Wall-clock gap between successive AskUserQuestion keystroke groups: the proven - * pty submit gap plus the advance buffer that lets each selector step render - * before the next keystroke is written. */ -export const NATIVE_CHAT_QUESTION_STEP_MS = - NATIVE_CHAT_SUBMIT_DELAY_MS + NATIVE_CHAT_ADVANCE_BUFFER_MS /** Cancels an in-flight send's pending pty writes (the delayed Enter, and any * later question bodies/Enters). Safe to call after the send completes. */ diff --git a/src/renderer/src/components/native-chat/native-chat-session-transport.test.ts b/src/renderer/src/components/native-chat/native-chat-session-transport.test.ts index 324cdd482487..849897c386b6 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-transport.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-transport.test.ts @@ -119,22 +119,81 @@ describe('runtime subscribe', () => { } } - it('forwards only appended frames with an array of messages', async () => { + it('normalizes the first frame to a snapshot and reconnect replays to appends', async () => { markRuntimeEnvironmentCompatible(ENV) const { deliver } = stubSubscribe() - const onAppended = vi.fn() + const onFrame = vi.fn() const transport = getNativeChatSessionTransport(ENV) - transport.subscribe({ subscriptionId: 's-1', agent: 'claude', sessionId: 'sess-1' }, onAppended) + transport.subscribe({ subscriptionId: 's-1', agent: 'claude', sessionId: 'sess-1' }, onFrame) await Promise.resolve() deliver({ type: 'appended', messages: [message('m-1')] }) + deliver({ type: 'snapshot', messages: [message('m-snapshot')] }) + deliver({ type: 'replacement', messages: [message('m-replacement')], hasMore: true }) deliver({ type: 'ready' }) // non-appended, ignored deliver({ type: 'appended', messages: undefined }) // no array, ignored deliver({ type: 'appended', messages: [message('m-2')] }, false) // !ok, ignored - expect(onAppended).toHaveBeenCalledTimes(1) - expect(onAppended).toHaveBeenCalledWith([message('m-1')]) + expect(onFrame).toHaveBeenCalledTimes(3) + expect(onFrame).toHaveBeenNthCalledWith(1, { + type: 'snapshot', + messages: [message('m-1')], + hasMore: false + }) + expect(onFrame).toHaveBeenNthCalledWith(2, { + type: 'snapshot', + messages: [message('m-snapshot')], + hasMore: false + }) + expect(onFrame).toHaveBeenNthCalledWith(3, { + type: 'replacement', + messages: [message('m-replacement')], + hasMore: true + }) + }) + + it('settles with an empty snapshot when the first ok frame is an unrecognized shape', async () => { + markRuntimeEnvironmentCompatible(ENV) + const { deliver } = stubSubscribe() + const onFrame = vi.fn() + const transport = getNativeChatSessionTransport(ENV) + + transport.subscribe({ subscriptionId: 's-1', agent: 'claude', sessionId: 'sess-1' }, onFrame) + await Promise.resolve() + + // An ok response we can't parse must still un-stick the view exactly once, + // carrying any error the runtime sent. + deliver({ type: 'ready', error: 'boom' }) + deliver({ type: 'ready' }) + + expect(onFrame).toHaveBeenCalledTimes(1) + expect(onFrame).toHaveBeenCalledWith({ + type: 'snapshot', + messages: [], + hasMore: false, + error: 'boom' + }) + }) + + it('carries an error on a post-initial reconnect snapshot', async () => { + markRuntimeEnvironmentCompatible(ENV) + const { deliver } = stubSubscribe() + const onFrame = vi.fn() + const transport = getNativeChatSessionTransport(ENV) + + transport.subscribe({ subscriptionId: 's-1', agent: 'claude', sessionId: 'sess-1' }, onFrame) + await Promise.resolve() + + deliver({ type: 'snapshot', messages: [message('m-1')] }) + deliver({ type: 'snapshot', messages: [], error: 'runtime too old' }) + + expect(onFrame).toHaveBeenLastCalledWith({ + type: 'snapshot', + messages: [], + hasMore: false, + error: 'runtime too old' + }) }) it('sync unsubscribe closes the dedicated stream and issues no unsubscribe RPC (KTD-6)', async () => { @@ -186,6 +245,115 @@ describe('runtime subscribe', () => { } }) + it('keeps retrying when a reconnect subscribe rejects', async () => { + vi.useFakeTimers() + try { + markRuntimeEnvironmentCompatible(ENV) + const attempts: { + callbacks: { + onResponse: (response: { ok: boolean; result?: unknown }) => void + onClose?: () => void + } + resolve: (handle: { unsubscribe: () => void; sendBinary: () => void }) => void + reject: (error: Error) => void + }[] = [] + runtimeEnvironmentsSubscribe.mockImplementation((_args, callbacks) => { + return new Promise((resolve, reject) => attempts.push({ callbacks, resolve, reject })) + }) + const transport = getNativeChatSessionTransport(ENV) + + transport.subscribe({ subscriptionId: 's-1', agent: 'claude', sessionId: 'sess-1' }, vi.fn()) + attempts[0]!.resolve({ unsubscribe: vi.fn(), sendBinary: vi.fn() }) + attempts[0]!.callbacks.onResponse({ + ok: true, + result: { type: 'snapshot', messages: [], hasMore: false } + }) + attempts[0]!.callbacks.onClose?.() + await vi.advanceTimersByTimeAsync(RUNTIME_NATIVE_CHAT_RECONNECT_MS) + expect(attempts).toHaveLength(2) + + attempts[1]!.reject(new Error('reconnect failed')) + await vi.advanceTimersByTimeAsync(RUNTIME_NATIVE_CHAT_RECONNECT_MS) + + expect(attempts).toHaveLength(3) + } finally { + vi.useRealTimers() + } + }) + + it('closes and retries when an established reconnect returns an error envelope', async () => { + vi.useFakeTimers() + try { + markRuntimeEnvironmentCompatible(ENV) + const attempts: { + callbacks: { + onResponse: (response: { ok: boolean; result?: unknown }) => void + onClose?: () => void + } + unsubscribe: ReturnType + }[] = [] + runtimeEnvironmentsSubscribe.mockImplementation((_args, callbacks) => { + const unsubscribe = vi.fn() + attempts.push({ callbacks, unsubscribe }) + return Promise.resolve({ unsubscribe, sendBinary: vi.fn() }) + }) + const transport = getNativeChatSessionTransport(ENV) + + transport.subscribe({ subscriptionId: 's-1', agent: 'claude', sessionId: 'sess-1' }, vi.fn()) + await vi.advanceTimersByTimeAsync(0) + attempts[0]!.callbacks.onResponse({ + ok: true, + result: { type: 'snapshot', messages: [], hasMore: false } + }) + attempts[0]!.callbacks.onClose?.() + await vi.advanceTimersByTimeAsync(RUNTIME_NATIVE_CHAT_RECONNECT_MS) + expect(attempts).toHaveLength(2) + + attempts[1]!.callbacks.onResponse({ ok: false }) + attempts[1]!.callbacks.onClose?.() + expect(attempts[1]!.unsubscribe).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(RUNTIME_NATIVE_CHAT_RECONNECT_MS) + + expect(attempts).toHaveLength(3) + } finally { + vi.useRealTimers() + } + }) + + it('closes an out-of-order stale handle without replacing the active reconnect', async () => { + vi.useFakeTimers() + try { + markRuntimeEnvironmentCompatible(ENV) + const attempts: { + callbacks: { onClose?: () => void } + resolve: (handle: { unsubscribe: () => void; sendBinary: () => void }) => void + }[] = [] + runtimeEnvironmentsSubscribe.mockImplementation((_args, callbacks) => { + return new Promise((resolve) => attempts.push({ callbacks, resolve })) + }) + const transport = getNativeChatSessionTransport(ENV) + const stop = transport.subscribe( + { subscriptionId: 's-1', agent: 'claude', sessionId: 'sess-1' }, + vi.fn() + ) + + attempts[0]!.callbacks.onClose?.() + await vi.advanceTimersByTimeAsync(RUNTIME_NATIVE_CHAT_RECONNECT_MS) + const activeUnsubscribe = vi.fn() + attempts[1]!.resolve({ unsubscribe: activeUnsubscribe, sendBinary: vi.fn() }) + await Promise.resolve() + const staleUnsubscribe = vi.fn() + attempts[0]!.resolve({ unsubscribe: staleUnsubscribe, sendBinary: vi.fn() }) + await Promise.resolve() + stop() + + expect(staleUnsubscribe).toHaveBeenCalledOnce() + expect(activeUnsubscribe).toHaveBeenCalledOnce() + } finally { + vi.useRealTimers() + } + }) + it('closes a stream that resolves after unsubscribe (cancel race)', async () => { markRuntimeEnvironmentCompatible(ENV) const unsubscribe = vi.fn() diff --git a/src/renderer/src/components/native-chat/native-chat-session-transport.ts b/src/renderer/src/components/native-chat/native-chat-session-transport.ts index 2851a4d84e47..a57d255b6ca8 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-transport.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-transport.ts @@ -44,7 +44,7 @@ export function toRuntimeNativeChatErrorMessage(err: unknown): string { const localNativeChatTransport: NativeChatSessionTransport = { readSession: (agent, sessionId, limit, transcriptPath) => window.api.nativeChat.readSession(agent, sessionId, limit, transcriptPath), - subscribe: (args, onAppended) => window.api.nativeChat.subscribe(args, onAppended) + subscribe: (args, onFrame) => window.api.nativeChat.subscribe(args, onFrame) } function createRuntimeNativeChatTransport(environmentId: string): NativeChatSessionTransport { @@ -63,25 +63,33 @@ function createRuntimeNativeChatTransport(environmentId: string): NativeChatSess return { error: toRuntimeNativeChatErrorMessage(err) } } }, - subscribe: (args, onAppended) => { - const { subscriptionId, agent, sessionId, transcriptPath } = args + subscribe: (args, onFrame) => { + const { subscriptionId, agent, sessionId, transcriptPath, limit } = args let cancelled = false + let receivedInitial = false let handleUnsubscribe: (() => void) | null = null let reconnectTimer: ReturnType | null = null + let activeAttempt = 0 + let reconnectPendingAttempt: number | null = null // A mid-stream drop (runtime restart, network blip, relay reconnect) closes // the dedicated stream socket. Re-open it after a short backoff so the live // tail resumes without a manual chat toggle; the fresh drain re-emits the // windowed tail, which merges by id so no turn is duplicated. An initial // connect failure lands in `.catch` instead (and is surfaced through the - // parallel readSession error), so a too-old/absent runtime never spins here. - const scheduleReconnect = (): void => { + // initial error frame), so a too-old/absent runtime never spins here. + const scheduleReconnect = (attempt: number): void => { + if (attempt !== activeAttempt) { + return + } handleUnsubscribe = null + reconnectPendingAttempt = attempt if (cancelled || reconnectTimer) { return } reconnectTimer = setTimeout(() => { reconnectTimer = null + reconnectPendingAttempt = null if (!cancelled) { openStream() } @@ -89,48 +97,121 @@ function createRuntimeNativeChatTransport(environmentId: string): NativeChatSess } const openStream = (): void => { + const attempt = ++activeAttempt void window.api.runtimeEnvironments .subscribe( { selector: environmentId, method: 'nativeChat.subscribe', - params: { subscriptionId, agent, sessionId, transcriptPath }, + params: { subscriptionId, agent, sessionId, transcriptPath, limit }, timeoutMs: 15_000 }, { onResponse: (response) => { - if (cancelled) { + if (cancelled || attempt !== activeAttempt) { return } if (response.ok === false) { + if (!receivedInitial) { + receivedInitial = true + onFrame({ + type: 'snapshot', + messages: [], + hasMore: false, + error: toRuntimeNativeChatErrorMessage(new RuntimeRpcCallError(response)) + }) + } else { + handleUnsubscribe?.() + scheduleReconnect(attempt) + } return } const frame = response.result as { type?: string messages?: NativeChatAppendedMessages + hasMore?: boolean + error?: string } - if (frame?.type === 'appended' && Array.isArray(frame.messages)) { - onAppended(frame.messages) + if ( + (frame?.type === 'appended' || + frame?.type === 'snapshot' || + frame?.type === 'replacement') && + Array.isArray(frame.messages) + ) { + if (!receivedInitial) { + receivedInitial = true + onFrame({ + type: 'snapshot', + messages: frame.messages, + hasMore: frame.hasMore ?? frame.messages.length >= (limit ?? 300), + ...(frame.error ? { error: frame.error } : {}) + }) + } else if (frame.type === 'snapshot') { + onFrame({ + type: 'snapshot', + messages: frame.messages, + hasMore: frame.hasMore ?? false, + ...(frame.error ? { error: frame.error } : {}) + }) + } else { + onFrame( + frame.type === 'replacement' + ? { + type: 'replacement', + messages: frame.messages, + hasMore: frame.hasMore ?? false + } + : { type: 'appended', messages: frame.messages } + ) + } + } else if (!receivedInitial) { + // Why: an ok response whose payload shape we don't recognize + // would otherwise never flip receivedInitial, stranding the view + // on 'loading'. Settle it with an empty snapshot (carrying any + // error the runtime sent) so the UI resolves. + receivedInitial = true + onFrame({ + type: 'snapshot', + messages: [], + hasMore: false, + ...(frame?.error ? { error: frame.error } : {}) + }) } }, // Established-then-dropped: resume the tail. onClose also fires on our // own teardown, but `cancelled` short-circuits the reconnect there. - onError: scheduleReconnect, - onClose: scheduleReconnect + onError: () => scheduleReconnect(attempt), + onClose: () => scheduleReconnect(attempt) } ) .then((handle) => { - handleUnsubscribe = handle.unsubscribe - // The stream resolved after teardown already ran — close it now so the - // late-arriving handle doesn't leak (same race as runtime-file-client). - if (cancelled) { + // Why: reconnect attempts may resolve out of order. A stale handle + // must never replace or tear down the current stream. + if (cancelled || attempt !== activeAttempt || reconnectPendingAttempt === attempt) { handle.unsubscribe() - handleUnsubscribe = null + return } + handleUnsubscribe = handle.unsubscribe }) - .catch(() => { + .catch((err: unknown) => { + if (cancelled || attempt !== activeAttempt) { + return + } // Initial subscribe failed (e.g. a too-old runtime lacking the method). - // The parallel readSession surfaces the error; don't reconnect-spin. + // Surface the same compatibility-specific copy as a direct read. + if (!receivedInitial) { + receivedInitial = true + onFrame({ + type: 'snapshot', + messages: [], + hasMore: false, + error: toRuntimeNativeChatErrorMessage(err) + }) + return + } + // Why: a failed reconnect is still a transient dropped-stream state; + // keep retrying after the backoff instead of stranding a live view. + scheduleReconnect(attempt) }) } @@ -147,6 +228,7 @@ function createRuntimeNativeChatTransport(environmentId: string): NativeChatSess clearTimeout(reconnectTimer) reconnectTimer = null } + reconnectPendingAttempt = null handleUnsubscribe?.() handleUnsubscribe = null } diff --git a/src/renderer/src/components/native-chat/native-chat-tool-fold.ts b/src/renderer/src/components/native-chat/native-chat-tool-fold.ts index f49370f415b8..e18bf8f47644 100644 --- a/src/renderer/src/components/native-chat/native-chat-tool-fold.ts +++ b/src/renderer/src/components/native-chat/native-chat-tool-fold.ts @@ -1,58 +1,6 @@ -// Pure folding logic for the native chat tool runs. Claude emits each tool call -// as its own assistant message and each result as a tool-role message; folding -// every tool-only message into the preceding assistant turn lets the view -// collapse a whole turn's tool activity under one "N tool calls" line. Kept out -// of the .tsx so the merge/split rules are unit-testable without rendering. -// Ported from the mobile foldToolMessages/splitNativeChatBlocks (mobile parity). - -import { - isToolCallBlock, - isToolResultBlock, - type NativeChatBlock, - type NativeChatMessage -} from '../../../../shared/native-chat-types' - -/** True when a message carries nothing but tool calls/results — the shape Claude - * emits for the tool half of a turn. */ -function isToolOnlyMessage(message: NativeChatMessage): boolean { - return ( - message.blocks.length > 0 && - message.blocks.every((block) => isToolCallBlock(block) || isToolResultBlock(block)) - ) -} - -/** Fold a turn's tool activity into the assistant message it belongs to, so the - * view can collapse a whole turn's tools under one line. A tool-only message is - * merged into the preceding assistant turn when one exists; otherwise it stands - * on its own (e.g. an orphan result with no preceding assistant prose). */ -export function foldToolMessages(messages: readonly NativeChatMessage[]): NativeChatMessage[] { - const out: NativeChatMessage[] = [] - for (const message of messages) { - const prev = out.at(-1) - if (isToolOnlyMessage(message) && prev && prev.role === 'assistant') { - out[out.length - 1] = { ...prev, blocks: [...prev.blocks, ...message.blocks] } - } else { - out.push(message) - } - } - return out -} - -/** Split a message's blocks into prose (text/image) and tool (call/result), so - * the view renders the agent's words first and folds the tool activity into a - * separate collapsible run beneath it. */ -export function splitNativeChatBlocks(blocks: readonly NativeChatBlock[]): { - prose: NativeChatBlock[] - tools: NativeChatBlock[] -} { - const prose: NativeChatBlock[] = [] - const tools: NativeChatBlock[] = [] - for (const block of blocks) { - if (isToolCallBlock(block) || isToolResultBlock(block)) { - tools.push(block) - } else { - prose.push(block) - } - } - return { prose, tools } -} +export { + foldToolMessages, + pairToolBlocks, + splitNativeChatBlocks, + type NativeChatToolPair +} from '../../../../shared/native-chat-tool-fold' diff --git a/src/renderer/src/components/native-chat/native-chat-tool-summary.ts b/src/renderer/src/components/native-chat/native-chat-tool-summary.ts index d79ab1f43c97..2122c1fb7da9 100644 --- a/src/renderer/src/components/native-chat/native-chat-tool-summary.ts +++ b/src/renderer/src/components/native-chat/native-chat-tool-summary.ts @@ -1,101 +1,8 @@ -// Why: tool-call inputs are arbitrary JSON; the chat row only shows a one-line -// preview so a long file body or diff doesn't dominate the conversation. Kept -// pure so the truncation/serialization rules are unit-testable. Ported from the -// mobile summarizeToolInput/summarizeToolRun (desktop parity). - -import { basename } from '../../lib/path' -import { isToolCallBlock, type NativeChatBlock } from '../../../../shared/native-chat-types' - -const MAX_PREVIEW_LENGTH = 80 - -/** One-line, length-capped preview of a tool-call input payload. Strings pass - * through; objects/arrays serialize to compact JSON; everything collapses - * whitespace and truncates with an ellipsis. Returns '' when there's nothing - * worth showing. */ -export function summarizeToolInput(input: unknown): string { - const raw = toRawPreview(input) - const collapsed = raw.replace(/\s+/g, ' ').trim() - if (collapsed.length <= MAX_PREVIEW_LENGTH) { - return collapsed - } - return `${collapsed.slice(0, MAX_PREVIEW_LENGTH - 1)}…` -} - -/** Full, pretty-printed tool-call input for the expanded detail view. Strings - * pass through as-is; objects/arrays print as indented JSON so a diff-less call - * (e.g. a question payload) reads cleanly instead of one long minified line. */ -export function formatToolInput(input: unknown): string { - if (input === null || input === undefined) { - return '' - } - if (typeof input === 'string') { - return input - } - if (typeof input === 'number' || typeof input === 'boolean') { - return String(input) - } - try { - return JSON.stringify(input, null, 2) ?? '' - } catch { - return '' - } -} - -/** A very short hint for a tool call in a one-line run summary: the target file's - * basename when present, else a clipped preview of the input. */ -export function briefToolArg(input: unknown): string { - if (input && typeof input === 'object') { - const obj = input as Record - const path = obj.file_path ?? obj.path ?? obj.notebook_path - if (typeof path === 'string' && path.length > 0) { - return basename(path) - } - const cmd = obj.command ?? obj.cmd ?? obj.query ?? obj.pattern - if (typeof cmd === 'string') { - return summarizeToolInput(cmd).slice(0, 28) - } - } - return summarizeToolInput(input).slice(0, 28) -} - -/** One-line summary of a run of tool calls: "Bash git status · Edit app.tsx · …". - * Only tool-call blocks contribute names; results are skipped. */ -export function summarizeToolRun(blocks: readonly NativeChatBlock[]): string { - const parts: string[] = [] - for (const block of blocks) { - if (!isToolCallBlock(block)) { - continue - } - const name = block.name.trim() - // Skip nameless tool calls so the join can't produce orphan separators - // ("Tool · ·"). - if (!name) { - continue - } - const brief = briefToolArg(block.input) - parts.push(brief ? `${name} ${brief}` : name) - } - return parts.join(' · ') -} - -/** Count the tool calls in a run (used for the "N tool calls" fold label). */ -export function countToolCalls(blocks: readonly NativeChatBlock[]): number { - return blocks.filter(isToolCallBlock).length -} - -function toRawPreview(input: unknown): string { - if (input === null || input === undefined) { - return '' - } - if (typeof input === 'string') { - return input - } - if (typeof input === 'number' || typeof input === 'boolean') { - return String(input) - } - try { - return JSON.stringify(input) ?? '' - } catch { - return '' - } -} +export { + briefToolArg, + countToolCalls, + formatToolInput, + summarizeToolInput, + summarizeToolRun, + toolFilePath +} from '../../../../shared/native-chat-tool-summary' diff --git a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts index e7c70e120cf2..cb49cd9c57db 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts @@ -2,6 +2,7 @@ import { useCallback, useLayoutEffect, useRef } from 'react' import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection' import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' import type { AgentType } from '../../../../shared/native-chat-types' +import { shouldStepNativeChatAskAnswer } from '../../../../shared/native-chat-agent-support' import { buildAskAnswerKeys, formatAskAnswer, @@ -80,10 +81,11 @@ export function useNativeChatInteractiveSend( // highlighted option, not a pasted label, so answer it with per-option // keystrokes (by option number), paced so each step renders before the next. // Other agents' question tools commit a pasted answer, so send label text. - const handle: NativeChatSendHandle = - agent === 'claude' - ? sendNativeChatAskAnswer(settings, targetPtyId, buildAskAnswerKeys(prompt, selections)) - : sendNativeChatMessage(settings, targetPtyId, formatAskAnswer(prompt, selections)) + // Gate on the transcript agent (not `=== 'claude'`) so OpenClaude — which + // runs the same selector — takes the keystroke path too. + const handle: NativeChatSendHandle = shouldStepNativeChatAskAnswer(agent) + ? sendNativeChatAskAnswer(settings, targetPtyId, buildAskAnswerKeys(prompt, selections)) + : sendNativeChatMessage(settings, targetPtyId, formatAskAnswer(prompt, selections)) inFlightRef.current = handle return handle.settleAfterMs }, diff --git a/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts b/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts index f60ce34d3c86..89ae267c8e5a 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts @@ -15,16 +15,31 @@ const { transportFactory, getMockTransport, resetMockTransports } = vi.hoisted(( readSession: ReturnType subscribe: ReturnType unsubscribe: ReturnType + emit: (frame: unknown) => void } const transports = new Map() - const getMockTransport = (ownerId: string | null): MockTransport => { + // autoSnapshot mirrors a watchable transcript's immediate first frame; pass + // false to model a not-yet-flushed transcript whose server-side resolve poll + // has nothing to emit yet (#8401) — only readSession drives the view then. + const getMockTransport = ( + ownerId: string | null, + opts?: { autoSnapshot?: boolean } + ): MockTransport => { let transport = transports.get(ownerId) if (!transport) { const unsubscribe = vi.fn() + let listener: (frame: unknown) => void = () => {} transport = { unsubscribe, readSession: vi.fn().mockResolvedValue({ messages: [] }), - subscribe: vi.fn(() => unsubscribe) + subscribe: vi.fn((_args, onFrame) => { + listener = onFrame + if (opts?.autoSnapshot !== false) { + onFrame({ type: 'snapshot', messages: [], hasMore: false }) + } + return unsubscribe + }), + emit: (frame) => listener(frame) } transports.set(ownerId, transport) } @@ -74,16 +89,42 @@ describe('mergeNativeChatLiveSession', () => { expect(session.messages).toHaveLength(1) }) - it("clears 'working' once the assistant message flushes to the transcript", () => { + it("keeps 'working' authoritative when a prior assistant message is present", () => { const session = mergeNativeChatLiveSession({ sources: { transcript: [user('u-1', 'do a thing'), assistant('a-1', 'done')] }, sessionId: 'sess', agent: 'claude', hookState: 'working' }) + expect(session.status).toBe('working') + }) + + it("self-heals a stale 'working' once this turn's assistant reply lands", () => { + // Trailing assistant reply (ts 2) post-dates the working turn's start (ts 1), + // so a dropped/late Stop hook must not strand 'working' on a visible reply. + const session = mergeNativeChatLiveSession({ + sources: { transcript: [user('u-1', 'go'), assistant('a-1', 'done')] }, + sessionId: 'sess', + agent: 'claude', + hookState: 'working', + stateStartedAt: 1 + }) expect(session.status).toBe('ready') }) + it("keeps 'working' when the trailing assistant reply predates the working turn", () => { + // Trailing reply (ts 2) is older than the new working turn (started at ts 5), + // so the agent is working again — the stale reply must not suppress 'working'. + const session = mergeNativeChatLiveSession({ + sources: { transcript: [user('u-1', 'go'), assistant('a-1', 'prior')] }, + sessionId: 'sess', + agent: 'claude', + hookState: 'working', + stateStartedAt: 5 + }) + expect(session.status).toBe('working') + }) + it('leaves completed states (done/waiting/blocked) on the derived status', () => { const session = mergeNativeChatLiveSession({ sources: { transcript: [user('u-1', 'hi')] }, @@ -177,17 +218,14 @@ describe('useNativeChatLiveSession — transport routing', () => { resetMockTransports() }) - it('routes the initial read and subscribe through the runtime transport', async () => { + it('seeds from readSession alongside the subscription for the initial runtime load', async () => { await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) expect(transportFactory).toHaveBeenCalledWith('env-1') const transport = getMockTransport('env-1') - expect(transport.readSession).toHaveBeenCalledWith( - AGENT, - SESSION, - expect.any(Number), - undefined - ) + // The independent seed guards against a stuck 'loading' when the stream never + // delivers a snapshot; a live snapshot still wins when it does (see below). + expect(transport.readSession).toHaveBeenCalledOnce() expect(transport.subscribe).toHaveBeenCalledOnce() }) @@ -228,11 +266,16 @@ describe('useNativeChatLiveSession — transport routing', () => { expect(transport.unsubscribe).toHaveBeenCalledOnce() }) - it('surfaces a runtime read error in the error phase (R4 end-to-end)', async () => { - getMockTransport('env-1').readSession.mockResolvedValueOnce({ error: 'runtime too old' }) - + it('surfaces a runtime snapshot error in the error phase (R4 end-to-end)', async () => { await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) - await flush() + await act(async () => { + getMockTransport('env-1').emit({ + type: 'snapshot', + messages: [], + hasMore: false, + error: 'runtime too old' + }) + }) expect(latest?.status).toBe('error') expect(latest?.error).toBe('runtime too old') @@ -261,11 +304,6 @@ describe('useNativeChatLiveSession — transport routing', () => { assistant(`m-${n}`, 't') ) const first = getMockTransport('env-1') - first.readSession.mockResolvedValueOnce({ messages: many }) - let resolveEarlier: (result: { messages: NativeChatMessage[] }) => void = () => {} - first.readSession.mockImplementationOnce( - () => new Promise((resolve) => (resolveEarlier = resolve)) - ) const root = await render({ paneKey: PANE, @@ -273,6 +311,14 @@ describe('useNativeChatLiveSession — transport routing', () => { sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) + await act(async () => { + first.emit({ type: 'snapshot', messages: many, hasMore: true }) + }) + // Arm the pending read for load-earlier only; the initial seed already ran. + let resolveEarlier: (result: { messages: NativeChatMessage[] }) => void = () => {} + first.readSession.mockImplementationOnce( + () => new Promise((resolve) => (resolveEarlier = resolve)) + ) // Kick off load-earlier against env-1, then flip the owner before it resolves. await act(async () => { latest?.loadEarlier() @@ -292,6 +338,162 @@ describe('useNativeChatLiveSession — transport routing', () => { expect(latest?.messages.map((m) => m.id)).not.toContain('stale') }) + + it('discards a load-earlier resolve from before transcript replacement', async () => { + const transport = getMockTransport('env-1') + const many = Array.from({ length: NATIVE_CHAT_INITIAL_LIMIT }, (_unused, n) => + assistant(`old-${n}`, 'old') + ) + await render({ + paneKey: PANE, + agent: AGENT, + sessionId: SESSION, + runtimeEnvironmentId: 'env-1' + }) + await act(async () => transport.emit({ type: 'snapshot', messages: many, hasMore: true })) + // Arm the pending read for load-earlier only; the initial seed already ran. + let resolveEarlier: (result: { messages: NativeChatMessage[] }) => void = () => {} + transport.readSession.mockImplementationOnce( + () => new Promise((resolve) => (resolveEarlier = resolve)) + ) + await act(async () => latest?.loadEarlier()) + + await act(async () => + transport.emit({ + type: 'replacement', + messages: [assistant('replacement', 'new inode')], + hasMore: false + }) + ) + await act(async () => { + resolveEarlier({ messages: [assistant('stale-page', 'old inode')] }) + await Promise.resolve() + }) + + expect(latest?.messages.map((message) => message.id)).toEqual(['replacement']) + }) + + it('discards a load-earlier resolve from before a reconnect snapshot', async () => { + const transport = getMockTransport('env-1') + const many = Array.from({ length: NATIVE_CHAT_INITIAL_LIMIT }, (_unused, n) => + assistant(`old-${n}`, 'old') + ) + await render({ + paneKey: PANE, + agent: AGENT, + sessionId: SESSION, + runtimeEnvironmentId: 'env-1' + }) + await act(async () => transport.emit({ type: 'snapshot', messages: many, hasMore: true })) + // Arm the pending read for load-earlier only; the initial seed already ran. + let resolveEarlier: (result: { messages: NativeChatMessage[] }) => void = () => {} + transport.readSession.mockImplementationOnce( + () => new Promise((resolve) => (resolveEarlier = resolve)) + ) + await act(async () => latest?.loadEarlier()) + + await act(async () => + transport.emit({ + type: 'snapshot', + messages: [assistant('reconnected', 'fresh snapshot')], + hasMore: false + }) + ) + await act(async () => { + resolveEarlier({ messages: [assistant('stale-page', 'old generation')] }) + await Promise.resolve() + }) + + expect(latest?.messages.map((message) => message.id)).toEqual(['reconnected']) + }) + + it('discards a load-earlier resolve from before a transcript-path rebind', async () => { + const transport = getMockTransport('env-1') + const many = Array.from({ length: NATIVE_CHAT_INITIAL_LIMIT }, (_unused, n) => + assistant(`old-path-${n}`, 'old') + ) + const root = await render({ + paneKey: PANE, + agent: AGENT, + sessionId: SESSION, + transcriptPath: '/old/transcript', + runtimeEnvironmentId: 'env-1' + }) + await act(async () => transport.emit({ type: 'snapshot', messages: many, hasMore: true })) + // Arm the pending read for load-earlier only; the initial seed already ran. + let resolveEarlier: (result: { messages: NativeChatMessage[] }) => void = () => {} + transport.readSession.mockImplementationOnce( + () => new Promise((resolve) => (resolveEarlier = resolve)) + ) + await act(async () => latest?.loadEarlier()) + + await rerender(root, { + paneKey: PANE, + agent: AGENT, + sessionId: SESSION, + transcriptPath: '/new/transcript', + runtimeEnvironmentId: 'env-1' + }) + await act(async () => { + resolveEarlier({ messages: [assistant('stale-old-path', 'old transcript')] }) + await Promise.resolve() + }) + + expect(latest?.messages.map((message) => message.id)).not.toContain('stale-old-path') + }) + + it('seeds ready from readSession when the subscription never delivers a frame', async () => { + const transport = getMockTransport('env-1') + // Older runtime: the stream wires only appends and stays silent for an empty + // tail, so no snapshot arrives — the independent seed must settle the view. + transport.subscribe.mockImplementation(() => transport.unsubscribe) + transport.readSession.mockResolvedValueOnce({ messages: [user('u-seed', 'hi')] }) + + await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) + + expect(latest?.status).not.toBe('loading') + expect(latest?.messages.map((message) => message.id)).toEqual(['u-seed']) + }) + + it('keeps the live snapshot when a readSession seed resolves after it', async () => { + const transport = getMockTransport('env-1') + // The seed resolves only after the authoritative snapshot has already landed. + let resolveSeed: (result: { messages: NativeChatMessage[] }) => void = () => {} + transport.readSession.mockImplementationOnce( + () => new Promise((resolve) => (resolveSeed = resolve)) + ) + + await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) + await act(async () => + transport.emit({ type: 'snapshot', messages: [user('u-live', 'live')], hasMore: false }) + ) + await act(async () => { + resolveSeed({ messages: [user('u-stale', 'stale')] }) + await Promise.resolve() + }) + + expect(latest?.messages.map((message) => message.id)).toEqual(['u-live']) + }) + + it("self-heals a stale 'working' hook once this turn's reply has landed", async () => { + // The hook must read stateStartedAt from the store and thread it into the + // merge, so a dropped Stop hook doesn't strand 'Agent is working'. + useAppStore.setState({ + agentStatusByPaneKey: { [PANE]: { state: 'working', stateStartedAt: 1 } as never } + }) + const transport = getMockTransport('env-1') + await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) + await act(async () => + transport.emit({ + type: 'snapshot', + messages: [user('u-1', 'go'), assistant('a-1', 'done')], + hasMore: false + }) + ) + + // The reply (ts 2) post-dates stateStartedAt (1): the stuck 'working' heals. + expect(latest?.status).toBe('ready') + }) }) // Regression for #8401: a just-created Claude Code session's transcript can @@ -338,7 +540,7 @@ describe('useNativeChatLiveSession — notFound retry (#8401)', () => { it('retries a notFound miss with backoff and settles into ready without ever exposing an error', async () => { vi.useFakeTimers() - const transport = getMockTransport('env-1') + const transport = getMockTransport('env-1', { autoSnapshot: false }) transport.readSession .mockResolvedValueOnce({ error: 'No transcript found', notFound: true }) .mockResolvedValueOnce({ messages: [assistant('a-1', 'hello')] }) @@ -358,7 +560,7 @@ describe('useNativeChatLiveSession — notFound retry (#8401)', () => { it('surfaces an error once the ~60s retry window is exhausted', async () => { vi.useFakeTimers() - const transport = getMockTransport('env-1') + const transport = getMockTransport('env-1', { autoSnapshot: false }) transport.readSession.mockResolvedValue({ error: 'No transcript found', notFound: true }) await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) @@ -374,22 +576,15 @@ describe('useNativeChatLiveSession — notFound retry (#8401)', () => { it('renders live-appended content instead of loading while the read is still retrying', async () => { vi.useFakeTimers() - const transport = getMockTransport('env-1') + const transport = getMockTransport('env-1', { autoSnapshot: false }) transport.readSession.mockResolvedValue({ error: 'No transcript found', notFound: true }) - let onAppended: ((messages: NativeChatMessage[]) => void) | null = null - transport.subscribe.mockImplementationOnce( - (_args: unknown, cb: (m: NativeChatMessage[]) => void) => { - onAppended = cb - return transport.unsubscribe - } - ) await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) expect(latest?.status).toBe('loading') // The watcher's first drain lands mid-retry — content must win over the spinner. await act(async () => { - onAppended?.([assistant('a-early', 'landed during retry')]) + transport.emit({ type: 'appended', messages: [assistant('a-early', 'landed during retry')] }) }) expect(latest?.status).not.toBe('loading') @@ -397,21 +592,14 @@ describe('useNativeChatLiveSession — notFound retry (#8401)', () => { }) it('renders live-appended content even when the initial read settled into a permanent error', async () => { - const transport = getMockTransport('env-1') + const transport = getMockTransport('env-1', { autoSnapshot: false }) transport.readSession.mockResolvedValueOnce({ error: 'unreadable transcript' }) - let onAppended: ((messages: NativeChatMessage[]) => void) | null = null - transport.subscribe.mockImplementationOnce( - (_args: unknown, cb: (m: NativeChatMessage[]) => void) => { - onAppended = cb - return transport.unsubscribe - } - ) await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) expect(latest?.status).toBe('error') await act(async () => { - onAppended?.([assistant('a-late', 'landed late')]) + transport.emit({ type: 'appended', messages: [assistant('a-late', 'landed late')] }) }) expect(latest?.status).not.toBe('error') diff --git a/src/renderer/src/components/native-chat/use-native-chat-live-session.ts b/src/renderer/src/components/native-chat/use-native-chat-live-session.ts index 384ae7247698..ec1ebbc8226f 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-live-session.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-live-session.ts @@ -8,7 +8,6 @@ import { } from '../../../../shared/native-chat-types' import { applyAppend, - boundNativeChatWindow, createNativeChatMerger, replaceList } from '../../../../shared/native-chat-merge' @@ -130,7 +129,7 @@ export function useNativeChatLiveSession( // The active read window; raised by loadEarlier to page in older history. const limitRef = useRef(NATIVE_CHAT_INITIAL_LIMIT) - // Appended messages accumulate separately from the initial read so a re-read + // Appended messages accumulate separately from the initial snapshot so pagination // (session change or load-earlier) doesn't lose in-flight appends mid-swap; // they reset with the same effect that re-subscribes. Live frames merge by id // (re-emitted ids replace in place, no unbounded concat) and the bucket is @@ -143,6 +142,11 @@ export function useNativeChatLiveSession( // Live hook state for this pane, selected narrowly so unrelated status churn // doesn't re-render the chat view. const hookState = useAppStore((s) => s.agentStatusByPaneKey[paneKey]?.state ?? null) + // When that state began (epoch ms). A separate primitive selector so it doesn't + // churn renders; lets a stale 'working' self-heal once this turn's reply lands. + const hookStateStartedAt = useAppStore( + (s) => s.agentStatusByPaneKey[paneKey]?.stateStartedAt ?? null + ) const latestSessionId = useRef(sessionId) latestSessionId.current = sessionId @@ -150,6 +154,7 @@ export function useNativeChatLiveSession( // host is discarded after an owner flip (the session id can stay the same). const latestTransport = useRef(transport) latestTransport.current = transport + const transcriptEpochRef = useRef(0) // Incremental assembler: reset on the base axis (session/agent/read swap), // applyAppends on the hot append axis. `appliedTranscriptRef` is the exact @@ -161,6 +166,10 @@ export function useNativeChatLiveSession( const baseMessagesRef = useRef(EMPTY_MESSAGES) useEffect(() => { + // Why: agent/path/owner rebinds can keep the same session and transport; + // every source generation must invalidate pagination captured before it. + transcriptEpochRef.current += 1 + setLoadingEarlier(false) if (!sessionId) { // No session id yet: nothing to read or tail. Surface live hook state on // an empty transcript; backfills once the id arrives (effect re-runs). @@ -172,6 +181,9 @@ export function useNativeChatLiveSession( } let cancelled = false + // Set by the first authoritative snapshot/replacement frame so the + // independent readSession seed below can never clobber a live snapshot. + let frameArrived = false let retryTimer: ReturnType | null = null const retryStartedAt = Date.now() // Re-bound as a plain const: TS doesn't retain the `!sessionId` narrowing @@ -184,11 +196,19 @@ export function useNativeChatLiveSession( setAppended([]) setHasMore(false) + // Independent initial seed: the subscribe stream normally delivers the first + // snapshot, but a persistent initial-drain error, or an older runtime whose + // subscribe only wires onAppend, would otherwise strand the view at 'loading' + // forever. Apply this only while no authoritative frame has landed yet, so a + // live snapshot always wins and a late seed never repaints it. function loadSession(attempt: number): void { + if (frameArrived) { + return + } void transport .readSession(agent, activeSessionId, limitRef.current, transcriptPath ?? undefined) .then((result) => { - if (cancelled) { + if (cancelled || frameArrived) { return } if (result && 'error' in result) { @@ -209,7 +229,7 @@ export function useNativeChatLiveSession( setHasMore(hasMoreNativeChatHistory(messages.length, limitRef.current)) }) .catch((err: unknown) => { - if (!cancelled) { + if (!cancelled && !frameArrived) { setRead({ phase: 'error', error: err instanceof Error ? err.message : String(err) }) } }) @@ -219,19 +239,36 @@ export function useNativeChatLiveSession( const subscriptionId = nextSubscriptionId() const unsubscribe = transport.subscribe( - { subscriptionId, agent, sessionId, transcriptPath: transcriptPath ?? undefined }, - (messages) => { + { + subscriptionId, + agent, + sessionId, + transcriptPath: transcriptPath ?? undefined, + limit: limitRef.current + }, + (frame) => { if (!cancelled) { + if (frame.type === 'snapshot' || frame.type === 'replacement') { + // Why: reconnect snapshots and inode replacements are both + // authoritative generations; older pagination must not repaint them. + frameArrived = true + transcriptEpochRef.current += 1 + setLoadingEarlier(false) + if ('error' in frame && frame.error) { + setRead({ phase: 'error', error: frame.error }) + return + } + replaceList(appendMergerRef.current, frame.messages) + setAppended([]) + setRead({ phase: 'ready', messages: appendMergerRef.current.list }) + setHasMore(frame.hasMore) + return + } // Merge by id (re-emits replace in place) then bound to the window so // the bucket can't grow without limit. The base read still holds older // turns, and the assembler re-dedups the concat, so trimming the recent // append tail can't drop a turn the base window still covers (#6). - const merged = applyAppend(appendMergerRef.current, messages) - const bounded = boundNativeChatWindow(merged, limitRef.current) - if (bounded !== merged) { - replaceList(appendMergerRef.current, bounded) - } - setAppended(appendMergerRef.current.list) + setAppended(applyAppend(appendMergerRef.current, frame.messages, limitRef.current)) } } ) @@ -266,13 +303,18 @@ export function useNativeChatLiveSession( return } const nextLimit = nextNativeChatLimit(limitRef.current) + const requestEpoch = transcriptEpochRef.current setLoadingEarlier(true) void transport .readSession(agent, sessionId, nextLimit, transcriptPath ?? undefined) .then((result) => { // Ignore a stale resolve from a session that swapped OR an owner that // flipped underneath us — either would paint the wrong host's history. - if (latestSessionId.current !== sessionId || latestTransport.current !== transport) { + if ( + latestSessionId.current !== sessionId || + latestTransport.current !== transport || + transcriptEpochRef.current !== requestEpoch + ) { return } if (!result || 'error' in result) { @@ -293,7 +335,9 @@ export function useNativeChatLiveSession( // Always clear the loading flag — even after a session swap — so a stale // resolve can't leave loadingEarlier stuck true on the new session. Only // APPLYING the result above is gated on the session-id match. - setLoadingEarlier(false) + if (transcriptEpochRef.current === requestEpoch) { + setLoadingEarlier(false) + } }) }, [agent, sessionId, transcriptPath, transport, hasMore, loadingEarlier, read.phase]) @@ -307,7 +351,7 @@ export function useNativeChatLiveSession( // Base axis: the read's message array reference changes on session swap and // loadEarlier; sessionId/agent identify the conversation. Any change forces a // full reset so a missed trigger can't leave the cache stale. - const baseSig = `${agent}${sessionId ?? ''}` + const baseSig = `${agent}\u0000${sessionId ?? ''}` const baseChanged = baseSig !== baseSigRef.current || baseMessages !== baseMessagesRef.current const applied = appliedTranscriptRef.current const isSuffixExtension = @@ -338,6 +382,7 @@ export function useNativeChatLiveSession( sessionId, agent, hookState, + stateStartedAt: hookStateStartedAt, // Why: a watcher append (fix for #8401) can land content while the read is // still retrying ('loading') or after it settled into 'error' — in both // cases showing the live content beats a spinner or a stale error, so each @@ -352,6 +397,7 @@ export function useNativeChatLiveSession( sessionId, agent, hookState, + hookStateStartedAt, hasMore, loadingEarlier, loadEarlier, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index c2f095e2aad5..a0a4265cf520 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -13143,6 +13143,7 @@ "title": "Could not load conversation", "subtitle": "The transcript could not be read. Toggle back to the terminal to keep working." }, + "pairHost": "Pair a host to view agent chat history.", "notAgent": { "title": "No conversation here", "subtitle": "This terminal is not running a recognized coding agent." diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index d4dc50167517..5fec0c3ef2fe 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -13143,6 +13143,7 @@ "title": "No se pudo cargar la conversación", "subtitle": "No se pudo leer la transcripción. Vuelva a la terminal para seguir trabajando." }, + "pairHost": "Empareje un host para ver el historial de chat del agente.", "notAgent": { "title": "No hay conversación aquí", "subtitle": "Este terminal no está ejecutando un agente de código reconocido." diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 313c6bfb2381..a760df564088 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -13143,6 +13143,7 @@ "title": "会話を読み込めませんでした", "subtitle": "トランスクリプトを読み取ることができませんでした。作業を続けるには、terminal に切り替えてください。" }, + "pairHost": "ホストをペアリングして、エージェントのチャット履歴を表示します。", "notAgent": { "title": "ここでは会話はありません", "subtitle": "この terminal は認識されたコーディング agent を実行していません。" diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index e7d7fc7bac1e..ce8f97cf3ec6 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -13143,6 +13143,7 @@ "title": "대화를 로드할 수 없습니다.", "subtitle": "스크립트를 읽을 수 없습니다. 계속 작업하려면 terminal로 다시 전환하세요." }, + "pairHost": "호스트를 페어링하여 에이전트 채팅 기록을 확인하세요.", "notAgent": { "title": "여기에는 대화가 없습니다.", "subtitle": "이 terminal은 인식된 코딩 agent를 실행하고 있지 않습니다." diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 2f0f5bdbf475..dc56b80b9417 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -13143,6 +13143,7 @@ "title": "无法加载对话", "subtitle": "无法读取记录。请切回终端继续工作。" }, + "pairHost": "配对主机以查看智能体聊天记录。", "notAgent": { "title": "这里没有对话", "subtitle": "此终端未运行可识别的编码智能体。" diff --git a/src/renderer/src/lib/activity-thread-display.test.ts b/src/renderer/src/lib/activity-thread-display.test.ts index c553ceb02f35..1728c8ab5cac 100644 --- a/src/renderer/src/lib/activity-thread-display.test.ts +++ b/src/renderer/src/lib/activity-thread-display.test.ts @@ -112,7 +112,11 @@ describe('getActivityThreadTaskTitle', () => { }) it('keeps orchestration labels across terse follow-ups but yields to new work', () => { - const orchestration = { taskId: 'task-1', dispatchId: 'ctx-1', displayName: 'Fix checkout race' } + const orchestration = { + taskId: 'task-1', + dispatchId: 'ctx-1', + displayName: 'Fix checkout race' + } // Terse follow-up → still the same orchestration task. expect( getActivityThreadTaskTitle({ diff --git a/src/renderer/src/lib/agent-status.ts b/src/renderer/src/lib/agent-status.ts index 141da5c2a3a7..aaf3fc4795c5 100644 --- a/src/renderer/src/lib/agent-status.ts +++ b/src/renderer/src/lib/agent-status.ts @@ -105,36 +105,9 @@ export function getWorkingAgentsPerWorktree({ return result } -const WELL_KNOWN_LABELS: Record = { - claude: 'Claude', - openclaude: 'OpenClaude', - codex: 'Codex', - gemini: 'Gemini', - antigravity: 'Antigravity', - amp: 'Amp', - copilot: 'GitHub Copilot', - opencode: 'OpenCode', - 'mimo-code': 'MiMo Code', - cursor: 'Cursor', - aider: 'Aider', - pi: 'Pi', - omp: 'OMP', - droid: 'Droid', - 'command-code': 'Command Code', - grok: 'Grok', - hermes: 'Hermes', - devin: 'Devin', - ante: 'Ante', - kimi: 'Kimi' -} - -export function formatAgentTypeLabel(agentType: AgentType | null | undefined): string { - if (!agentType || agentType === 'unknown') { - return 'Agent' - } - // Capitalize well-known names nicely; pass through custom names as-is - return WELL_KNOWN_LABELS[agentType] ?? agentType -} +// Re-exported from shared so mobile shows the same agent labels (one source of +// truth). Kept re-exported here so existing `@/lib/agent-status` importers work. +export { formatAgentTypeLabel } from '../../../shared/agent-type-label' // Why: AgentIcon expects a TuiAgent, but AgentType is a broader union // (WellKnownAgentType | (string & {})) that includes 'unknown' and arbitrary diff --git a/src/renderer/src/lib/native-chat-supported-agent.ts b/src/renderer/src/lib/native-chat-supported-agent.ts index a376fe0d5ec0..84135bba8f42 100644 --- a/src/renderer/src/lib/native-chat-supported-agent.ts +++ b/src/renderer/src/lib/native-chat-supported-agent.ts @@ -1,19 +1,4 @@ -import type { AgentType } from '../../../shared/agent-status-types' -import type { TuiAgent } from '../../../shared/types' - -/** Agents whose transcripts the native chat view can parse and render. */ -export const NATIVE_CHAT_SUPPORTED_AGENTS: ReadonlySet = new Set([ - 'claude', - 'openclaude', - 'codex', - // Why: Grok writes `~/.grok/sessions///chat_history.jsonl` and - // Orca already reads that for hooks/AI Vault — native chat reuses the same - // path + a Grok JSONL decoder rather than leaving Grok TUI-only. - 'grok' -]) - -export function isNativeChatSupportedAgent( - agent: TuiAgent | AgentType | null | undefined -): boolean { - return agent != null && NATIVE_CHAT_SUPPORTED_AGENTS.has(agent) -} +export { + isNativeChatSupportedAgent, + NATIVE_CHAT_SUPPORTED_AGENTS +} from '../../../shared/native-chat-agent-support' diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 925b91ab2846..a64f4abe263e 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -1090,41 +1090,114 @@ function createNativeChatApi(): NativeChatApi { limit, transcriptPath }), - subscribe: (args, onAppended) => { + subscribe: (args, onFrame) => { // No paired runtime yet: nothing to subscribe to, and // requireActiveEnvironment() would throw. Return a no-op teardown so the // chat view mounts cleanly until a runtime is paired (only the not-paired // case is swallowed — real subscribe errors still surface via .catch). const environment = requireActiveEnvironmentOrNull() if (!environment) { + onFrame({ + type: 'snapshot', + messages: [], + hasMore: false, + error: translate( + 'components.native-chat.state.pairHost', + 'Pair a host to view agent chat history.' + ) + }) return () => {} } let handle: { unsubscribe: () => void } | null = null let cancelled = false + let receivedInitial = false void getClientForEnvironment(environment) .subscribe( 'nativeChat.subscribe', - { agent: args.agent, sessionId: args.sessionId, transcriptPath: args.transcriptPath }, + { + agent: args.agent, + sessionId: args.sessionId, + subscriptionId: args.subscriptionId, + transcriptPath: args.transcriptPath, + limit: args.limit + }, { onResponse: (response) => { - if (cancelled || !response.ok) { + if (cancelled) { + return + } + if (!response.ok) { + if (!receivedInitial) { + receivedInitial = true + onFrame({ + type: 'snapshot', + messages: [], + hasMore: false, + error: response.error.message + }) + } return } const result = response.result as { type?: string messages?: NativeChatAppendedMessages + hasMore?: boolean + error?: string } - if (result?.type === 'appended' && Array.isArray(result.messages)) { - onAppended(result.messages) + if ( + (result?.type === 'appended' || + result?.type === 'snapshot' || + result?.type === 'replacement') && + Array.isArray(result.messages) + ) { + if (!receivedInitial) { + receivedInitial = true + onFrame({ + type: 'snapshot', + messages: result.messages, + hasMore: result.hasMore ?? result.messages.length >= (args.limit ?? 300), + ...(result.error ? { error: result.error } : {}) + }) + } else if (result.type === 'snapshot') { + onFrame({ + type: 'snapshot', + messages: result.messages, + hasMore: result.hasMore ?? false, + ...(result.error ? { error: result.error } : {}) + }) + } else { + onFrame( + result.type === 'replacement' + ? { + type: 'replacement', + messages: result.messages, + hasMore: result.hasMore ?? false + } + : { type: 'appended', messages: result.messages } + ) + } + } else if (!receivedInitial) { + // Why: an ok response whose payload shape we don't recognize would + // otherwise never flip receivedInitial, stranding the view on + // 'loading'. Settle it with an empty snapshot (carrying any error + // the runtime sent) so the UI resolves. + receivedInitial = true + onFrame({ + type: 'snapshot', + messages: [], + hasMore: false, + ...(result?.error ? { error: result.error } : {}) + }) } } }, { // Why: send nativeChat.unsubscribe on teardown so the server reaps // the transcript fs-watcher on view-toggle, not just on socket close - // (the watcher-leak fix). Uses the same agent:sessionId cleanup token - // mobile sends, via the shared key-builder so it can't drift. - buildUnsubscribe: () => buildNativeChatUnsubscribe(args.agent, args.sessionId) + // (the watcher-leak fix). Echo the pane-specific token so two panes + // watching one session cannot tear down each other's watcher. + buildUnsubscribe: () => + buildNativeChatUnsubscribe(args.agent, args.sessionId, args.subscriptionId) } ) .then((h) => { @@ -1134,7 +1207,17 @@ function createNativeChatApi(): NativeChatApi { handle = h } }) - .catch(() => {}) + .catch((err: unknown) => { + if (!cancelled && !receivedInitial) { + receivedInitial = true + onFrame({ + type: 'snapshot', + messages: [], + hasMore: false, + error: err instanceof Error ? err.message : String(err) + }) + } + }) return () => { cancelled = true handle?.unsubscribe() diff --git a/src/shared/agent-type-label.ts b/src/shared/agent-type-label.ts new file mode 100644 index 000000000000..b638334f633a --- /dev/null +++ b/src/shared/agent-type-label.ts @@ -0,0 +1,34 @@ +import type { AgentType } from './agent-status-types' + +// Shared so the desktop renderer and the mobile app show the same agent name +// (e.g. native chat's empty state on both surfaces) from one source of truth. +const WELL_KNOWN_LABELS: Record = { + claude: 'Claude', + openclaude: 'OpenClaude', + codex: 'Codex', + gemini: 'Gemini', + antigravity: 'Antigravity', + amp: 'Amp', + copilot: 'GitHub Copilot', + opencode: 'OpenCode', + 'mimo-code': 'MiMo Code', + cursor: 'Cursor', + aider: 'Aider', + pi: 'Pi', + omp: 'OMP', + droid: 'Droid', + 'command-code': 'Command Code', + grok: 'Grok', + hermes: 'Hermes', + devin: 'Devin', + ante: 'Ante', + kimi: 'Kimi' +} + +export function formatAgentTypeLabel(agentType: AgentType | null | undefined): string { + if (!agentType || agentType === 'unknown') { + return 'Agent' + } + // Capitalize well-known names nicely; pass through custom names as-is + return WELL_KNOWN_LABELS[agentType] ?? agentType +} diff --git a/src/shared/native-chat-agent-support.test.ts b/src/shared/native-chat-agent-support.test.ts new file mode 100644 index 000000000000..8745d47b3948 --- /dev/null +++ b/src/shared/native-chat-agent-support.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { + isNativeChatSupportedAgent, + resolveNativeChatTranscriptAgent, + shouldStepNativeChatAskAnswer +} from './native-chat-agent-support' + +describe('resolveNativeChatTranscriptAgent', () => { + it('maps OpenClaude onto the Claude transcript format', () => { + expect(resolveNativeChatTranscriptAgent('openclaude')).toBe('claude') + expect(resolveNativeChatTranscriptAgent('claude')).toBe('claude') + }) + + it('passes codex and grok through and rejects everything else', () => { + expect(resolveNativeChatTranscriptAgent('codex')).toBe('codex') + expect(resolveNativeChatTranscriptAgent('grok')).toBe('grok') + expect(resolveNativeChatTranscriptAgent('cursor')).toBeNull() + expect(resolveNativeChatTranscriptAgent(null)).toBeNull() + expect(resolveNativeChatTranscriptAgent(undefined)).toBeNull() + }) +}) + +describe('isNativeChatSupportedAgent', () => { + it('recognizes the parseable agents and rejects unknown / nullish input', () => { + expect(isNativeChatSupportedAgent('claude')).toBe(true) + expect(isNativeChatSupportedAgent('openclaude')).toBe(true) + expect(isNativeChatSupportedAgent('cursor')).toBe(false) + expect(isNativeChatSupportedAgent(null)).toBe(false) + expect(isNativeChatSupportedAgent(undefined)).toBe(false) + }) +}) + +describe('shouldStepNativeChatAskAnswer', () => { + it('steps only the Claude-format agents (Claude, OpenClaude)', () => { + expect(shouldStepNativeChatAskAnswer('claude')).toBe(true) + expect(shouldStepNativeChatAskAnswer('openclaude')).toBe(true) + }) + + it('does not step other or unknown agents', () => { + expect(shouldStepNativeChatAskAnswer('codex')).toBe(false) + expect(shouldStepNativeChatAskAnswer('grok')).toBe(false) + expect(shouldStepNativeChatAskAnswer('cursor')).toBe(false) + expect(shouldStepNativeChatAskAnswer(null)).toBe(false) + expect(shouldStepNativeChatAskAnswer(undefined)).toBe(false) + }) +}) diff --git a/src/shared/native-chat-agent-support.ts b/src/shared/native-chat-agent-support.ts new file mode 100644 index 000000000000..0c7e8e4a14db --- /dev/null +++ b/src/shared/native-chat-agent-support.ts @@ -0,0 +1,34 @@ +export type NativeChatTranscriptAgent = 'claude' | 'codex' | 'grok' + +/** Agents whose transcripts the native chat view can parse and render. */ +export const NATIVE_CHAT_SUPPORTED_AGENTS: ReadonlySet = new Set([ + 'claude', + 'openclaude', + 'codex', + 'grok' +]) + +export function isNativeChatSupportedAgent(agent: string | null | undefined): boolean { + return agent != null && NATIVE_CHAT_SUPPORTED_AGENTS.has(agent) +} + +/** True when the agent renders Claude's multi-step AskUserQuestion — one question + * per step, each Enter advancing — so a multi-line answer must be paced per line. + * Other agents submit the whole answer with a single Enter. */ +export function shouldStepNativeChatAskAnswer(agent: string | null | undefined): boolean { + return resolveNativeChatTranscriptAgent(agent) === 'claude' +} + +export function resolveNativeChatTranscriptAgent( + agent: string | null | undefined +): NativeChatTranscriptAgent | null { + // Why: OpenClaude writes the Claude transcript format and layout even though + // Orca preserves its distinct agent identity for launch and UI behavior. + if (agent === 'claude' || agent === 'openclaude') { + return 'claude' + } + if (agent === 'codex' || agent === 'grok') { + return agent + } + return null +} diff --git a/src/shared/native-chat-answer-stepping.ts b/src/shared/native-chat-answer-stepping.ts new file mode 100644 index 000000000000..0fd148e13e87 --- /dev/null +++ b/src/shared/native-chat-answer-stepping.ts @@ -0,0 +1,26 @@ +export const NATIVE_CHAT_SUBMIT_DELAY_MS = 500 +// 500ms (not a tighter cadence) so the next AskUserQuestion step still renders +// before its body is written on slower machines / under SSH round-trip latency. +export const NATIVE_CHAT_ADVANCE_BUFFER_MS = 500 +export const NATIVE_CHAT_QUESTION_STEP_MS = + NATIVE_CHAT_SUBMIT_DELAY_MS + NATIVE_CHAT_ADVANCE_BUFFER_MS + +export function nativeChatQuestionOffsets(index: number): { bodyAt: number; enterAt: number } { + const bodyAt = index * NATIVE_CHAT_QUESTION_STEP_MS + return { bodyAt, enterAt: bodyAt + NATIVE_CHAT_SUBMIT_DELAY_MS } +} + +/** Schedule one body and one Enter per question; callers own the actual transport. */ +export function scheduleNativeChatAnswer( + lines: readonly string[], + writeBody: (line: string) => void, + writeEnter: () => void +): ReturnType[] { + const timers: ReturnType[] = [] + lines.forEach((line, index) => { + const { bodyAt, enterAt } = nativeChatQuestionOffsets(index) + timers.push(setTimeout(() => writeBody(line), bodyAt)) + timers.push(setTimeout(writeEnter, enterAt)) + }) + return timers +} diff --git a/src/shared/native-chat-ask-types.ts b/src/shared/native-chat-ask-types.ts index 640f2ed74d79..9c5942ab7de0 100644 --- a/src/shared/native-chat-ask-types.ts +++ b/src/shared/native-chat-ask-types.ts @@ -1,8 +1,5 @@ -// Canonical AskUserQuestion prompt types shared by both native-chat surfaces -// (desktop `native-chat-interactive-prompt.ts` and mobile `mobile-native-chat-ask.ts`). -// Types only — both surfaces `import type` from here (Metro can't resolve runtime -// values from src/shared), so the type declarations stop being duplicated while -// the parser bodies stay mirrored and parity-tested. +// Canonical AskUserQuestion prompt types consumed by the shared parser and both +// native-chat platform UIs. export type AskOption = { label: string; description?: string } export type AskQuestion = { diff --git a/src/shared/native-chat-ask.test.ts b/src/shared/native-chat-ask.test.ts new file mode 100644 index 000000000000..e02f2bb9bba2 --- /dev/null +++ b/src/shared/native-chat-ask.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatBlock, NativeChatMessage } from './native-chat-types' +import { extractPendingAsk, parseAskFromStatus } from './native-chat-ask' + +function message(id: string, blocks: NativeChatBlock[]): NativeChatMessage { + return { id, role: 'assistant', blocks, timestamp: 1, source: 'transcript' } +} + +function call(name: string, input: unknown): NativeChatBlock { + return { type: 'tool-call', name, input } +} + +function result(): NativeChatBlock { + return { type: 'tool-result', output: 'ok' } +} + +const QUESTIONS_INPUT = { + questions: [{ question: 'Deploy?', options: [{ label: 'Yes' }, { label: 'No' }] }] +} + +describe('extractPendingAsk', () => { + it('recognizes an unregistered tool whose input matches the canonical questions shape', () => { + // The live path (parseAskFromStatus) accepts this shape from any tool name; + // transcript replay must not silently drop the same pending question. + const pending = extractPendingAsk([message('m1', [call('CustomAskTool', QUESTIONS_INPUT)])]) + expect(pending?.questions[0]?.question).toBe('Deploy?') + }) + + it('resolves calls FIFO so a sibling result cannot clear a newer pending ask', () => { + const pending = extractPendingAsk([ + message('m1', [ + call('Bash', { command: 'ls' }), + call('AskUserQuestion', QUESTIONS_INPUT), + // FIFO: this result answers the Bash call, not the ask. + result() + ]) + ]) + expect(pending?.questions[0]?.question).toBe('Deploy?') + }) + + it("clears the ask when its own result arrives, keeping the newest ask's identity", () => { + const first = { questions: [{ question: 'First?', options: [] }] } + const pending = extractPendingAsk([ + message('m1', [ + call('AskUserQuestion', first), + call('AskUserQuestion', QUESTIONS_INPUT), + // Resolves the FIRST ask (FIFO); the newer one stays pending. + result() + ]) + ]) + expect(pending?.questions[0]?.question).toBe('Deploy?') + }) + + it('ignores malformed question payloads', () => { + expect( + extractPendingAsk([ + message('m1', [ + call('AskUserQuestion', { questions: [] }), + call('AskUserQuestion', { questions: [{}] }), + call('AskUserQuestion', 'not-an-object') + ]) + ]) + ).toBeNull() + }) +}) + +describe('parseAskFromStatus', () => { + it('accepts the canonical shape from any tool name and rejects broken JSON', () => { + expect( + parseAskFromStatus(JSON.stringify(QUESTIONS_INPUT), 'SomeNewTool')?.questions + ).toHaveLength(1) + expect(parseAskFromStatus('{not json', 'AskUserQuestion')).toBeNull() + expect(parseAskFromStatus(null)).toBeNull() + }) + + it('parses string options into labels', () => { + const prompt = parseAskFromStatus( + JSON.stringify({ questions: [{ question: 'Pick', options: ['a', 'b'] }] }) + ) + expect(prompt?.questions[0]?.options.map((o) => o.label)).toEqual(['a', 'b']) + }) +}) diff --git a/src/shared/native-chat-ask.ts b/src/shared/native-chat-ask.ts new file mode 100644 index 000000000000..567ed97e0d21 --- /dev/null +++ b/src/shared/native-chat-ask.ts @@ -0,0 +1,227 @@ +import type { + AskOption, + AskPrompt, + AskQuestion, + InteractiveQuestionParser +} from './native-chat-ask-types' +import type { NativeChatMessage } from './native-chat-types' + +export type { AskOption, AskPrompt, AskQuestion, InteractiveQuestionParser } + +const QUESTION_TOOL_PARSERS = new Map() + +export function registerQuestionTool(toolName: string, parser: InteractiveQuestionParser): void { + QUESTION_TOOL_PARSERS.set(toolName, parser) +} + +function parseQuestionsShape(input: unknown): AskPrompt | null { + if (!input || typeof input !== 'object') { + return null + } + const rawQuestions = (input as { questions?: unknown }).questions + if (!Array.isArray(rawQuestions) || rawQuestions.length === 0) { + return null + } + const questions: AskQuestion[] = [] + for (const raw of rawQuestions) { + if (!raw || typeof raw !== 'object') { + continue + } + const question = raw as Record + const text = typeof question.question === 'string' ? question.question : '' + const options = parseOptions(question.options) + if (text || options.length > 0) { + questions.push({ + question: text, + header: typeof question.header === 'string' ? question.header : undefined, + multiSelect: question.multiSelect === true, + options + }) + } + } + return questions.length > 0 ? { questions } : null +} + +function parseOptions(raw: unknown): AskOption[] { + if (!Array.isArray(raw)) { + return [] + } + return raw + .map((option): AskOption | null => { + if (typeof option === 'string') { + return { label: option } + } + if ( + option && + typeof option === 'object' && + typeof (option as { label?: unknown }).label === 'string' + ) { + const value = option as { label: string; description?: unknown } + return { + label: value.label, + description: typeof value.description === 'string' ? value.description : undefined + } + } + return null + }) + .filter((option): option is AskOption => option !== null) +} + +for (const name of ['AskUserQuestion', 'ask_user_question', 'askUserQuestion']) { + QUESTION_TOOL_PARSERS.set(name, parseQuestionsShape) +} + +function parseToolInput(toolName: string | undefined, input: unknown): AskPrompt | null { + const parser = toolName ? QUESTION_TOOL_PARSERS.get(toolName) : undefined + return (parser ? parser(input) : null) ?? parseQuestionsShape(input) +} + +export function parseAskFromStatus( + interactivePrompt: string | undefined | null, + toolName?: string +): AskPrompt | null { + if (!interactivePrompt) { + return null + } + try { + return parseToolInput(toolName, JSON.parse(interactivePrompt)) + } catch { + return null + } +} + +/** Resolve the newest question tool that has not received its FIFO tool result. + * Transcript replay parses each tool-call through the same registered-parser + + * canonical-shape fallback as live status, so a question tool that rendered + * live cannot vanish from pending state after a reconnect/replay. */ +export function extractPendingAsk(messages: readonly NativeChatMessage[]): AskPrompt | null { + let pending: AskPrompt | null = null + const outstanding: (AskPrompt | null)[] = [] + for (const message of messages) { + for (const block of message.blocks) { + if (block.type === 'tool-call') { + const parsed = parseToolInput(block.name, block.input) + if (parsed) { + pending = parsed + } + outstanding.push(parsed) + } else if (block.type === 'tool-result' && outstanding.length > 0) { + const resolved = outstanding.shift() + if (resolved && resolved === pending) { + pending = null + } + } + } + } + return pending +} + +/** One question's chosen answer, normalized for delivery: the selected option + * indices (in option order) plus any free-text "other" answer. Index-based (not + * label text) so the answer can be delivered by the selector's stable option + * number — see `buildAskAnswerKeys`. */ +export type AskAnswerSelection = { indices: number[]; other?: string } + +/** A single keystroke group to write to the agent PTY. `raw` bytes (option + * numbers, Enter, arrows) are written verbatim as keystrokes; `text` is a + * free-text answer the caller runs through its paste sanitizer before writing. */ +export type AskAnswerKeyGroup = { raw: string } | { text: string } + +/** True when this question is answered (a picked option or typed free text). */ +function isAnswered(sel: AskAnswerSelection | undefined): boolean { + return (sel?.indices.length ?? 0) > 0 || (sel?.other ?? '').trim().length > 0 +} + +/** The picked labels + trimmed free text for one question, in option order. */ +function answerLabels(question: AskQuestion, sel: AskAnswerSelection | undefined): string[] { + const labels = (sel?.indices ?? []) + .map((i) => question.options[i]?.label ?? '') + .filter((l) => l.length > 0) + const other = (sel?.other ?? '').trim() + return other ? [...labels, other] : labels +} + +/** Build the human-readable answer text: one line per question, in question + * order, each the selected label(s) + free text joined by ", ". Empty answers + * stay empty lines so N lines always == N questions. Used for agents whose + * question tool commits a pasted answer (not Claude's arrow-navigate selector). */ +export function formatAskAnswer(prompt: AskPrompt, selections: AskAnswerSelection[]): string { + return prompt.questions.map((q, i) => answerLabels(q, selections[i]).join(', ')).join('\n') +} + +// Claude's AskUserQuestion is an arrow-navigate selector: a bare Enter commits +// the HIGHLIGHTED default (the first option), and pasted label text does not move +// the highlight — so answering by label silently delivered every non-first pick +// as the first option (STA-1860). Instead we drive the selector by each option's +// stable 1-based number (which matches the card's badge), the marker it commits +// on. Right-arrow steps to the next question / the Submit tab. Verified live +// against Claude Code's TUI; groups are written spaced apart (see the senders) +// because a navigation keystroke batched with Enter commits before the selector +// has applied it. +const ASK_ENTER = '\r' +const ASK_NEXT_TAB = '\x1b[C' + +/** Build the ordered keystroke groups that answer a Claude Code AskUserQuestion. + * Each group is written a step apart so the selector applies it before the next. + * + * - single-select pick → the option number (selects AND commits; in a + * multi-question prompt it auto-advances to the next question) + * - free-text answer → the "Type something" row number, the text, then Enter + * - multi-select → each option number TOGGLES its checkbox, then a step + * to the Submit tab + * - a multi-question prompt (and a lone multi-select) finishes on a Submit + * confirmation, so it ends with one Enter + * + * (Option counts are ≤ the tool's cap of a few, so single-digit numbers always + * address every row.) */ +export function buildAskAnswerKeys( + prompt: AskPrompt, + selections: AskAnswerSelection[] +): AskAnswerKeyGroup[] { + const questions = prompt.questions + const multiQuestion = questions.length > 1 + const groups: AskAnswerKeyGroup[] = [] + + questions.forEach((q, qi) => { + const sel = selections[qi] + const other = (sel?.other ?? '').trim() + const typeSomething = String(q.options.length + 1) + + if (q.multiSelect) { + for (const i of sel?.indices ?? []) { + groups.push({ raw: String(i + 1) }) + } + if (other) { + groups.push({ raw: typeSomething }, { text: other }, { raw: ASK_ENTER }) + } + // A multi-select never auto-advances; step to the next tab (the Submit tab + // when this is the last question). + groups.push({ raw: ASK_NEXT_TAB }) + } else if (other) { + // Single-select can only carry one value, so route any answer that + // includes free text through the "Type something" row as one string. + groups.push( + { raw: typeSomething }, + { text: answerLabels(q, sel).join(', ') }, + { raw: ASK_ENTER } + ) + } else if ((sel?.indices.length ?? 0) > 0) { + groups.push({ raw: String(sel!.indices[0]! + 1) }) + } else if (multiQuestion) { + // Unanswered question in a multi-question prompt: step past it. + groups.push({ raw: ASK_NEXT_TAB }) + } + }) + + const endsOnSubmitTab = + multiQuestion || (questions.length === 1 && questions[0]!.multiSelect === true) + if (endsOnSubmitTab && groups.length > 0) { + groups.push({ raw: ASK_ENTER }) + } + return groups +} + +/** Whether any question in `selections` carries an answer worth submitting. */ +export function hasAskAnswer(prompt: AskPrompt, selections: AskAnswerSelection[]): boolean { + return prompt.questions.some((_, i) => isAnswered(selections[i])) +} diff --git a/src/shared/native-chat-diff.ts b/src/shared/native-chat-diff.ts new file mode 100644 index 000000000000..1e49d1e8969d --- /dev/null +++ b/src/shared/native-chat-diff.ts @@ -0,0 +1,85 @@ +export type NativeChatDiffLineKind = 'add' | 'del' | 'context' | 'meta' + +export type NativeChatDiffLine = { + kind: NativeChatDiffLineKind + text: string +} + +const EDIT_TOOL_NAMES = new Set(['Edit', 'MultiEdit', 'Write', 'str_replace', 'apply_patch']) +const MAX_DIFF_CHARS = 32_000 +const DEFAULT_MAX_DIFF_LINES = 120 +const DIFF_TRUNCATED_LINE: NativeChatDiffLine = { + kind: 'meta', + text: '… diff truncated …' +} + +function toLines(value: unknown, maxLines: number): { lines: string[]; truncated: boolean } { + if (typeof value !== 'string') { + return { lines: [], truncated: false } + } + const clipped = value.slice(0, MAX_DIFF_CHARS) + const lines = clipped.split('\n', maxLines + 1) + const truncated = value.length > MAX_DIFF_CHARS || lines.length > maxLines + const bounded = lines.slice(0, maxLines) + if (!truncated && bounded.at(-1) === '') { + bounded.pop() + } + return { lines: bounded, truncated } +} + +export function diffFromToolCall( + name: string, + input: unknown, + maxLines = DEFAULT_MAX_DIFF_LINES +): NativeChatDiffLine[] | null { + if (!EDIT_TOOL_NAMES.has(name) || typeof input !== 'object' || input === null) { + return null + } + const value = input as Record + const oldLines = toLines(value.old_string ?? value.oldString ?? value.old, maxLines) + const newLines = toLines( + value.new_string ?? value.newString ?? value.new ?? value.content ?? value.file_text, + maxLines + ) + const deleted = oldLines.lines.map((text): NativeChatDiffLine => ({ kind: 'del', text })) + const added = newLines.lines.map((text): NativeChatDiffLine => ({ kind: 'add', text })) + if (deleted.length === 0 && added.length === 0) { + return null + } + const path = value.file_path ?? value.path + const prefix: NativeChatDiffLine[] = + typeof path === 'string' ? [{ kind: 'meta', text: path }] : [] + const combined = [...prefix, ...deleted, ...added] + const truncated = oldLines.truncated || newLines.truncated || combined.length > maxLines + return truncated ? [...combined.slice(0, maxLines - 1), DIFF_TRUNCATED_LINE] : combined +} + +export function diffFromText( + text: string, + maxLines = DEFAULT_MAX_DIFF_LINES +): NativeChatDiffLine[] | null { + if (text.length === 0) { + return null + } + const bounded = toLines(text, maxLines) + let added = 0 + let removed = 0 + const lines = bounded.lines.map((line): NativeChatDiffLine => { + if (line.startsWith('@@') || line.startsWith('diff ') || line.startsWith('index ')) { + return { kind: 'meta', text: line } + } + if (line.startsWith('+') && !line.startsWith('+++')) { + added += 1 + return { kind: 'add', text: line.slice(1) } + } + if (line.startsWith('-') && !line.startsWith('---')) { + removed += 1 + return { kind: 'del', text: line.slice(1) } + } + return { kind: 'context', text: line } + }) + if (added + removed < 2) { + return null + } + return bounded.truncated ? [...lines.slice(0, maxLines - 1), DIFF_TRUNCATED_LINE] : lines +} diff --git a/src/shared/native-chat-empty-state.ts b/src/shared/native-chat-empty-state.ts new file mode 100644 index 000000000000..2797648afd0f --- /dev/null +++ b/src/shared/native-chat-empty-state.ts @@ -0,0 +1,39 @@ +// Canonical English copy for the native-chat empty/loading/error states, shared +// by the desktop renderer (as its i18n fallback strings) and the mobile app +// (used directly — mobile ships English only) so the two surfaces never drift. +// `{{value0}}` is the agent label; each caller substitutes it (i18n on desktop, +// `formatNativeChatEmptyStateCopy` on mobile). + +export type NativeChatEmptyStateCopy = { title: string; subtitle: string } + +export const NATIVE_CHAT_EMPTY_STATE_COPY = { + loading: { + title: 'Loading conversation…', + subtitle: 'Reading the agent transcript.' + }, + empty: { + title: 'Start a chat with {{value0}}', + subtitle: 'Ask {{value0}} to inspect code, explain output, or make a change.' + }, + error: { + title: 'Could not load conversation', + subtitle: 'The transcript could not be read. Toggle back to the terminal to keep working.' + }, + notAgent: { + title: 'No conversation here', + subtitle: 'This terminal is not running a recognized coding agent.' + } +} as const satisfies Record + +/** Resolve the empty-state copy with the agent label substituted for `{{value0}}`. + * For platforms without an i18n layer (mobile). */ +export function formatNativeChatEmptyStateCopy( + kind: keyof typeof NATIVE_CHAT_EMPTY_STATE_COPY, + agentLabel: string +): NativeChatEmptyStateCopy { + const copy = NATIVE_CHAT_EMPTY_STATE_COPY[kind] + return { + title: copy.title.replaceAll('{{value0}}', agentLabel), + subtitle: copy.subtitle.replaceAll('{{value0}}', agentLabel) + } +} diff --git a/src/shared/native-chat-merge.ts b/src/shared/native-chat-merge.ts index d662fec3891e..a2e12ad8302b 100644 --- a/src/shared/native-chat-merge.ts +++ b/src/shared/native-chat-merge.ts @@ -1,13 +1,10 @@ -// Pure id-dedup merge for native-chat message windows, shared by the desktop -// renderer live path. Mobile keeps a parity-locked twin of this algorithm -// (mobile/src/session/mobile-native-chat-merge.ts) because Metro can't resolve -// runtime values outside the mobile package — the cross-surface parity test -// (native-chat-merge-parity.test.ts) pins the two implementations together. -// -// The algorithm is parameterized on a source-priority map so the single body -// works for any caller that supplies NATIVE_CHAT_SOURCE_PRIORITY. +// Pure id-dedup and windowing for both desktop and mobile native-chat streams. -import type { NativeChatMessage, NativeChatSource } from './native-chat-types' +import { + NATIVE_CHAT_SOURCE_PRIORITY, + type NativeChatMessage, + type NativeChatSource +} from './native-chat-types' export type NativeChatSourcePriority = Record @@ -31,6 +28,13 @@ export function mergeNativeChatMessagesWith( return merged } +export function mergeNativeChatMessages( + existing: readonly NativeChatMessage[], + incoming: readonly NativeChatMessage[] +): NativeChatMessage[] { + return mergeNativeChatMessagesWith(existing, incoming, NATIVE_CHAT_SOURCE_PRIORITY) +} + /** Cap a message list to its most-recent `limit` entries. The base read is * already windowed; this keeps the live-append tail bounded to the same window * so a long run can't grow the list without limit. A non-positive limit means @@ -56,7 +60,9 @@ export type NativeChatMerger = { readonly priority: NativeChatSourcePriority } -export function createNativeChatMerger(priority: NativeChatSourcePriority): NativeChatMerger { +export function createNativeChatMerger( + priority: NativeChatSourcePriority = NATIVE_CHAT_SOURCE_PRIORITY +): NativeChatMerger { return { list: [], indexById: new Map(), priority } } @@ -73,13 +79,20 @@ export function replaceList(merger: NativeChatMerger, list: readonly NativeChatM * index incrementally — O(incoming), never re-scanning the existing list. */ export function applyAppend( merger: NativeChatMerger, - incoming: readonly NativeChatMessage[] + incoming: readonly NativeChatMessage[], + limit?: number ): NativeChatMessage[] { if (incoming.length === 0) { return merger.list } const next = [...merger.list] applyIncoming(next, merger.indexById, incoming, merger.priority) + const bounded = limit === undefined ? next : boundNativeChatWindow(next, limit) + if (bounded !== next) { + // Why: trimming shifts every cached index, so rebuild at the window boundary. + replaceList(merger, bounded) + return merger.list + } merger.list = next return next } diff --git a/src/shared/native-chat-noise.ts b/src/shared/native-chat-noise.ts new file mode 100644 index 000000000000..ef75c81f751e --- /dev/null +++ b/src/shared/native-chat-noise.ts @@ -0,0 +1,25 @@ +import { isKnownHarnessInjectedUserTurnText } from './harness-injected-user-turns' +import { isTextBlock, type NativeChatMessage } from './native-chat-types' + +function messageText(message: NativeChatMessage): string { + return message.blocks + .filter(isTextBlock) + .map((block) => block.text) + .join('') + .trim() +} + +/** Harness-injected user/system turns are transport machinery, not conversation. */ +export function isNoiseMessage(message: NativeChatMessage): boolean { + if (message.role !== 'user' && message.role !== 'system') { + return false + } + if (message.blocks.some((block) => block.type === 'tool-call' || block.type === 'tool-result')) { + return false + } + return isKnownHarnessInjectedUserTurnText(messageText(message)) +} + +export function stripNoiseMessages(messages: readonly NativeChatMessage[]): NativeChatMessage[] { + return messages.filter((message) => !isNoiseMessage(message)) +} diff --git a/src/shared/native-chat-stream-unsubscribe.test.ts b/src/shared/native-chat-stream-unsubscribe.test.ts index 01c4c3e0db84..60f3cc260993 100644 --- a/src/shared/native-chat-stream-unsubscribe.test.ts +++ b/src/shared/native-chat-stream-unsubscribe.test.ts @@ -15,4 +15,11 @@ describe('native-chat stream unsubscribe key builder', () => { params: { subscriptionId: 'codex:abc' } }) }) + + it('echoes a pane-specific token when the subscriber supplied one', () => { + expect(buildNativeChatUnsubscribe('claude', 'same-session', 'pane-2')).toEqual({ + method: 'nativeChat.unsubscribe', + params: { subscriptionId: 'pane-2' } + }) + }) }) diff --git a/src/shared/native-chat-stream-unsubscribe.ts b/src/shared/native-chat-stream-unsubscribe.ts index 76475ba32cba..ae7c52587feb 100644 --- a/src/shared/native-chat-stream-unsubscribe.ts +++ b/src/shared/native-chat-stream-unsubscribe.ts @@ -18,10 +18,11 @@ export function buildNativeChatSubscriptionId(agent: string, sessionId: string): /** The unsubscribe RPC frame a client sends on teardown to close the watcher. */ export function buildNativeChatUnsubscribe( agent: string, - sessionId: string + sessionId: string, + subscriptionId?: string ): NativeChatUnsubscribeRpc { return { method: 'nativeChat.unsubscribe', - params: { subscriptionId: buildNativeChatSubscriptionId(agent, sessionId) } + params: { subscriptionId: subscriptionId ?? buildNativeChatSubscriptionId(agent, sessionId) } } } diff --git a/src/shared/native-chat-tool-fold.ts b/src/shared/native-chat-tool-fold.ts new file mode 100644 index 000000000000..1b363a280fa1 --- /dev/null +++ b/src/shared/native-chat-tool-fold.ts @@ -0,0 +1,93 @@ +import { + isToolCallBlock, + isToolResultBlock, + type NativeChatBlock, + type NativeChatMessage, + type NativeChatToolCallBlock, + type NativeChatToolResultBlock +} from './native-chat-types' + +function isToolOnlyMessage(message: NativeChatMessage): boolean { + return ( + message.blocks.length > 0 && + message.blocks.every((block) => isToolCallBlock(block) || isToolResultBlock(block)) + ) +} + +/** Fold consecutive tool-only messages into their preceding assistant turn. */ +export function foldToolMessages(messages: readonly NativeChatMessage[]): NativeChatMessage[] { + const output: NativeChatMessage[] = [] + let mutableAssistantIndex = -1 + for (const message of messages) { + const previous = output.at(-1) + if (isToolOnlyMessage(message) && previous?.role === 'assistant') { + const index = output.length - 1 + if (mutableAssistantIndex !== index) { + output[index] = { ...previous, blocks: [...previous.blocks] } + mutableAssistantIndex = index + } + output[index]!.blocks.push(...message.blocks) + } else { + output.push(message) + mutableAssistantIndex = -1 + } + } + return output +} + +export type NativeChatToolPair = { + call?: NativeChatToolCallBlock + result?: NativeChatToolResultBlock +} + +/** Pair calls and results by FIFO ordinal because transcript blocks carry no tool ids. */ +export function pairToolBlocks( + blocks: readonly NativeChatBlock[], + limit = Infinity +): NativeChatToolPair[] { + const pairs: NativeChatToolPair[] = [] + const callSlots: (number | null)[] = [] + let resultOrdinal = 0 + for (const block of blocks) { + if (block.type === 'tool-call') { + if (pairs.length < limit) { + callSlots.push(pairs.length) + pairs.push({ call: block }) + } else { + callSlots.push(null) + } + continue + } + if (block.type !== 'tool-result') { + continue + } + const slot = callSlots[resultOrdinal] + if (slot === undefined) { + if (pairs.length < limit) { + pairs.push({ result: block }) + } + } else { + resultOrdinal += 1 + if (slot !== null) { + pairs[slot]!.result = block + } + } + } + return pairs +} + +export function splitNativeChatBlocks(blocks: readonly NativeChatBlock[]): { + prose: NativeChatBlock[] + tools: NativeChatBlock[] +} { + const prose: NativeChatBlock[] = [] + const tools: NativeChatBlock[] = [] + for (const block of blocks) { + if (isToolCallBlock(block) || isToolResultBlock(block)) { + tools.push(block) + } else { + prose.push(block) + } + } + return { prose, tools } +} diff --git a/src/shared/native-chat-tool-summary.test.ts b/src/shared/native-chat-tool-summary.test.ts new file mode 100644 index 000000000000..ef6b385806d0 --- /dev/null +++ b/src/shared/native-chat-tool-summary.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatBlock } from './native-chat-types' +import { briefToolArg, summarizeToolInput, summarizeToolRun } from './native-chat-tool-summary' + +describe('summarizeToolInput bounded preview', () => { + it('collapses depth beyond the bound instead of serializing the whole tree', () => { + const deep = { a: { b: { c: { d: 'buried' } } } } + const preview = summarizeToolInput(deep) + expect(preview).toContain('[…]') + expect(preview).not.toContain('buried') + }) + + it('truncates oversized collections with an ellipsis marker', () => { + const wide = Object.fromEntries(Array.from({ length: 12 }, (_, i) => [`k${i}`, i])) + const preview = summarizeToolInput(wide) + expect(preview).toContain('…') + expect(preview).not.toContain('k11') + }) + + it('survives circular references', () => { + const cyclic: Record = { name: 'loop' } + cyclic.self = cyclic + expect(summarizeToolInput(cyclic)).toContain('[circular]') + }) +}) + +describe('briefToolArg', () => { + it('extracts the basename from forward- and backslash paths', () => { + expect(briefToolArg({ file_path: 'src/app/main.tsx' })).toBe('main.tsx') + expect(briefToolArg({ file_path: 'C:\\Users\\me\\project\\app.tsx' })).toBe('app.tsx') + }) + + it('falls back to the command preview when no path is present', () => { + expect(briefToolArg({ command: 'git status --short' })).toBe('git status --short') + }) +}) + +describe('summarizeToolRun', () => { + it('caps the run summary and skips nameless calls', () => { + const blocks: NativeChatBlock[] = [ + { type: 'tool-call', name: ' ', input: {} }, + { type: 'tool-call', name: 'Bash', input: { command: 'ls' } }, + { type: 'tool-call', name: 'Read', input: { file_path: 'a.ts' } }, + { type: 'tool-call', name: 'Edit', input: { file_path: 'b.ts' } }, + { type: 'tool-call', name: 'Write', input: { file_path: 'c.ts' } } + ] + const summary = summarizeToolRun(blocks) + expect(summary).toBe('Bash ls · Read a.ts · Edit b.ts') + }) +}) diff --git a/src/shared/native-chat-tool-summary.ts b/src/shared/native-chat-tool-summary.ts new file mode 100644 index 000000000000..efe606f55a98 --- /dev/null +++ b/src/shared/native-chat-tool-summary.ts @@ -0,0 +1,140 @@ +import { isToolCallBlock, type NativeChatBlock } from './native-chat-types' + +const MAX_PREVIEW_LENGTH = 80 +const MAX_PREVIEW_STRING_INPUT = 160 +const MAX_PREVIEW_COLLECTION_ITEMS = 8 +const MAX_PREVIEW_DEPTH = 2 +const MAX_TOOL_RUN_SUMMARY_PARTS = 3 + +export function summarizeToolInput(input: unknown): string { + const collapsed = toRawPreview(input).replace(/\s+/g, ' ').trim() + return collapsed.length <= MAX_PREVIEW_LENGTH + ? collapsed + : `${collapsed.slice(0, MAX_PREVIEW_LENGTH - 1)}…` +} + +/** Full, pretty-printed tool-call input for the expanded detail view. Strings + * pass through as-is; objects/arrays print as indented JSON so a diff-less call + * (e.g. a question payload) reads cleanly instead of one long minified line. */ +export function formatToolInput(input: unknown): string { + if (input === null || input === undefined) { + return '' + } + if (typeof input === 'string') { + return input + } + if (typeof input === 'number' || typeof input === 'boolean') { + return String(input) + } + try { + return JSON.stringify(input, null, 2) ?? '' + } catch { + return '' + } +} + +export function toolFilePath(input: unknown): string | null { + if (!input || typeof input !== 'object') { + return null + } + const value = input as Record + const path = value.file_path ?? value.filePath ?? value.path ?? value.notebook_path + return typeof path === 'string' && path.length > 0 ? path : null +} + +export function briefToolArg(input: unknown): string { + if (input && typeof input === 'object') { + const value = input as Record + const path = value.file_path ?? value.filePath ?? value.path ?? value.notebook_path + if (typeof path === 'string' && path.length > 0) { + const parts = path.split(/[\\/]/).filter(Boolean) + return parts.at(-1) ?? path + } + const command = value.command ?? value.cmd ?? value.query ?? value.pattern + if (typeof command === 'string') { + return summarizeToolInput(command).slice(0, 28) + } + } + return summarizeToolInput(input).slice(0, 28) +} + +export function summarizeToolRun(blocks: readonly NativeChatBlock[]): string { + const parts: string[] = [] + for (const block of blocks) { + if (!isToolCallBlock(block)) { + continue + } + const name = block.name.trim() + if (!name) { + continue + } + const detail = briefToolArg(block.input) + parts.push(detail ? `${name} ${detail}` : name) + if (parts.length >= MAX_TOOL_RUN_SUMMARY_PARTS) { + break + } + } + return parts.join(' · ') +} + +export function countToolCalls(blocks: readonly NativeChatBlock[]): number { + return blocks.filter(isToolCallBlock).length +} + +function toRawPreview(input: unknown): string { + if (input === null || input === undefined) { + return '' + } + if (typeof input === 'string') { + return input + } + if (typeof input !== 'object') { + return String(input) + } + try { + return JSON.stringify(boundedPreviewValue(input, 0, new WeakSet())) ?? '' + } catch { + return '' + } +} + +function boundedPreviewValue(value: unknown, depth: number, seen: WeakSet): unknown { + if (typeof value === 'string') { + return value.length > MAX_PREVIEW_STRING_INPUT + ? `${value.slice(0, MAX_PREVIEW_STRING_INPUT)}…` + : value + } + if (!value || typeof value !== 'object') { + return value + } + if (seen.has(value)) { + return '[circular]' + } + if (depth >= MAX_PREVIEW_DEPTH) { + return '[…]' + } + seen.add(value) + if (Array.isArray(value)) { + const result = value + .slice(0, MAX_PREVIEW_COLLECTION_ITEMS) + .map((item) => boundedPreviewValue(item, depth + 1, seen)) + if (value.length > MAX_PREVIEW_COLLECTION_ITEMS) { + result.push('…') + } + return result + } + const result: Record = {} + let count = 0 + for (const key in value) { + if (!Object.prototype.hasOwnProperty.call(value, key)) { + continue + } + if (count >= MAX_PREVIEW_COLLECTION_ITEMS) { + result['…'] = '…' + break + } + result[key] = boundedPreviewValue((value as Record)[key], depth + 1, seen) + count += 1 + } + return result +} diff --git a/src/shared/quick-open-readdir-budget.ts b/src/shared/quick-open-readdir-budget.ts new file mode 100644 index 000000000000..14fd599cf647 --- /dev/null +++ b/src/shared/quick-open-readdir-budget.ts @@ -0,0 +1,38 @@ +export const QUICK_OPEN_READDIR_MAX_FILES = 10_000 +export const QUICK_OPEN_READDIR_TIMEOUT_MS = 10_000 + +export type QuickOpenReaddirBudget = { + remainingFiles: number + deadlineMs: number +} + +export function createQuickOpenReaddirBudget( + opts: { maxFiles?: number; timeoutMs?: number; nowMs?: number } = {} +): QuickOpenReaddirBudget { + return { + remainingFiles: opts.maxFiles ?? QUICK_OPEN_READDIR_MAX_FILES, + deadlineMs: (opts.nowMs ?? Date.now()) + (opts.timeoutMs ?? QUICK_OPEN_READDIR_TIMEOUT_MS) + } +} + +const FILE_LISTING_TIMED_OUT = 'File listing timed out' +const FILE_LISTING_EXCEEDED_PREFIX = 'File listing exceeded' + +/** Budget errors are the only fallback failures translated into install-rg guidance. */ +export function isQuickOpenReaddirBudgetError(error: unknown): boolean { + const message = error instanceof Error ? error.message : '' + return message === FILE_LISTING_TIMED_OUT || message.startsWith(FILE_LISTING_EXCEEDED_PREFIX) +} + +export function assertQuickOpenReaddirDeadline(budget: QuickOpenReaddirBudget): void { + if (Date.now() > budget.deadlineMs) { + throw new Error(FILE_LISTING_TIMED_OUT) + } +} + +export function consumeQuickOpenReaddirFileBudget(budget: QuickOpenReaddirBudget): void { + if (budget.remainingFiles <= 0) { + throw new Error(`${FILE_LISTING_EXCEEDED_PREFIX} ${QUICK_OPEN_READDIR_MAX_FILES} files`) + } + budget.remainingFiles-- +} diff --git a/src/shared/quick-open-readdir-walk.test.ts b/src/shared/quick-open-readdir-walk.test.ts index 2331bc3b1c32..e6894bd815cc 100644 --- a/src/shared/quick-open-readdir-walk.test.ts +++ b/src/shared/quick-open-readdir-walk.test.ts @@ -434,6 +434,17 @@ describe('quick-open readdir walk', () => { ).rejects.toThrow('File listing timed out') }) + it('returns a successful bounded prefix without consuming the safety cap', async () => { + const root = await makeTempRoot() + await writeRel(root, 'a.ts') + await writeRel(root, 'b.ts') + + const files = await listQuickOpenFilesWithReaddir(root, { maxResults: 1 }) + + expect(files).toHaveLength(1) + expect(['a.ts', 'b.ts']).toContain(files[0]) + }) + it('does not list symlinked files or follow symlinked directories', async () => { const root = await makeTempRoot() await writeRel(root, 'src/index.ts') diff --git a/src/shared/quick-open-readdir-walk.ts b/src/shared/quick-open-readdir-walk.ts index ada9442cd23d..fbbb24963cc6 100644 --- a/src/shared/quick-open-readdir-walk.ts +++ b/src/shared/quick-open-readdir-walk.ts @@ -8,16 +8,22 @@ import { shouldExcludeQuickOpenRelPath, shouldIncludeQuickOpenPath } from './quick-open-filter' +import { + assertQuickOpenReaddirDeadline, + consumeQuickOpenReaddirFileBudget, + createQuickOpenReaddirBudget, + type QuickOpenReaddirBudget +} from './quick-open-readdir-budget' + +export { + createQuickOpenReaddirBudget, + isQuickOpenReaddirBudgetError, + QUICK_OPEN_READDIR_MAX_FILES, + QUICK_OPEN_READDIR_TIMEOUT_MS +} from './quick-open-readdir-budget' -export const QUICK_OPEN_READDIR_MAX_FILES = 10_000 -export const QUICK_OPEN_READDIR_TIMEOUT_MS = 10_000 const QUICK_OPEN_READDIR_CONCURRENCY = 32 -export type QuickOpenReaddirBudget = { - remainingFiles: number - deadlineMs: number -} - export type QuickOpenGitEntryKind = 'keep' | 'fill-nested-repo' | 'drop-placeholder' export type QuickOpenGitLsFilesEntry = { @@ -44,42 +50,6 @@ export function parseQuickOpenGitLsFilesEntry(entry: string): QuickOpenGitLsFile } } -export function createQuickOpenReaddirBudget( - opts: { maxFiles?: number; timeoutMs?: number; nowMs?: number } = {} -): QuickOpenReaddirBudget { - return { - remainingFiles: opts.maxFiles ?? QUICK_OPEN_READDIR_MAX_FILES, - deadlineMs: (opts.nowMs ?? Date.now()) + (opts.timeoutMs ?? QUICK_OPEN_READDIR_TIMEOUT_MS) - } -} - -const FILE_LISTING_TIMED_OUT = 'File listing timed out' -const FILE_LISTING_EXCEEDED_PREFIX = 'File listing exceeded' - -/** - * Why: the readdir walk can exhaust its cap/deadline even on the git path (a - * git monorepo parent with a huge nested repo). Callers translate only these - * budget errors into "install rg" guidance, leaving genuine git failures with - * their own messages. - */ -export function isQuickOpenReaddirBudgetError(error: unknown): boolean { - const message = error instanceof Error ? error.message : '' - return message === FILE_LISTING_TIMED_OUT || message.startsWith(FILE_LISTING_EXCEEDED_PREFIX) -} - -function assertWithinDeadline(budget: QuickOpenReaddirBudget): void { - if (Date.now() > budget.deadlineMs) { - throw new Error(FILE_LISTING_TIMED_OUT) - } -} - -function consumeFileBudget(budget: QuickOpenReaddirBudget): void { - if (budget.remainingFiles <= 0) { - throw new Error(`${FILE_LISTING_EXCEEDED_PREFIX} ${QUICK_OPEN_READDIR_MAX_FILES} files`) - } - budget.remainingFiles-- -} - function shouldDescend(name: string): boolean { return name !== 'node_modules' && !HIDDEN_DIR_BLOCKLIST.has(name) } @@ -162,6 +132,7 @@ export async function listQuickOpenFilesWithReaddir( excludePathPrefixes?: readonly string[] workspaceRelPathPrefix?: string budget?: QuickOpenReaddirBudget + maxResults?: number signal?: AbortSignal } = {} ): Promise { @@ -175,7 +146,8 @@ export async function listQuickOpenFilesWithReaddir( } ], opts.budget ?? createQuickOpenReaddirBudget(), - opts.signal + opts.signal, + opts.maxResults ) } @@ -191,9 +163,13 @@ type QuickOpenReaddirRoot = { async function listQuickOpenFilesFromRoots( roots: readonly QuickOpenReaddirRoot[], budget: QuickOpenReaddirBudget, - signal?: AbortSignal + signal?: AbortSignal, + maxResults?: number ): Promise { const files: string[] = [] + if (maxResults !== undefined && maxResults <= 0) { + return files + } let pendingDirectories = roots.map((root) => ({ root, absPath: root.rootPath, @@ -211,7 +187,7 @@ async function listQuickOpenFilesFromRoots( // so the shared cap remains exact while shallow placeholder-heavy repos // do not pay one relay event-loop turn per directory. throwIfFileListingCancelled(signal) - assertWithinDeadline(budget) + assertQuickOpenReaddirDeadline(budget) const batch = pendingDirectories.slice(offset, offset + QUICK_OPEN_READDIR_CONCURRENCY) const entryGroups = await Promise.all( batch.map(async (pending) => { @@ -240,12 +216,12 @@ async function listQuickOpenFilesFromRoots( // Why: an empty directory has no per-entry checkpoint below. Cancellation // or timeout that lands during readdir must still reject, never resolve []. throwIfFileListingCancelled(signal) - assertWithinDeadline(budget) + assertQuickOpenReaddirDeadline(budget) for (const { pending, entries } of entryGroups) { for (const entry of entries) { throwIfFileListingCancelled(signal) - assertWithinDeadline(budget) + assertQuickOpenReaddirDeadline(budget) const name = entry.name const absPath = join(pending.absPath, name) @@ -266,12 +242,17 @@ async function listQuickOpenFilesFromRoots( (entry.isFile() || (pending.root.includeSymlinks && entry.isSymbolicLink())) && shouldIncludeQuickOpenPath(workspaceRelPath) ) { - consumeFileBudget(budget) + consumeQuickOpenReaddirFileBudget(budget) files.push( pending.root.outputPathPrefix ? `${pending.root.outputPathPrefix}/${relPath}` : relPath ) + // Why: a caller result limit is a successful bounded prefix, while + // the separate traversal budget still rejects incomplete scans. + if (maxResults !== undefined && files.length >= maxResults) { + return files + } } } } @@ -288,6 +269,7 @@ export async function expandQuickOpenGitFileListing(opts: { directoryPaths?: Iterable excludePathPrefixes?: readonly string[] budget?: QuickOpenReaddirBudget + maxResults?: number signal?: AbortSignal }): Promise { const files = new Set() @@ -309,7 +291,7 @@ export async function expandQuickOpenGitFileListing(opts: { for (const rawPath of opts.gitPaths) { throwIfFileListingCancelled(opts.signal) - assertWithinDeadline(budget) + assertQuickOpenReaddirDeadline(budget) const { kind, relPath } = await classifyQuickOpenGitEntry(opts.rootPath, rawPath) if (kind === 'keep') { @@ -325,7 +307,7 @@ export async function expandQuickOpenGitFileListing(opts: { for (const rawPath of opts.directoryPaths ?? []) { throwIfFileListingCancelled(opts.signal) - assertWithinDeadline(budget) + assertQuickOpenReaddirDeadline(budget) const relPath = normalizeGitEntry(rawPath) // Why: Git intentionally leaves collapsed directories unexpanded; reject @@ -356,11 +338,12 @@ export async function expandQuickOpenGitFileListing(opts: { includeSymlinks })), budget, - opts.signal + opts.signal, + opts.maxResults === undefined ? undefined : Math.max(0, opts.maxResults - files.size) ) for (const expandedFile of expandedFiles) { addFinalPath(expandedFile) } - return Array.from(files) + return Array.from(files).slice(0, opts.maxResults) } diff --git a/tests/e2e/terminal-push-delivery-loss-recovery.spec.ts b/tests/e2e/terminal-push-delivery-loss-recovery.spec.ts index 395353cbe487..9d3b4914147b 100644 --- a/tests/e2e/terminal-push-delivery-loss-recovery.spec.ts +++ b/tests/e2e/terminal-push-delivery-loss-recovery.spec.ts @@ -95,8 +95,8 @@ test.describe('terminal push-delivery loss recovery', () => { async () => orcaPage.evaluate( () => - (window as DeliveryWatchdogWindow).__terminalDeliveryWatchdog?.snapshot()?.healCount ?? - 0 + (window as DeliveryWatchdogWindow).__terminalDeliveryWatchdog?.snapshot() + ?.healCount ?? 0 ), { timeout: 30_000 } ) From 4e232a030f67bf64e77a03a0507e35cef21e27a6 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:12:47 -0700 Subject: [PATCH 084/351] fix(usage): show CLI-owned session recovery steps (#9069) Co-authored-by: Orca --- src/main/rate-limits/grok-fetcher.test.ts | 7 ++ src/main/rate-limits/grok-fetcher.ts | 21 ++++-- src/main/rate-limits/kimi-fetcher.test.ts | 5 ++ src/main/rate-limits/kimi-fetcher.ts | 28 ++++++-- .../settings/GrokAccountsSection.test.tsx | 69 +++++++++++++++++++ .../settings/GrokAccountsSection.tsx | 8 +-- .../src/components/status-bar/tooltip.test.ts | 32 +++++++-- .../components/status-bar/usage-error-copy.ts | 31 +++++++++ src/renderer/src/i18n/locales/en.json | 10 ++- src/renderer/src/i18n/locales/es.json | 10 ++- src/renderer/src/i18n/locales/ja.json | 10 ++- src/renderer/src/i18n/locales/ko.json | 10 ++- src/renderer/src/i18n/locales/zh.json | 10 ++- 13 files changed, 222 insertions(+), 29 deletions(-) create mode 100644 src/renderer/src/components/settings/GrokAccountsSection.test.tsx diff --git a/src/main/rate-limits/grok-fetcher.test.ts b/src/main/rate-limits/grok-fetcher.test.ts index e387a13e3a18..b077b880576a 100644 --- a/src/main/rate-limits/grok-fetcher.test.ts +++ b/src/main/rate-limits/grok-fetcher.test.ts @@ -241,6 +241,13 @@ describe('fetchGrokRateLimits', () => { const result = await fetchGrokRateLimits() expect(result.status).toBe('error') expect(result.error).toMatch(/expired/i) + expect(result.error).toMatch(/run grok on the computer running Orca/i) + expect(result.error).toMatch(/sign in if prompted/i) + expect(result.error).toMatch(/no chat message is needed/i) + expect(result.usageMetadata).toEqual({ + failureKind: 'delegated-refresh-required', + source: 'oauth' + }) // Why: a stored-but-expired access token is refreshed by Grok CLI on next // use (a genuine sign-out returns 'missing'), so the message must not tell // users to re-run `grok login` (#8497). diff --git a/src/main/rate-limits/grok-fetcher.ts b/src/main/rate-limits/grok-fetcher.ts index eaa140eb2be2..1e6d20f5ccec 100644 --- a/src/main/rate-limits/grok-fetcher.ts +++ b/src/main/rate-limits/grok-fetcher.ts @@ -1,5 +1,9 @@ import { net } from 'electron' -import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' +import type { + ProviderRateLimits, + RateLimitWindow, + UsageRateLimitMetadata +} from '../../shared/rate-limit-types' import { isGrokAccessTokenFresh, readGrokAuthSession, @@ -47,14 +51,19 @@ type GrokBillingResponse = GrokBillingConfig & { config?: GrokBillingConfig } -function result(status: ProviderRateLimits['status'], error: string | null): ProviderRateLimits { +function result( + status: ProviderRateLimits['status'], + error: string | null, + usageMetadata?: UsageRateLimitMetadata +): ProviderRateLimits { return { provider: 'grok', session: null, weekly: null, updatedAt: Date.now(), error, - status + status, + ...(usageMetadata ? { usageMetadata } : {}) } } @@ -225,7 +234,11 @@ export async function fetchGrokRateLimits( // Why: a genuine sign-out returns 'missing' earlier, so reaching here always // means a stored, refreshable session — Grok CLI refreshes the access token // on its next run, so don't tell users to re-run `grok login` (#8497). - return result('error', 'Grok access token expired — Grok CLI will refresh it on next use') + return result( + 'error', + 'Grok sign-in expired — run grok on the computer running Orca; sign in if prompted. No chat message is needed.', + { failureKind: 'delegated-refresh-required', source: 'oauth' } + ) } try { diff --git a/src/main/rate-limits/kimi-fetcher.test.ts b/src/main/rate-limits/kimi-fetcher.test.ts index a6274097f5f2..58f11dfdb03e 100644 --- a/src/main/rate-limits/kimi-fetcher.test.ts +++ b/src/main/rate-limits/kimi-fetcher.test.ts @@ -140,6 +140,11 @@ describe('fetchKimiRateLimits', () => { const result = await fetchKimiRateLimits() expect(result.status).toBe('error') expect(result.error).toMatch(/expired/i) + expect(result.error).toMatch(/run kimi on the computer running Orca/i) + expect(result.usageMetadata).toEqual({ + failureKind: 'delegated-refresh-required', + source: 'oauth' + }) expect(netFetchMock).not.toHaveBeenCalled() }) }) diff --git a/src/main/rate-limits/kimi-fetcher.ts b/src/main/rate-limits/kimi-fetcher.ts index f957e171d88f..778b90b796cb 100644 --- a/src/main/rate-limits/kimi-fetcher.ts +++ b/src/main/rate-limits/kimi-fetcher.ts @@ -2,7 +2,11 @@ import { existsSync, readFileSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' import { net } from 'electron' -import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' +import type { + ProviderRateLimits, + RateLimitWindow, + UsageRateLimitMetadata +} from '../../shared/rate-limit-types' // Why: Kimi Code's managed coding plan exposes subscription usage at // `${base}/usages` (see packages/oauth/src/managed-usage.ts in the CLI bundle). @@ -208,8 +212,20 @@ function mapUsageResponse(data: KimiUsageResponse): ProviderRateLimits { } } -function result(status: ProviderRateLimits['status'], error: string | null): ProviderRateLimits { - return { provider: 'kimi', session: null, weekly: null, updatedAt: Date.now(), error, status } +function result( + status: ProviderRateLimits['status'], + error: string | null, + usageMetadata?: UsageRateLimitMetadata +): ProviderRateLimits { + return { + provider: 'kimi', + session: null, + weekly: null, + updatedAt: Date.now(), + error, + status, + ...(usageMetadata ? { usageMetadata } : {}) + } } /** @@ -239,7 +255,11 @@ export async function fetchKimiRateLimits(): Promise { // Why: don't refresh — the CLI owns the token lifecycle. Report a transient // error so the rate-limit service keeps the last good snapshot (stale // policy) until the user next runs Kimi and the CLI refreshes the file. - return result('error', 'Kimi token expired — open Kimi to refresh') + return result( + 'error', + 'Kimi session expired — run kimi on the computer running Orca, then retry usage.', + { failureKind: 'delegated-refresh-required', source: 'oauth' } + ) } try { diff --git a/src/renderer/src/components/settings/GrokAccountsSection.test.tsx b/src/renderer/src/components/settings/GrokAccountsSection.test.tsx new file mode 100644 index 000000000000..cd418597719a --- /dev/null +++ b/src/renderer/src/components/settings/GrokAccountsSection.test.tsx @@ -0,0 +1,69 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import React from 'react' +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getStatus: vi.fn(), + refreshGrokRateLimits: vi.fn() +})) + +vi.mock('@/lib/agent-catalog', () => ({ + AgentIcon: () => React.createElement('span', { 'data-testid': 'grok-icon' }) +})) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, values?: Record) => { + let result = fallback + for (const [key, value] of Object.entries(values ?? {})) { + result = result.replace(`{{${key}}}`, value) + } + return result + } +})) + +vi.mock('../../store', () => ({ + useAppStore: (selector: (state: Record) => unknown) => + selector({ + refreshGrokRateLimits: mocks.refreshGrokRateLimits, + rateLimits: { grok: null } + }) +})) + +import { GrokAccountsSection } from './GrokAccountsSection' + +describe('GrokAccountsSection', () => { + beforeEach(() => { + mocks.getStatus.mockResolvedValue({ + signedIn: true, + email: 'dev@example.com', + teamId: null, + tokenFresh: false, + error: null + }) + mocks.refreshGrokRateLimits.mockResolvedValue(undefined) + Object.defineProperty(window, 'api', { + configurable: true, + value: { grokAccounts: { getStatus: mocks.getStatus } } + }) + }) + + afterEach(() => { + cleanup() + vi.clearAllMocks() + }) + + it('explains the host-scoped Grok recovery flow without requiring a chat message', async () => { + render() + + expect( + await screen.findByText( + 'Session expired — run grok on the computer running Orca and wait for it to start. If prompted, complete sign-in, then click Refresh usage. No chat message is needed.' + ) + ).toBeInTheDocument() + expect(screen.queryByText(/grok login/i)).not.toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/components/settings/GrokAccountsSection.tsx b/src/renderer/src/components/settings/GrokAccountsSection.tsx index 474c4c1ee66a..8df194dbd2df 100644 --- a/src/renderer/src/components/settings/GrokAccountsSection.tsx +++ b/src/renderer/src/components/settings/GrokAccountsSection.tsx @@ -109,12 +109,12 @@ export function GrokAccountsSection(): React.JSX.Element {

{tokenFresh ? translate( - 'auto.components.settings.GrokAccountsSection.c3d4e5f6a7', - 'Signed in. Orca only reads that file on disk — run grok login again if usage fails.' + 'auto.components.settings.GrokAccountsSection.b36fa2c908', + 'Signed in. Orca reads the Grok CLI session stored on disk.' ) : translate( - 'auto.components.settings.GrokAccountsSection.d4e5f6a7b8', - 'Session expired — run grok login in a terminal to refresh.' + 'auto.components.settings.GrokAccountsSection.f08c41de73', + 'Session expired — run grok on the computer running Orca and wait for it to start. If prompted, complete sign-in, then click Refresh usage. No chat message is needed.' )}

diff --git a/src/renderer/src/components/status-bar/tooltip.test.ts b/src/renderer/src/components/status-bar/tooltip.test.ts index 87e12d660486..ed80c7b9ab87 100644 --- a/src/renderer/src/components/status-bar/tooltip.test.ts +++ b/src/renderer/src/components/status-bar/tooltip.test.ts @@ -135,18 +135,36 @@ describe('provider usage error copy', () => { ) }) - it('keeps the reworded Grok expired-token error classified as an auth failure (#8497)', () => { - // Why: the fix (grok-fetcher.ts) dropped the "run grok login" wording that - // used to trigger auth classification; this pins that the new copy still - // resolves to the softer refresh message instead of leaking the raw string. + it('shows the exact Grok CLI recovery flow for an expired refreshable session (#8497)', () => { const grok = provider({ provider: 'grok', - error: 'Grok access token expired — Grok CLI will refresh it on next use' + error: + 'Grok sign-in expired — run grok on the computer running Orca; sign in if prompted. No chat message is needed.', + usageMetadata: { + failureKind: 'delegated-refresh-required', + source: 'oauth' + } }) - expect(getProviderUsageStatusLabel(grok)).toBe('Refresh failed') + expect(getProviderUsageStatusLabel(grok)).toBe('Run Grok to refresh') expect(getProviderUsageErrorMessage(grok)).toBe( - 'Grok usage could not be refreshed. Agent sessions may still be signed in.' + 'Run grok in a terminal on the computer running Orca and wait for it to start. If prompted, complete sign-in, then retry usage. You do not need to send a chat message.' + ) + }) + + it('shows the exact Kimi CLI recovery flow for an expired read-only session', () => { + const kimi = provider({ + provider: 'kimi', + error: 'Kimi session expired — run kimi on the computer running Orca, then retry usage.', + usageMetadata: { + failureKind: 'delegated-refresh-required', + source: 'oauth' + } + }) + + expect(getProviderUsageStatusLabel(kimi)).toBe('Run Kimi to refresh') + expect(getProviderUsageErrorMessage(kimi)).toBe( + 'Run kimi in a terminal on the computer running Orca and wait for it to start, then retry usage.' ) }) diff --git a/src/renderer/src/components/status-bar/usage-error-copy.ts b/src/renderer/src/components/status-bar/usage-error-copy.ts index 0b0c3bc2eb6c..39032ab5e2c3 100644 --- a/src/renderer/src/components/status-bar/usage-error-copy.ts +++ b/src/renderer/src/components/status-bar/usage-error-copy.ts @@ -64,7 +64,25 @@ function isUsageAuthError(message: string | null): boolean { return Boolean(message && USAGE_AUTH_ERROR_PATTERNS.some((pattern) => pattern.test(message))) } +function getDelegatedCliRefreshProvider( + p: ProviderRateLimits +): Extract | null { + if (p.usageMetadata?.failureKind !== 'delegated-refresh-required') { + return null + } + // Why: only these providers require a user-run CLI to rotate the read-only + // session Orca consumes; Claude handles the same failure kind in-app. + return p.provider === 'grok' || p.provider === 'kimi' ? p.provider : null +} + export function getProviderUsageStatusLabel(p: ProviderRateLimits): string { + const delegatedCliProvider = getDelegatedCliRefreshProvider(p) + if (delegatedCliProvider === 'grok') { + return translate('auto.components.status.bar.tooltip.e2c6a4f917', 'Run Grok to refresh') + } + if (delegatedCliProvider === 'kimi') { + return translate('auto.components.status.bar.tooltip.f90b3d7a16', 'Run Kimi to refresh') + } if (p.provider === 'claude') { switch (p.usageMetadata?.failureKind) { case 'deferred-by-live-session': @@ -107,6 +125,19 @@ export function getProviderUsageErrorMessage(p: ProviderRateLimits): string { if (!p.error) { return fallback } + const delegatedCliProvider = getDelegatedCliRefreshProvider(p) + if (delegatedCliProvider === 'grok') { + return translate( + 'auto.components.status.bar.tooltip.d1b7f509ac', + 'Run grok in a terminal on the computer running Orca and wait for it to start. If prompted, complete sign-in, then retry usage. You do not need to send a chat message.' + ) + } + if (delegatedCliProvider === 'kimi') { + return translate( + 'auto.components.status.bar.tooltip.a37e8c15d4', + 'Run kimi in a terminal on the computer running Orca and wait for it to start, then retry usage.' + ) + } if (p.provider === 'claude') { switch (p.usageMetadata?.failureKind) { case 'deferred-by-live-session': diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index a0a4265cf520..6596b371b7d7 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3224,7 +3224,11 @@ "42fdd4da1d": "Claude sign-in is being refreshed. Agent sessions may still be signed in.", "c06c1d215d": "Claude usage could not be refreshed because the network request failed.", "cabdc2a9e0": "Claude sign-in credentials could not be read.", - "a7517cccb6": "Claude usage is unavailable right now." + "a7517cccb6": "Claude usage is unavailable right now.", + "e2c6a4f917": "Run Grok to refresh", + "d1b7f509ac": "Run grok in a terminal on the computer running Orca and wait for it to start. If prompted, complete sign-in, then retry usage. You do not need to send a chat message.", + "f90b3d7a16": "Run Kimi to refresh", + "a37e8c15d4": "Run kimi in a terminal on the computer running Orca and wait for it to start, then retry usage." }, "SshTargetStatusRow": { "sshHost": "SSH Host" @@ -9037,7 +9041,9 @@ "b7e2d9f0a3": "Same weekly credit % as the grok /usage screen in the terminal.", "c6d1a8f4e2": "Resets {{when}}", "e6dadc1e2b": "Monthly usage", - "75e396bf42": "Included monthly usage for Grok unified-billing accounts." + "75e396bf42": "Included monthly usage for Grok unified-billing accounts.", + "b36fa2c908": "Signed in. Orca reads the Grok CLI session stored on disk.", + "f08c41de73": "Session expired — run grok on the computer running Orca and wait for it to start. If prompted, complete sign-in, then click Refresh usage. No chat message is needed." }, "AppearanceWindowSidebarSection": { "usagePercentageDisplayUsed": "Used", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 5fec0c3ef2fe..2b568c764bd0 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -3224,7 +3224,11 @@ "42fdd4da1d": "Se está actualizando el inicio de sesión de Claude. Las sesiones de agentes pueden seguir iniciadas.", "c06c1d215d": "No se pudo actualizar el uso de Claude porque falló la solicitud de red.", "cabdc2a9e0": "No se pudieron leer las credenciales de inicio de sesión de Claude.", - "a7517cccb6": "El uso de Claude no está disponible ahora." + "a7517cccb6": "El uso de Claude no está disponible ahora.", + "e2c6a4f917": "Run Grok to refresh", + "d1b7f509ac": "Run grok in a terminal on the computer running Orca and wait for it to start. If prompted, complete sign-in, then retry usage. You do not need to send a chat message.", + "f90b3d7a16": "Run Kimi to refresh", + "a37e8c15d4": "Run kimi in a terminal on the computer running Orca and wait for it to start, then retry usage." }, "SshTargetStatusRow": { "sshHost": "Host SSH" @@ -9037,7 +9041,9 @@ "b7e2d9f0a3": "El mismo porcentaje de créditos semanales que la pantalla grok /usage en la terminal.", "c6d1a8f4e2": "Se restablece {{when}}", "e6dadc1e2b": "Monthly usage", - "75e396bf42": "Included monthly usage for Grok unified-billing accounts." + "75e396bf42": "Included monthly usage for Grok unified-billing accounts.", + "b36fa2c908": "Signed in. Orca reads the Grok CLI session stored on disk.", + "f08c41de73": "Session expired — run grok on the computer running Orca and wait for it to start. If prompted, complete sign-in, then click Refresh usage. No chat message is needed." }, "AppearanceWindowSidebarSection": { "usagePercentageDisplayUsed": "Usado", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index a760df564088..70aaea12442f 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -3224,7 +3224,11 @@ "42fdd4da1d": "Claude サインインを更新中です。エージェントセッションはまだサインイン済みの場合があります。", "c06c1d215d": "ネットワークリクエストに失敗したため、Claude 使用量を更新できませんでした。", "cabdc2a9e0": "Claude サインイン認証情報を読み取れませんでした。", - "a7517cccb6": "Claude 使用量は現在利用できません。" + "a7517cccb6": "Claude 使用量は現在利用できません。", + "e2c6a4f917": "Run Grok to refresh", + "d1b7f509ac": "Run grok in a terminal on the computer running Orca and wait for it to start. If prompted, complete sign-in, then retry usage. You do not need to send a chat message.", + "f90b3d7a16": "Run Kimi to refresh", + "a37e8c15d4": "Run kimi in a terminal on the computer running Orca and wait for it to start, then retry usage." }, "SshTargetStatusRow": { "sshHost": "SSH ホスト" @@ -9037,7 +9041,9 @@ "b7e2d9f0a3": "ターミナルの grok /usage 画面と同じ週次クレジット率です。", "c6d1a8f4e2": "{{when}} にリセット", "e6dadc1e2b": "Monthly usage", - "75e396bf42": "Included monthly usage for Grok unified-billing accounts." + "75e396bf42": "Included monthly usage for Grok unified-billing accounts.", + "b36fa2c908": "Signed in. Orca reads the Grok CLI session stored on disk.", + "f08c41de73": "Session expired — run grok on the computer running Orca and wait for it to start. If prompted, complete sign-in, then click Refresh usage. No chat message is needed." }, "AppearanceWindowSidebarSection": { "usagePercentageDisplayUsed": "使用済み", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index ce8f97cf3ec6..98311b01de7f 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -3224,7 +3224,11 @@ "42fdd4da1d": "Claude 로그인을 새로 고치는 중입니다. 에이전트 세션은 여전히 로그인되어 있을 수 있습니다.", "c06c1d215d": "네트워크 요청 실패로 Claude 사용량을 새로 고칠 수 없습니다.", "cabdc2a9e0": "Claude 로그인 자격 증명을 읽을 수 없습니다.", - "a7517cccb6": "현재 Claude 사용량을 사용할 수 없습니다." + "a7517cccb6": "현재 Claude 사용량을 사용할 수 없습니다.", + "e2c6a4f917": "Run Grok to refresh", + "d1b7f509ac": "Run grok in a terminal on the computer running Orca and wait for it to start. If prompted, complete sign-in, then retry usage. You do not need to send a chat message.", + "f90b3d7a16": "Run Kimi to refresh", + "a37e8c15d4": "Run kimi in a terminal on the computer running Orca and wait for it to start, then retry usage." }, "SshTargetStatusRow": { "sshHost": "SSH 호스트" @@ -9037,7 +9041,9 @@ "b7e2d9f0a3": "터미널의 grok /usage 화면과 같은 주간 크레딧 비율입니다.", "c6d1a8f4e2": "{{when}}에 재설정", "e6dadc1e2b": "Monthly usage", - "75e396bf42": "Included monthly usage for Grok unified-billing accounts." + "75e396bf42": "Included monthly usage for Grok unified-billing accounts.", + "b36fa2c908": "Signed in. Orca reads the Grok CLI session stored on disk.", + "f08c41de73": "Session expired — run grok on the computer running Orca and wait for it to start. If prompted, complete sign-in, then click Refresh usage. No chat message is needed." }, "AppearanceWindowSidebarSection": { "usagePercentageDisplayUsed": "사용", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index dc56b80b9417..7a439d1ef378 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -3224,7 +3224,11 @@ "42fdd4da1d": "正在刷新 Claude 登录。智能体会话可能仍处于登录状态。", "c06c1d215d": "由于网络请求失败,无法刷新 Claude 用量。", "cabdc2a9e0": "无法读取 Claude 登录凭据。", - "a7517cccb6": "Claude 用量目前不可用。" + "a7517cccb6": "Claude 用量目前不可用。", + "e2c6a4f917": "Run Grok to refresh", + "d1b7f509ac": "Run grok in a terminal on the computer running Orca and wait for it to start. If prompted, complete sign-in, then retry usage. You do not need to send a chat message.", + "f90b3d7a16": "Run Kimi to refresh", + "a37e8c15d4": "Run kimi in a terminal on the computer running Orca and wait for it to start, then retry usage." }, "SshTargetStatusRow": { "sshHost": "SSH 主机" @@ -9037,7 +9041,9 @@ "b7e2d9f0a3": "与终端中 grok /usage 屏幕显示的每周额度百分比相同。", "c6d1a8f4e2": "{{when}} 重置", "e6dadc1e2b": "Monthly usage", - "75e396bf42": "Included monthly usage for Grok unified-billing accounts." + "75e396bf42": "Included monthly usage for Grok unified-billing accounts.", + "b36fa2c908": "Signed in. Orca reads the Grok CLI session stored on disk.", + "f08c41de73": "Session expired — run grok on the computer running Orca and wait for it to start. If prompted, complete sign-in, then click Refresh usage. No chat message is needed." }, "AppearanceWindowSidebarSection": { "usagePercentageDisplayUsed": "已用", From fc6570da677c35940da0c2710c738cd7ac6b3ebc Mon Sep 17 00:00:00 2001 From: Maxim Syabro Date: Fri, 17 Jul 2026 04:16:30 +0700 Subject: [PATCH 085/351] fix(pi): report completion on agent_settled so intermediate agent_end stays silent (#8035) (#8826) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pi's agent_end fires per low-level run — auto-retry, auto-compaction and queued follow-ups all emit it while the agent is still working — so Orca fired premature completion notifications. Pi >= 0.80.4 emits agent_settled exactly once when nothing is left to run. The extension now subscribes to both events: the first agent_settled proves runtime support and mutes agent_end reporting from then on; on older Pi/OMP runtimes (where unknown event names register silently and never fire) agent_end keeps reporting done exactly as before. The settled handler reports the existing agent_end hook event, so the agent-hooks server contract is unchanged. --- .../pi/agent-status-extension-source.test.ts | 136 +++++++++++++++++- src/main/pi/agent-status-handler-source.ts | 65 ++++++++- 2 files changed, 196 insertions(+), 5 deletions(-) diff --git a/src/main/pi/agent-status-extension-source.test.ts b/src/main/pi/agent-status-extension-source.test.ts index 1dff2ecccf0e..c77b094b97a8 100644 --- a/src/main/pi/agent-status-extension-source.test.ts +++ b/src/main/pi/agent-status-extension-source.test.ts @@ -5,7 +5,11 @@ import { describe, expect, it, vi } from 'vitest' import { getPiAgentStatusExtensionSource } from './agent-status-extension-source' -type HookHandler = (event?: unknown) => Promise | void +type HookContext = { + isIdle: () => boolean +} + +type HookHandler = (event?: unknown, context?: HookContext) => Promise | void type FakeCurlChild = { on: ReturnType @@ -25,7 +29,7 @@ type Harness = { } handlers: Record processEnv: Record - callHook: (name: string, event?: unknown) => Promise + callHook: (name: string, event?: unknown, context?: HookContext) => Promise // Re-invoke the extension factory in the same process (as Pi does on an // in-process extension reload), swapping in the freshly registered handlers. reload: () => void @@ -160,8 +164,8 @@ function createHarness(args: { fsMock, handlers, processEnv: processMock.env, - callHook: async (name, event) => { - await handlers[name]?.(event) + callHook: async (name, event, hookContext) => { + await handlers[name]?.(event, hookContext) }, reload: () => { for (const key of Object.keys(handlers)) { @@ -436,6 +440,130 @@ describe('getPiAgentStatusExtensionSource', () => { } }) + it('reports only agent_settled after multiple first-run agent_end events', async () => { + vi.useFakeTimers() + try { + const harness = createHarness({ kind: 'pi' }) + const context = { isIdle: vi.fn(() => false) } + + for (let index = 0; index < 3; index += 1) { + await harness.callHook('agent_end', undefined, context) + await vi.advanceTimersByTimeAsync(700) + } + expect(harness.fetchMock).not.toHaveBeenCalled() + + await harness.callHook('agent_settled') + await vi.advanceTimersByTimeAsync(0) + + expect(harness.fetchMock).toHaveBeenCalledTimes(1) + expect(JSON.parse(String(harness.fetchMock.mock.calls[0]?.[1]?.body)).payload).toEqual({ + hook_event_name: 'agent_end' + }) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('does not duplicate completion when idle is observed before agent_settled', async () => { + vi.useFakeTimers() + try { + const harness = createHarness({ kind: 'pi' }) + const context = { isIdle: vi.fn(() => true) } + + await harness.callHook('agent_end', undefined, context) + await vi.advanceTimersByTimeAsync(0) + expect(harness.fetchMock).toHaveBeenCalledTimes(1) + + await harness.callHook('agent_settled') + await vi.advanceTimersByTimeAsync(0) + + expect(harness.fetchMock).toHaveBeenCalledTimes(1) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('cancels an ambiguous agent_end when modern Pi resumes work', async () => { + vi.useFakeTimers() + try { + const harness = createHarness({ kind: 'pi' }) + const context = { isIdle: vi.fn(() => false) } + + await harness.callHook('agent_end', undefined, context) + await vi.advanceTimersByTimeAsync(100) + await harness.callHook('agent_start') + await harness.callHook('agent_end', undefined, context) + await vi.advanceTimersByTimeAsync(2_000) + await harness.callHook('agent_settled') + await vi.advanceTimersByTimeAsync(0) + + const events = harness.fetchMock.mock.calls.map( + (call) => JSON.parse(String(call[1]?.body)).payload.hook_event_name + ) + expect(events).toEqual(['agent_start', 'agent_end']) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('drops a pending legacy fallback when its context becomes stale on reload', async () => { + vi.useFakeTimers() + try { + const harness = createHarness({ kind: 'pi' }) + let active = true + const context = { + isIdle: vi.fn(() => { + if (!active) { + throw new Error('stale extension context') + } + return false + }) + } + + await harness.callHook('agent_end', undefined, context) + await vi.advanceTimersByTimeAsync(100) + active = false + harness.reload() + await vi.advanceTimersByTimeAsync(100) + + expect(harness.fetchMock).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('keeps reporting legacy Pi and OMP once their agent_end handlers settle', async () => { + vi.useFakeTimers() + try { + for (const kind of ['pi', 'omp'] as const) { + const harness = createHarness({ kind }) + let idle = false + const context = { isIdle: vi.fn(() => idle) } + + await harness.callHook('agent_end', undefined, context) + await vi.advanceTimersByTimeAsync(100) + expect(harness.fetchMock).not.toHaveBeenCalled() + + idle = true + await vi.advanceTimersByTimeAsync(100) + expect(harness.fetchMock).toHaveBeenCalledTimes(1) + } + } finally { + vi.useRealTimers() + } + }) + + it('keeps immediate agent_end fallback for runtimes without an idle context', async () => { + const harness = createHarness({ kind: 'omp' }) + + await harness.callHook('agent_end') + await vi.waitFor(() => expect(harness.fetchMock).toHaveBeenCalledTimes(1)) + }) + it('does not treat WSLENV alone as WSL evidence', async () => { const harness = createHarness({ kind: 'omp', diff --git a/src/main/pi/agent-status-handler-source.ts b/src/main/pi/agent-status-handler-source.ts index bc002cf5741a..38c4354ee13b 100644 --- a/src/main/pi/agent-status-handler-source.ts +++ b/src/main/pi/agent-status-handler-source.ts @@ -41,6 +41,8 @@ export function getPiAgentStatusHandlerSourceLines(): string[] { ' })', '', " pi.on('agent_start', () => {", + ' clearPendingAgentEndCheck()', + ' agentEndReported = false', " post('agent_start')", ' })', '', @@ -75,8 +77,69 @@ export function getPiAgentStatusHandlerSourceLines(): string[] { " post('message_end', { role: 'assistant', text })", ' })', '', - " pi.on('agent_end', () => {", + ' // Why: modern Pi stays non-idle across retry/compaction/follow-up work,', + ' // while legacy Pi/OMP becomes idle after its final agent_end handlers.', + ' const AGENT_END_IDLE_RECHECK_MS = 25', + ' const AGENT_END_IDLE_RECHECK_MAX_MS = 250', + ' let agentSettledSupported = false', + ' let agentEndReported = false', + ' let agentEndIdleRecheckMs = AGENT_END_IDLE_RECHECK_MS', + ' let pendingAgentEndCheck: ReturnType | null = null', + ' let pendingAgentEndContext: { isIdle: () => boolean } | null = null', + '', + ' function clearPendingAgentEndCheck(): void {', + ' if (pendingAgentEndCheck !== null) clearTimeout(pendingAgentEndCheck)', + ' pendingAgentEndCheck = null', + ' pendingAgentEndContext = null', + ' }', + '', + ' // Why: isIdle flips before agent_settled handlers run, so both paths', + ' // share a per-run guard instead of racing duplicate completion posts.', + ' function postAgentEndOnce(): void {', + ' if (agentEndReported) return', + ' agentEndReported = true', " post('agent_end')", + ' }', + '', + ' function checkPendingAgentEnd(): void {', + ' pendingAgentEndCheck = null', + ' const ctx = pendingAgentEndContext', + ' if (!ctx || agentSettledSupported || agentEndReported) {', + ' pendingAgentEndContext = null', + ' return', + ' }', + ' try {', + ' if (ctx.isIdle()) {', + ' pendingAgentEndContext = null', + ' postAgentEndOnce()', + ' return', + ' }', + ' } catch {', + ' pendingAgentEndContext = null', + ' return', + ' }', + ' pendingAgentEndCheck = setTimeout(checkPendingAgentEnd, agentEndIdleRecheckMs)', + " if (typeof pendingAgentEndCheck.unref === 'function') pendingAgentEndCheck.unref()", + ' agentEndIdleRecheckMs = Math.min(agentEndIdleRecheckMs * 2, AGENT_END_IDLE_RECHECK_MAX_MS)', + ' }', + '', + " pi.on('agent_settled', () => {", + ' agentSettledSupported = true', + ' clearPendingAgentEndCheck()', + ' postAgentEndOnce()', + ' })', + '', + " pi.on('agent_end', (_event, ctx) => {", + ' if (agentSettledSupported) return', + " if (!ctx || typeof ctx.isIdle !== 'function') {", + ' postAgentEndOnce()', + ' return', + ' }', + ' clearPendingAgentEndCheck()', + ' agentEndIdleRecheckMs = AGENT_END_IDLE_RECHECK_MS', + ' pendingAgentEndContext = ctx', + ' pendingAgentEndCheck = setTimeout(checkPendingAgentEnd, 0)', + " if (typeof pendingAgentEndCheck.unref === 'function') pendingAgentEndCheck.unref()", ' })', '}', '' From f102972cc154635c44e31ad3c2ab6f2e217445d5 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:44:55 -0700 Subject: [PATCH 086/351] fix(emulator): ignore external serve-sim helpers (#9071) * fix(emulator): ignore external serve-sim helpers Co-authored-by: Orca * docs(reliability): align backpressure evidence Co-authored-by: Orca --------- Co-authored-by: Orca --- config/reliability-gates.jsonc | 30 +- .../serve-sim-seen-keys-leak-benchmark.mjs | 93 ------ .../emulator/serve-sim-state-watcher.test.ts | 239 -------------- src/main/emulator/serve-sim-state-watcher.ts | 309 ------------------ src/main/index.ts | 12 +- src/main/runtime/orca-runtime-emulator.ts | 13 +- src/main/runtime/orca-runtime.ts | 20 -- 7 files changed, 8 insertions(+), 708 deletions(-) delete mode 100644 config/scripts/serve-sim-seen-keys-leak-benchmark.mjs delete mode 100644 src/main/emulator/serve-sim-state-watcher.test.ts delete mode 100644 src/main/emulator/serve-sim-state-watcher.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 054f1748ea65..448b4beac63b 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1950,7 +1950,6 @@ "daemon stream", "main PTY batching", "runtime path provenance", - "runtime serve-sim detection", "runtime terminal wait detection", "renderer ACK", "xterm scheduler", @@ -1971,7 +1970,7 @@ "macos" ], "coveredProviders": [], - "coverageNotes": "Local macOS evidence covers the existing main-process pending-output caps plus deterministic runtime path-provenance history reuse, idle-path serve-sim detection, and saturated-tail wait detection. Daemon stream write(false)/drain contracts, cross-session drain priority, and bounded queued tails arrive with the pending perf slice; live flood/latency artifacts remain gaps.", + "coverageNotes": "Local macOS evidence covers the existing main-process pending-output caps plus deterministic runtime path-provenance history reuse and saturated-tail wait detection. Daemon stream write(false)/drain contracts, cross-session drain priority, and bounded queued tails arrive with the pending perf slice; live flood/latency artifacts remain gaps.", "motivatingLinks": [ "https://github.com/stablyai/orca/pull/6836", "https://github.com/stablyai/orca/pull/6858", @@ -1979,17 +1978,15 @@ "https://github.com/stablyai/orca/pull/7054" ], "invariant": "High-volume terminal output must stay bounded across daemon socket writes, main runtime metadata, detectors, and tail checks, main-to-renderer in-flight bytes, renderer scheduler queues, and hidden-output restore without starving focused input.", - "oracle": "The current executable slice injects daemon socket backpressure and main-process renderer backlog pressure, then asserts write(false)/drain ordering, bounded queued daemon bytes, unchanged path-provenance history reuse for pathless output, idle serve-sim detection with split-JSON continuity, ordinary terminal-wait detection without a joined-tail allocation and with full prompt-family continuity, per-PTY and total pending-output caps, preserved sequenced-tail metadata, active-pending protection ahead of background trimming, and ACK-gated in-flight bounds. The live Electron perf oracle adds hidden-output floods, renderer scheduler queue depth, dropped-output-zero normal scenarios, and active key latency budgets before promotion.", + "oracle": "The current executable slice injects main-process renderer backlog pressure, then asserts unchanged path-provenance history reuse for pathless output, ordinary terminal-wait detection without a joined-tail allocation and with full prompt-family continuity, per-PTY and total pending-output caps, preserved sequenced-tail metadata, active-pending protection ahead of background trimming, and ACK-gated in-flight bounds. The live Electron perf oracle adds hidden-output floods, renderer scheduler queue depth, dropped-output-zero normal scenarios, and active key latency budgets before promotion.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-path-candidate-history.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/main/emulator/serve-sim-state-watcher.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/orca-runtime-tail-wait-memo.test.ts" ], "testFiles": [ "src/main/ipc/pty.test.ts", "src/main/runtime/orca-runtime-path-candidate-history.test.ts", - "src/main/emulator/serve-sim-state-watcher.test.ts", "src/main/runtime/orca-runtime.test.ts", "src/main/runtime/orca-runtime-tail-wait-memo.test.ts" ], @@ -2009,13 +2006,6 @@ "copies history when new output adds a path candidate" ] }, - { - "file": "src/main/emulator/serve-sim-state-watcher.test.ts", - "assertions": [ - "does not buffer repeated brace-free PTY output while waiting for metadata", - "detects serve-sim metadata split across PTY chunks and releases the partial object" - ] - }, { "file": "src/main/runtime/orca-runtime-tail-wait-memo.test.ts", "assertions": [ @@ -2053,15 +2043,6 @@ "durationSeconds": 3.49, "summary": "1 test file passed, 2 tests passed on the exact rebased tree based on main@dc468f0ded." }, - { - "date": "2026-07-10", - "runner": "local", - "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/emulator/serve-sim-state-watcher.test.ts", - "result": "passed", - "durationSeconds": 0.37, - "summary": "1 test file passed, 6 tests passed on pushed commit f0e3a185593b." - }, { "date": "2026-07-10", "runner": "local", @@ -2078,11 +2059,11 @@ }, "flakeHistory": { "status": "unknown", - "evidence": "Daemon stream backpressure and focused main/renderer backlog tests are deterministic unit slices. The combined perf soak still needs runtime history before promotion." + "evidence": "Focused main-process backlog tests are deterministic unit slices. The combined perf soak still needs runtime history before promotion." }, "redGreenEvidence": { "status": "partial", - "evidence": "Tests assert daemon stream writes pause after socket write(false), later stream data queues behind the pressured socket, flush-immediate output from another session is prioritized ahead of unrelated queued background backlog on drain while preserving per-session order, queued lines resume on drain, global cleanup clears clients that only have backpressured writes, queued daemon stream bytes are bounded by keeping priority output and the newest tail under sustained pressure, pathless runtime output reuses unchanged path-provenance history with zero old-candidate byte scans, ordinary output leaves the niche serve-sim detector idle while split JSON still emits and releases its partial buffer, ordinary saturated terminal tails retain no rebuilt wait text or repeated phrase scans while every blocked/ready prompt family remains live, main pending renderer output is capped per PTY and in total, total-pressure trimming prefers background pending output before active pending output, trimmed pending tails preserve seq/rawLength metadata, and ACK-gated in-flight output remains bounded. Intentionally restoring the old path-history shape failed the focused scale test after 774ms and 4,096 replacements; restoring the old serve-sim buffer failed its scale test after 180ms with 4,096 buffer writes; disabling the ordinary-tail branch failed with a retained 253,999-character waitText. The fixed shapes passed their focused suites. Existing renderer tests cover replay/backlog slices. Needs broader hidden-output/input-latency perf artifacts before promotion." + "evidence": "Tests assert pathless runtime output reuses unchanged path-provenance history with zero old-candidate byte scans, ordinary saturated terminal tails retain no rebuilt wait text or repeated phrase scans while every blocked/ready prompt family remains live, main pending renderer output is capped per PTY and in total, total-pressure trimming prefers background pending output before active pending output, trimmed pending tails preserve seq/rawLength metadata, and ACK-gated in-flight output remains bounded. Intentionally restoring the old path-history shape failed the focused scale test after 774ms and 4,096 replacements; disabling the ordinary-tail branch failed with a retained 253,999-character waitText. The fixed shapes passed their focused suites. Existing renderer tests cover replay/backlog slices. Needs daemon-stream contract coverage and broader hidden-output/input-latency perf artifacts before promotion." }, "performanceBudget": { "required": true, @@ -2094,9 +2075,8 @@ "Keep stress variants non-blocking until stable runtime history exists." ], "knownGaps": [ - "Current command asserts daemon stream write(false)/drain/cleanup at the batcher contract layer and main pending renderer caps at the IPC contract layer.", + "Daemon stream write(false)/drain/cleanup and bounded queued-tail contracts are not registered in the current command.", "Runtime provenance coverage is deterministic and does not include a live high-throughput provider artifact.", - "Serve-sim detector coverage is deterministic and does not include a live external simulator helper.", "Terminal-wait scale evidence is deterministic and does not yet include a live saturated-tail event-loop artifact.", "Current command does not prove renderer parse pressure, scheduler queue depth, event-loop delay, or active key latency.", "Live hidden-output pressure, active input latency, and full Electron perf artifacts remain unproved." diff --git a/config/scripts/serve-sim-seen-keys-leak-benchmark.mjs b/config/scripts/serve-sim-seen-keys-leak-benchmark.mjs deleted file mode 100644 index 512191599d09..000000000000 --- a/config/scripts/serve-sim-seen-keys-leak-benchmark.mjs +++ /dev/null @@ -1,93 +0,0 @@ -#!/usr/bin/env node -// Benchmark: main-process retained-Set growth of the serve-sim state watcher's -// external-helper dedupe across worktree switches. -// -// emitIfExternal() records one entry per (worktreeId, deviceUdid, pid, wsUrl, -// streamUrl) tuple in `seenExternalKeys` so a helper is only auto-tabbed once. -// Before the fix, forgetWorktree() pruned ptyToWorktree and ptyBuffers but never -// touched seenExternalKeys, so its keys accumulated for the renderer/main -// process lifetime — only cleared at app shutdown (stop()). This script -// simulates N bind→detect→forget cycles and reports retained Set size with the -// prune disabled vs enabled. -import { performance } from 'node:perf_hooks' - -const CYCLES = Number.parseInt(process.env.ORCA_SEEN_KEYS_BENCH_CYCLES ?? '5000', 10) -const HELPERS_PER_WORKTREE = Number.parseInt(process.env.ORCA_SEEN_KEYS_BENCH_HELPERS ?? '2', 10) - -for (const [name, value] of [ - ['ORCA_SEEN_KEYS_BENCH_CYCLES', CYCLES], - ['ORCA_SEEN_KEYS_BENCH_HELPERS', HELPERS_PER_WORKTREE] -]) { - if (!Number.isInteger(value) || value <= 0) { - throw new Error(`${name} must be a positive integer, received ${value}`) - } -} - -// Mirror of the watcher's dedupe bookkeeping (serve-sim-state-watcher.ts): a -// Set of `${worktreeId}::${instanceKey}` keys, plus the prefix-prune the fix -// adds to forgetWorktree(). -function makeWatcher({ prune }) { - const seenExternalKeys = new Set() - return { - set: seenExternalKeys, - emitIfExternal(worktreeId, instanceKey) { - const dedupeKey = `${worktreeId}::${instanceKey}` - if (seenExternalKeys.has(dedupeKey)) { - return false - } - seenExternalKeys.add(dedupeKey) - return true - }, - forgetWorktree(worktreeId) { - if (!prune) { - return - } - const prefix = `${worktreeId}::` - for (const key of seenExternalKeys) { - if (key.startsWith(prefix)) { - seenExternalKeys.delete(key) - } - } - } - } -} - -function run({ prune }) { - const watcher = makeWatcher({ prune }) - const start = performance.now() - for (let cycle = 0; cycle < CYCLES; cycle++) { - const worktreeId = `worktree-${cycle}` - for (let h = 0; h < HELPERS_PER_WORKTREE; h++) { - // A realistic instance key: device udid :: pid :: wsUrl :: streamUrl. - const instanceKey = `udid-${cycle}-${h}::pid-${1000 + h}::ws://127.0.0.1:${3100 + h}/ws::http://127.0.0.1:${3100 + h}/stream.mjpeg` - watcher.emitIfExternal(worktreeId, instanceKey) - } - watcher.forgetWorktree(worktreeId) - } - return { retained: watcher.set.size, elapsedMs: performance.now() - start } -} - -console.log( - `serve-sim seenExternalKeys leak benchmark: ${CYCLES} worktree open/forget cycles, ` + - `${HELPERS_PER_WORKTREE} helper(s)/worktree\n` -) - -const before = run({ prune: false }) -const after = run({ prune: true }) - -// Each key is ~120-160 bytes (worktreeId + udid + pid + two URLs). -const APPROX_BYTES_PER_KEY = 140 -const leakedKb = (before.retained * APPROX_BYTES_PER_KEY) / 1024 - -console.log( - `before (no prune) retained=${before.retained} keys ` + - `(~${leakedKb.toFixed(0)} KiB) time=${before.elapsedMs.toFixed(1)}ms` -) -console.log( - `after (prune) retained=${after.retained} keys ` + - `(~0 KiB) time=${after.elapsedMs.toFixed(1)}ms` -) -console.log( - `\nBounded the dedupe Set to live worktrees: ${before.retained} → ${after.retained} ` + - `retained keys after ${CYCLES} worktree switches.` -) diff --git a/src/main/emulator/serve-sim-state-watcher.test.ts b/src/main/emulator/serve-sim-state-watcher.test.ts deleted file mode 100644 index e528ea324bbe..000000000000 --- a/src/main/emulator/serve-sim-state-watcher.test.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { - SERVE_SIM_STATE_DIR_EXISTENCE_POLL_MS, - ServeSimStateWatcher, - type ServeSimStateDetectedEvent -} from './serve-sim-state-watcher' - -const TEST_UDID = '11111111-2222-3333-4444-555555555555' - -let isolatedStateDirSeq = 0 -// Why: the default state dir is the real $TMPDIR/serve-sim — a leftover state -// file from an actual simulator session would leak extra events into tests -// that only exercise PTY ingestion. Point at a dir that never exists. -function createIsolatedWatcher(): ServeSimStateWatcher { - isolatedStateDirSeq += 1 - return new ServeSimStateWatcher({ - stateDir: join(tmpdir(), `orca-serve-sim-test-none-${process.pid}-${isolatedStateDirSeq}`) - }) -} - -async function waitForEvent( - events: ServeSimStateDetectedEvent[], - predicate: (event: ServeSimStateDetectedEvent) => boolean -): Promise { - const deadline = Date.now() + 1500 - while (Date.now() < deadline) { - const match = events.find(predicate) - if (match) { - return match - } - await new Promise((resolve) => setTimeout(resolve, 25)) - } - throw new Error(`Timed out waiting for serve-sim watcher event. Received ${events.length}`) -} - -describe('ServeSimStateWatcher', () => { - const cleanupPaths: string[] = [] - - afterEach(async () => { - await Promise.all( - cleanupPaths.splice(0).map((path) => rm(path, { recursive: true, force: true })) - ) - }) - - it('attaches to the serve-sim state directory when it appears after startup', async () => { - const parentDir = await mkdtemp(join(tmpdir(), 'orca-serve-sim-watch-')) - cleanupPaths.push(parentDir) - const stateDir = join(parentDir, 'serve-sim') - // Force darwin (the poll is macOS-only) and a fast interval so this - // cross-platform test exercises the existence-poll -> attach path quickly. - const watcher = new ServeSimStateWatcher({ stateDir, platform: 'darwin', existencePollMs: 50 }) - const events: ServeSimStateDetectedEvent[] = [] - - watcher.bindPty('pty-1', 'worktree-1') - watcher.onDetected((event) => events.push(event)) - watcher.start() - - await mkdir(stateDir) - await writeFile( - join(stateDir, `server-${TEST_UDID}.json`), - JSON.stringify({ - device: TEST_UDID, - streamUrl: 'http://127.0.0.1:3100/stream.mjpeg', - wsUrl: 'ws://127.0.0.1:3100/ws', - pid: 12345 - }) - ) - - const event = await waitForEvent(events, (candidate) => candidate.info.deviceUdid === TEST_UDID) - expect(event).toMatchObject({ - worktreeId: 'worktree-1', - source: 'state-file', - info: { - deviceUdid: TEST_UDID, - streamUrl: 'http://127.0.0.1:3100/stream.mjpeg', - wsUrl: 'ws://127.0.0.1:3100/ws', - helperPid: 12345 - } - }) - - watcher.stop() - }) - - it('does not arm the existence poll on non-macOS platforms', () => { - // serve-sim state never appears off macOS, so start() must not leave a - // recurring timer waking the daemon for a directory that can never exist. - for (const platform of ['win32', 'linux'] as const) { - const watcher = new ServeSimStateWatcher({ - stateDir: join(tmpdir(), `orca-serve-sim-nonmac-${process.pid}-${platform}`), - platform - }) - watcher.start() - const poll = (watcher as unknown as { stateDirPoll: unknown }).stateDirPoll - expect(poll).toBeNull() - watcher.stop() - } - }) - - it('defaults the existence poll to a coarse (battery-friendly) interval', () => { - expect(SERVE_SIM_STATE_DIR_EXISTENCE_POLL_MS).toBe(2_000) - const watcher = new ServeSimStateWatcher() - expect((watcher as unknown as { existencePollMs: number }).existencePollMs).toBe( - SERVE_SIM_STATE_DIR_EXISTENCE_POLL_MS - ) - }) - - it('does not buffer repeated brace-free PTY output while waiting for metadata', () => { - const watcher = createIsolatedWatcher() - const buffers = (watcher as unknown as { ptyBuffers: Map }).ptyBuffers - const originalSet = buffers.set - let setCalls = 0 - buffers.set = function (key, value): Map { - setCalls += 1 - return originalSet.call(this, key, value) - } - watcher.bindPty('pty-1', 'worktree-1') - - try { - for (let index = 0; index < 4096; index += 1) { - watcher.ingestPtyOutput('pty-1', 'ordinary compiler progress without JSON metadata\n') - } - } finally { - buffers.set = originalSet - } - - expect(setCalls).toBe(0) - expect(buffers.has('pty-1')).toBe(false) - watcher.stop() - }) - - it('detects serve-sim metadata split across PTY chunks and releases the partial object', () => { - const watcher = createIsolatedWatcher() - const events: ServeSimStateDetectedEvent[] = [] - const buffers = (watcher as unknown as { ptyBuffers: Map }).ptyBuffers - const payload = JSON.stringify({ - device: TEST_UDID, - streamUrl: 'http://127.0.0.1:3100/stream.mjpeg', - wsUrl: 'ws://127.0.0.1:3100/ws' - }) - const splitIndex = payload.indexOf('streamUrl') + 4 - watcher.bindPty('pty-1', 'worktree-1') - watcher.onDetected((event) => events.push(event)) - - watcher.ingestPtyOutput('pty-1', payload.slice(0, splitIndex)) - expect(events).toHaveLength(0) - expect(buffers.has('pty-1')).toBe(true) - - watcher.ingestPtyOutput('pty-1', payload.slice(splitIndex)) - expect(events).toHaveLength(1) - expect(events[0]?.info.deviceUdid).toBe(TEST_UDID) - expect(buffers.has('pty-1')).toBe(false) - watcher.stop() - }) - - it('suppresses Orca-managed sessions only while they are marked managed', async () => { - const watcher = createIsolatedWatcher() - const events: ServeSimStateDetectedEvent[] = [] - const payload = JSON.stringify({ - device: TEST_UDID, - streamUrl: 'http://127.0.0.1:3100/stream.mjpeg', - wsUrl: 'ws://127.0.0.1:3100/ws' - }) - - watcher.bindPty('pty-1', 'worktree-1') - watcher.onDetected((event) => events.push(event)) - watcher.markOrcaManaged({ - deviceUdid: TEST_UDID, - streamUrl: 'http://127.0.0.1:3100/stream.mjpeg', - wsUrl: 'ws://127.0.0.1:3100/ws' - }) - watcher.ingestPtyOutput('pty-1', payload) - expect(events).toHaveLength(0) - - watcher.unmarkOrcaManaged(TEST_UDID) - watcher.ingestPtyOutput('pty-1', payload) - expect(events).toHaveLength(1) - expect(events[0]?.info.deviceUdid).toBe(TEST_UDID) - - watcher.stop() - }) - - it('prunes worktree-scoped dedupe keys on forget so a re-bound worktree re-emits', () => { - const watcher = createIsolatedWatcher() - const events: ServeSimStateDetectedEvent[] = [] - const payload = JSON.stringify({ - device: TEST_UDID, - streamUrl: 'http://127.0.0.1:3100/stream.mjpeg', - wsUrl: 'ws://127.0.0.1:3100/ws', - pid: 12345 - }) - - watcher.onDetected((event) => events.push(event)) - - watcher.bindPty('pty-1', 'worktree-1') - watcher.ingestPtyOutput('pty-1', payload) - watcher.ingestPtyOutput('pty-1', payload) // deduped within the same worktree - expect(events).toHaveLength(1) - - // Forgetting the worktree must drop its dedupe keys; a re-bind is a fresh - // context and should re-emit (otherwise the Set leaks for the session). - watcher.forgetWorktree('worktree-1') - watcher.bindPty('pty-2', 'worktree-1') - watcher.ingestPtyOutput('pty-2', payload) - expect(events).toHaveLength(2) - - watcher.stop() - }) - - it('dedupes one helper without hiding a later helper for the same simulator', () => { - const watcher = createIsolatedWatcher() - const events: ServeSimStateDetectedEvent[] = [] - const firstPayload = JSON.stringify({ - device: TEST_UDID, - streamUrl: 'http://127.0.0.1:3100/stream.mjpeg', - wsUrl: 'ws://127.0.0.1:3100/ws', - pid: 12345 - }) - const secondPayload = JSON.stringify({ - device: TEST_UDID, - streamUrl: 'http://127.0.0.1:3101/stream.mjpeg', - wsUrl: 'ws://127.0.0.1:3101/ws', - pid: 23456 - }) - - watcher.bindPty('pty-1', 'worktree-1') - watcher.onDetected((event) => events.push(event)) - - watcher.ingestPtyOutput('pty-1', firstPayload) - watcher.ingestPtyOutput('pty-1', firstPayload) - watcher.ingestPtyOutput('pty-1', secondPayload) - - expect(events.map((event) => event.info.helperPid)).toEqual([12345, 23456]) - - watcher.stop() - }) -}) diff --git a/src/main/emulator/serve-sim-state-watcher.ts b/src/main/emulator/serve-sim-state-watcher.ts deleted file mode 100644 index dae8e57bc39e..000000000000 --- a/src/main/emulator/serve-sim-state-watcher.ts +++ /dev/null @@ -1,309 +0,0 @@ -import { existsSync, readFileSync, readdirSync, watch } from 'node:fs' -import { basename, join } from 'node:path' -import { tmpdir } from 'node:os' -import type { FSWatcher } from 'node:fs' - -// Why: terminal-started `serve-sim --detach` writes state under $TMPDIR/serve-sim/ and may -// print JSON to the PTY. Orca-managed attach (CLI/pane) already registers via EmulatorBridge; -// this watcher only reacts to *external* helper starts so the UI can open a simulator tab -// without focus-steal (mirror advertised-url-watcher PTY binding model). - -export type ServeSimHelperInfo = { - deviceUdid: string - wsUrl: string - streamUrl: string - axUrl?: string - helperPid?: number -} - -export type ServeSimStateDetectedEvent = { - worktreeId: string - info: ServeSimHelperInfo - source: 'pty' | 'state-file' -} - -const DEFAULT_STATE_DIR = join(tmpdir(), 'serve-sim') -// Why: this only waits for a rarely-created dir to appear; sub-second detection -// of a detached emulator is not user-perceptible, so a coarse interval avoids -// waking the daemon 4x/sec for the whole session on machines that never use it. -export const SERVE_SIM_STATE_DIR_EXISTENCE_POLL_MS = 2_000 -const STATE_FILE_RE = /^server-([0-9A-F-]{36})\.json$/i -const PTY_JSON_RE = /\{[^{}]*"streamUrl"\s*:\s*"[^"]+"[^{}]*"wsUrl"\s*:\s*"[^"]+"[^{}]*\}/g - -function parseHelperInfo(raw: unknown, fallbackUdid?: string): ServeSimHelperInfo | null { - if (!raw || typeof raw !== 'object') { - return null - } - const obj = raw as Record - const deviceUdid = - (typeof obj.device === 'string' && obj.device) || - (typeof obj.deviceUdid === 'string' && obj.deviceUdid) || - fallbackUdid - const streamUrl = - (typeof obj.streamUrl === 'string' && obj.streamUrl) || (typeof obj.url === 'string' && obj.url) - const wsUrl = typeof obj.wsUrl === 'string' ? obj.wsUrl : undefined - if (!deviceUdid || !streamUrl || !wsUrl) { - return null - } - return { - deviceUdid, - wsUrl, - streamUrl, - axUrl: typeof obj.axUrl === 'string' ? obj.axUrl : undefined, - helperPid: typeof obj.pid === 'number' ? obj.pid : undefined - } -} - -function readStateFilePath(filePath: string): ServeSimHelperInfo | null { - try { - const name = basename(filePath) - const match = STATE_FILE_RE.exec(name) - const fallbackUdid = match?.[1] - const raw = JSON.parse(readFileSync(filePath, 'utf8')) - return parseHelperInfo(raw, fallbackUdid) - } catch { - return null - } -} - -function helperInstanceKey(info: ServeSimHelperInfo): string { - return `${info.deviceUdid}::${info.helperPid ?? 'pidless'}::${info.wsUrl}::${info.streamUrl}` -} - -function trailingIncompletePtyJsonObject(data: string): string { - const lastObjectStart = data.lastIndexOf('{') - return lastObjectStart > data.lastIndexOf('}') ? data.slice(lastObjectStart) : '' -} - -export class ServeSimStateWatcher { - private readonly stateDir: string - private readonly platform: NodeJS.Platform - private readonly existencePollMs: number - private readonly ptyToWorktree = new Map() - private readonly ptyBuffers = new Map() - private readonly seenExternalKeys = new Set() - private readonly orcaManagedHelperKeys = new Set() - private readonly listeners = new Set<(event: ServeSimStateDetectedEvent) => void>() - private stateWatcher: FSWatcher | null = null - private stateDirPoll: ReturnType | null = null - - constructor( - options: { stateDir?: string; platform?: NodeJS.Platform; existencePollMs?: number } = {} - ) { - this.stateDir = options.stateDir ?? DEFAULT_STATE_DIR - this.platform = options.platform ?? process.platform - this.existencePollMs = options.existencePollMs ?? SERVE_SIM_STATE_DIR_EXISTENCE_POLL_MS - } - - onDetected(listener: (event: ServeSimStateDetectedEvent) => void): () => void { - this.listeners.add(listener) - return () => this.listeners.delete(listener) - } - - // Why: bridge calls this when Orca CLI/pane attaches so we do not duplicate-tab on our own session. - markOrcaManaged(info: ServeSimHelperInfo): void { - this.orcaManagedHelperKeys.add(helperInstanceKey(info)) - } - - unmarkOrcaManaged(deviceUdid: string): void { - for (const key of this.orcaManagedHelperKeys) { - if (key.startsWith(`${deviceUdid}::`)) { - this.orcaManagedHelperKeys.delete(key) - } - } - } - - bindPty(ptyId: string, worktreeId: string): void { - this.ptyToWorktree.set(ptyId, worktreeId) - this.scanExistingStateFiles() - } - - unbindPty(ptyId: string): void { - this.ptyToWorktree.delete(ptyId) - this.ptyBuffers.delete(ptyId) - } - - forgetWorktree(worktreeId: string): void { - for (const [ptyId, wt] of this.ptyToWorktree.entries()) { - if (wt === worktreeId) { - this.ptyToWorktree.delete(ptyId) - this.ptyBuffers.delete(ptyId) - } - } - // Why: dedupe keys are worktree-scoped (`${worktreeId}::...`); prune them on - // forget so the Set does not grow for the session across worktree switches. - // A later re-bind of the same worktree is a fresh context and should re-emit. - const prefix = `${worktreeId}::` - for (const key of this.seenExternalKeys) { - if (key.startsWith(prefix)) { - this.seenExternalKeys.delete(key) - } - } - } - - ingestPtyOutput(ptyId: string, data: string): void { - const worktreeId = this.ptyToWorktree.get(ptyId) - if (!worktreeId) { - return - } - - const previous = this.ptyBuffers.get(ptyId) - if (!previous && !data.includes('{')) { - // Why: serve-sim metadata is a JSON object, while ordinary PTY output is - // the hot path. Stay idle instead of rebuilding and regex-scanning 16 KiB. - return - } - const combined = `${previous ?? ''}${data}`.slice(-16_384) - const trailingObject = trailingIncompletePtyJsonObject(combined) - if (trailingObject) { - this.ptyBuffers.set(ptyId, trailingObject) - } else { - this.ptyBuffers.delete(ptyId) - } - - const matches = combined.match(PTY_JSON_RE) - if (!matches) { - return - } - for (const fragment of matches) { - try { - const info = parseHelperInfo(JSON.parse(fragment)) - if (info) { - this.emitIfExternal(worktreeId, info, 'pty') - } - } catch { - /* ignore partial JSON */ - } - } - } - - start(): void { - if (this.stateDirPoll || this.stateWatcher) { - return - } - // Why: serve-sim (the iOS Simulator bridge) only ever writes state on macOS, - // so $TMPDIR/serve-sim never appears on Windows/Linux. Skip arming the - // existence poll there instead of waking the daemon every interval for a - // directory that can never exist. - if (this.platform !== 'darwin') { - return - } - try { - // Why: $TMPDIR/serve-sim/ may not exist until the first terminal `serve-sim --detach`. - // Poll for it instead of fs.watch on the parent tmpdir: watching $TMPDIR - // registers a permanent FSEvents client on the system's highest-churn - // directory, while an existence poll costs the daemon nothing. - this.attachStateDirWatch() - if (this.stateWatcher) { - this.scanExistingStateFiles() - return - } - - // attachStateDirWatch() clears this poll once the dir appears and the - // native watcher takes over, so it only ticks while waiting for a - // rarely-created dir — a coarse interval keeps that wait off the idle floor. - this.stateDirPoll = setInterval(() => { - this.attachStateDirWatch() - this.scanExistingStateFiles() - }, this.existencePollMs) - this.stateDirPoll.unref?.() - } catch { - // Permission issues: watcher is best-effort. - } - } - - stop(): void { - this.stateWatcher?.close() - if (this.stateDirPoll) { - clearInterval(this.stateDirPoll) - } - this.stateWatcher = null - this.stateDirPoll = null - this.ptyToWorktree.clear() - this.ptyBuffers.clear() - this.seenExternalKeys.clear() - this.orcaManagedHelperKeys.clear() - this.listeners.clear() - } - - private attachStateDirWatch(): void { - if (this.stateWatcher || !existsSync(this.stateDir)) { - return - } - this.stateWatcher = watch(this.stateDir, (_event, filename) => { - if (!filename) { - return - } - const name = String(filename) - if (!STATE_FILE_RE.test(name)) { - return - } - const info = readStateFilePath(join(this.stateDir, name)) - if (!info) { - return - } - const worktreeId = this.latestBoundWorktree() - if (worktreeId) { - this.emitIfExternal(worktreeId, info, 'state-file') - } - }) - if (this.stateDirPoll) { - clearInterval(this.stateDirPoll) - this.stateDirPoll = null - } - } - - private latestBoundWorktree(): string | undefined { - let latest: string | undefined - for (const wt of this.ptyToWorktree.values()) { - latest = wt - } - return latest - } - - private scanExistingStateFiles(): void { - const worktreeId = this.latestBoundWorktree() - if (!worktreeId || !existsSync(this.stateDir)) { - return - } - try { - for (const name of readdirSync(this.stateDir)) { - if (!STATE_FILE_RE.test(name)) { - continue - } - const info = readStateFilePath(join(this.stateDir, name)) - if (info) { - this.emitIfExternal(worktreeId, info, 'state-file') - } - } - } catch { - /* ignore */ - } - } - - private emitIfExternal( - worktreeId: string, - info: ServeSimHelperInfo, - source: ServeSimStateDetectedEvent['source'] - ): void { - const instanceKey = helperInstanceKey(info) - if (this.orcaManagedHelperKeys.has(instanceKey)) { - return - } - const dedupeKey = `${worktreeId}::${instanceKey}` - if (this.seenExternalKeys.has(dedupeKey)) { - return - } - this.seenExternalKeys.add(dedupeKey) - const event: ServeSimStateDetectedEvent = { worktreeId, info, source } - for (const listener of this.listeners) { - try { - listener(event) - } catch { - /* listener errors must not break watcher */ - } - } - } -} - -export const serveSimStateWatcher = new ServeSimStateWatcher() diff --git a/src/main/index.ts b/src/main/index.ts index ababeb6e24ef..efdd7ee9a6e9 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -163,7 +163,6 @@ import { } from './ipc/pty' import { AgentBrowserBridge } from './browser/agent-browser-bridge' import { EmulatorBridge } from './emulator/emulator-bridge' -import { serveSimStateWatcher } from './emulator/serve-sim-state-watcher' import { browserManager } from './browser/browser-manager' import { OffscreenBrowserBackend } from './browser/offscreen-browser-backend' import { initializeBrowserSessionsForApp } from './browser/browser-session-startup' @@ -2032,16 +2031,10 @@ app.whenReady().then(async () => { ) // Emulator bridge (serve-sim). macOS-only feature (gated in CLI/runtime); always ship like agent-browser. + // Why: only Orca-managed or explicitly attached helpers belong to a workspace; + // externally started serve-sim processes must remain independent from Orca. const emulatorBridge = new EmulatorBridge() runtimeService.setEmulatorBridge(emulatorBridge) - serveSimStateWatcher.start() - serveSimStateWatcher.onDetected(({ worktreeId, info }) => { - runtimeService.getEmulatorBridge()?.registerActiveEmulator(worktreeId, info, { - managed: false - }) - serveSimStateWatcher.markOrcaManaged(info) - runtimeService.notifyEmulatorAutoAttachFromWatcher(worktreeId, info) - }) nativeTheme.themeSource = store.getSettings().theme ?? 'system' if (shouldInstallManagedHooks(is.dev)) { // Why: the persisted off switch must run before any auto-install path so @@ -2401,7 +2394,6 @@ app.on('will-quit', (e) => { // down explicitly on quit alongside the other browser/session shutdowns. runtime?.getOffscreenBrowserBackend()?.destroyAll?.() const emulatorShutdown = runtime?.getEmulatorBridge()?.destroyAllSessions() ?? Promise.resolve() - serveSimStateWatcher.stop() killAllPty() const watcherShutdown = shutdownWatchersOnce() store?.flush() diff --git a/src/main/runtime/orca-runtime-emulator.ts b/src/main/runtime/orca-runtime-emulator.ts index 31e4ad1d36a4..9bda55ca899b 100644 --- a/src/main/runtime/orca-runtime-emulator.ts +++ b/src/main/runtime/orca-runtime-emulator.ts @@ -7,7 +7,6 @@ import { } from '../emulator/emulator-availability' import { resolveDefaultAttachDevice } from '../emulator/emulator-default-attach-device' import { setConfiguredAndroidSdkPath } from '../emulator/android/android-sdk-host-discovery' -import { serveSimStateWatcher } from '../emulator/serve-sim-state-watcher' import type { EmulatorGesturePoint } from '../emulator/emulator-gesture-sender' import type { EmulatorSessionInfo } from '../emulator/emulator-types' import type { SimulatorDevice } from '../emulator/simctl-simulator-devices' @@ -148,7 +147,6 @@ export class RuntimeEmulatorCommands { if (reusable) { // Why: renderer remounts should reconnect to the existing stream, not // kill it and create the stream-disconnected reload loop users see. - serveSimStateWatcher.markOrcaManaged(reusable) this.notifyRendererEmulatorAutoAttach(worktreeId, reusable) if (params.focus) { this.notifyRendererEmulatorPaneFocus(worktreeId) @@ -157,15 +155,11 @@ export class RuntimeEmulatorCommands { } // A different requested device is an explicit switch; the bridge keeps a // slow-to-boot Android emulator alive for instant switch-back. - const stoppedUdid = await bridge.stopActiveForSwitch(worktreeId) - if (stoppedUdid) { - serveSimStateWatcher.unmarkOrcaManaged(stoppedUdid) - } + await bridge.stopActiveForSwitch(worktreeId) } const info = await bridge.startHelperForDevice(device) if (worktreeId) { bridge.registerActiveEmulator(worktreeId, info, { managed: true }) - serveSimStateWatcher.markOrcaManaged(info) this.notifyRendererEmulatorAutoAttach(worktreeId, info) if (params.focus) { this.notifyRendererEmulatorPaneFocus(worktreeId) @@ -281,7 +275,6 @@ export class RuntimeEmulatorCommands { const bridge = this.requireEmulatorBridge() const worktreeId = await this.resolveWorktreeId(params.worktree) const killedUdid = await bridge.kill(params.device ?? params.emulator, worktreeId) - serveSimStateWatcher.unmarkOrcaManaged(killedUdid) return { ok: true, deviceUdid: killedUdid } } @@ -295,13 +288,9 @@ export class RuntimeEmulatorCommands { const worktreeId = await this.resolveWorktreeId(params.worktree) if (params.managedOnly && worktreeId && !params.device && !params.emulator) { const shutdownUdid = await bridge.shutdownActiveManagedForWorktree(worktreeId) - if (shutdownUdid) { - serveSimStateWatcher.unmarkOrcaManaged(shutdownUdid) - } return { ok: true, deviceUdid: shutdownUdid ?? undefined } } const shutdownUdid = await bridge.shutdown(params.device ?? params.emulator, worktreeId) - serveSimStateWatcher.unmarkOrcaManaged(shutdownUdid) return { ok: true, deviceUdid: shutdownUdid } } diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index e64143647edb..40bfc0e5691a 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -349,7 +349,6 @@ import { buildHeadlessTabGroupSplit } from './headless-tab-group-split-layout' import { RuntimeEmulatorCommands, setEmulatorBridge } from './orca-runtime-emulator' -import { serveSimStateWatcher } from '../emulator/serve-sim-state-watcher' import type { EmulatorBridge } from '../emulator/emulator-bridge' import { RuntimeFileCommands } from './orca-runtime-files' import { RuntimeGitCommands } from './orca-runtime-git' @@ -5882,7 +5881,6 @@ export class OrcaRuntimeService { // `Network: https://local.example.com:3001/`) so the workspace ports // panel can surface them in place of the kernel bind address. advertisedUrlWatcher.ingest(ptyId, data, at) - serveSimStateWatcher.ingestPtyOutput(ptyId, data) // Why: reply ownership is captured per chunk, here at ingestion — the // same module state and tick as the hidden-gate drop sites — and rides // the writeChain link. A mark/setting/subscriber flip before the queued @@ -8705,7 +8703,6 @@ export class OrcaRuntimeService { onPtyExit(ptyId: string, exitCode: number): void { advertisedUrlWatcher.unbindPty(ptyId) - serveSimStateWatcher.unbindPty(ptyId) // Clean up new mobile state for this PTY this.mobileSubscribers.delete(ptyId) this.remoteTerminalViewSubscriberCounts.delete(ptyId) @@ -17608,7 +17605,6 @@ export class OrcaRuntimeService { // purge history and process-local caches before the ID points at new state. store.removeWorktreeMeta(worktreeId) advertisedUrlWatcher.forgetWorktree(worktreeId) - serveSimStateWatcher.forgetWorktree(worktreeId) deleteWorktreeHistoryDir(worktreeId) this.closeHeadlessBrowserPagesForWorktree(worktreeId) } @@ -21084,7 +21080,6 @@ export class OrcaRuntimeService { // Why: restored/controller-discovered PTYs learn their worktree here // without registerPty(), so URL enrichment must bind at this source. advertisedUrlWatcher.bindPty(ptyId, worktreeId) - serveSimStateWatcher.bindPty(ptyId, worktreeId) return pty } @@ -21120,7 +21115,6 @@ export class OrcaRuntimeService { // Why: recordPtyWorktree is the common lifecycle point for every path that // resolves a PTY's worktree, including renderer restore and controller list. advertisedUrlWatcher.bindPty(ptyId, worktreeId) - serveSimStateWatcher.bindPty(ptyId, worktreeId) return pty } @@ -21233,8 +21227,6 @@ export class OrcaRuntimeService { } private dropDisconnectedPtyRecord(ptyId: string): void { - // Why: pruning can remove a PTY without the normal exit callback. - serveSimStateWatcher.unbindPty(ptyId) this.ptysById.delete(ptyId) this.recentPtyOutputById.delete(ptyId) this.clearWaitBlockedCheckState(ptyId) @@ -25726,18 +25718,6 @@ export class OrcaRuntimeService { emulatorUnregisterActive: RuntimeEmulatorCommands['emulatorUnregisterActive'] = this.emulatorCommands.emulatorUnregisterActive.bind(this.emulatorCommands) - // Why: serve-sim-state-watcher runs from main/index.ts startup; keep window IPC behind runtime (getAuthoritativeWindow is private). - notifyEmulatorAutoAttachFromWatcher( - worktreeId: string, - info: { deviceUdid: string; streamUrl: string; wsUrl: string; axUrl?: string } - ): void { - try { - this.getAuthoritativeWindow().webContents.send('ui:emulatorAutoAttach', { worktreeId, info }) - } catch { - // Window may not exist during shutdown - } - } - private getAuthoritativeWindow(): BrowserWindow { const win = this.getAvailableAuthoritativeWindow() if (!win || win.isDestroyed()) { From 68fca0b0761da4dc73fb1cb4c77f41edc8809236 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:47:28 -0700 Subject: [PATCH 087/351] Add safe skill freshness detection and update rail (#8637) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add safe skill freshness detection * Accept observed copy-mode rail outcomes * chore(skills): regenerate snapshot artifacts for the merged guide content The rebase onto main picked up the reviewed guide fixes (#8624), so the current manifest hashes and a new appended snapshot generation must match those bytes; the registry keeps all prior snapshots so existing installs classify as outdated rather than unrecognized. * fix(skills): canonicalize snapshot file order and guard released history Historical snapshots kept git ls-tree byte-order while the working-tree walk and runtime observation use the sorted depth-first order, so any future multi-file skill would misclassify older installs as unrecognized and churn spurious registry revisions; all producers now share one canonical order (no digest changes for today's single-file packages). Also rejects executable files from shipped skills (Windows observation cannot see execute bits, which would misclassify pristine Windows installs) and adds an explicit append-only invariant for released snapshots so a generation-logic change cannot rewrite them silently. * fix(skills): throttle focus rescans and correct self-blocked placement copy Every window focus re-read and re-hashed all installed packages, and the nudge and panel each forced their own trailing rescan for one event; a 15s cooldown plus a shared invalidation latch keep one bounded scan per event while install-change events stay immediate. Bundle artifacts are now loaded once per run instead of re-parsed on every scan. A read-only or otherwise unsupported outdated placement now explains that it blocks itself instead of blaming a phantom sibling placement; the supported topology set moved to shared so eligibility and copy cannot drift. * feat(skills): move freshness surfacing to a lingering toast and update modal The Skills page has been unreachable since its toolbox menu entry was removed (#4535), so surfacing freshness there buried the feature behind its own nudge. The nudge now lingers until acted on (ignoring it records nothing; only the explicit close persists dismissal keys) and opens an update modal hosting the pre-filled editable terminal, an honest current/blocked summary, and the per-placement rows in a collapsed Details section. A compact 'Check for skill updates' row in CLI settings is the manual re-entry point. Skills page restored to main; design-doc surfacing section records the venue decision. * fix(skills): mount update dialog inside the link-routing provider and fold freshness into the setup rails The dialog hosts a live terminal pane that requires the link-routing preference context; mounted outside the provider it crashed the renderer the moment an eligible update existed (caught by live QA — unit tests mock the terminal). It now mounts inside the provider behind its own recoverable boundary. The separate 'Check for skill updates' settings button is gone: the setup rails' own pill now carries freshness (Update available / Up to date, falling back to Installed for blocked or unrecognized copies and for non-local runtimes the local-only scan cannot vouch for), and Re-check refreshes both installation detection and the freshness inventory. Wired for the CLI, Orchestration, Computer Use, and Per-Workspace Environments rails. * fix(skills): use the sleek scrollbar style in the update dialog * chore(skills): regenerate manifest for merged main (v1.4.142-rc.1) Main advanced to 1.4.142-rc.1 with a v1.4.141 release, so the embedded appVersion and release mapping were stale on the PR's merged tree. Only appVersion and the new release entry change; no snapshot digests move (released history preserved). * fix(skills): bound and batch freshness work * fix(skills): harden freshness integrity checks * fix(skills): accept observed copy topology outcomes * chore(skills): regenerate manifest for current main * fix(skills): preserve update terminal lifecycle * chore(skills): regenerate manifest for current main * fix(skills): fail closed on stale freshness scans * chore(skills): regenerate manifest for current main * fix(skills): preserve freshness safety under focus churn * feat(skills): group the update modal by skill with plain-language status The Update skills modal now lists only skills that will update or that can't (with why), grouped by skill with their install locations nested underneath — no more one row per placement. - Statuses collapse to "Update available" / "Can't update" at the skill level. - A location's problem is a chip (Duplicate, Unrecognized, Inaccessible, Read only, In a repo, External/Broken link, Plugin cache) with a hover tooltip that explains what it means for the user and what to do. - Up-to-date, unrecognized-only, and unreadable-only skills are hidden; a current/unrecognized/etc. location only appears when it explains a shown skill. - Copy is de-jargoned (drops "copy"/"placement"/"snapshot"/"official copy") and names the mechanism as the npx skills update command, not "Orca's update". - Rename the section to "Update details"; drop the unreachable newer-known state. Renderer-only: derivation is a pure module (groupSkillFreshness) with unit tests; no IPC or main-process change. Locales updated for all five languages. * chore(skills): regenerate manifest for current main (v1.4.143-rc.0) * feat(skills): don't let a duplicate block the update; clearer skipped copy - Eligibility: a clean standalone duplicate no longer poisons the whole name — the canonical copy still updates and the duplicate is flagged; a duplicate-only skill stays unoffered. - Update modal: "Can't update" -> "Skipped" with a reason-specific sentence (edited/read-only/in-a-repo/plugin/link); chips describe only the location state; footer "Check now" -> "Re-check". - Settings sidebar nav pills go amber "Update available" when a skill is updatable, matching the setup cards. - Localized new strings across en/es/ja/ko/zh. * chore(skills): regenerate manifest for merged main (v1.4.144-rc.1) --- .github/workflows/pr.yml | 8 + .github/workflows/skill-update-roundtrip.yml | 50 + config/electron-builder.config.cjs | 15 +- .../scripts/electron-builder-config.test.mjs | 7 + .../generate-skill-bundle-manifest.mjs | 522 +++++++ .../generate-skill-bundle-manifest.test.mjs | 199 +++ .../scripts/verify-skill-update-roundtrip.mjs | 249 ++++ notes/skill-freshness-design.md | 71 +- package.json | 4 +- resources/skills/current-manifest.json | 158 +++ resources/skills/release-mapping.json | 501 +++++++ resources/skills/snapshot-registry.json | 1239 +++++++++++++++++ src/main/ipc/skills.test.ts | 51 +- src/main/ipc/skills.ts | 8 + src/main/skills/discovery.test.ts | 35 + src/main/skills/discovery.ts | 1 + .../skills/skill-bundle-artifacts.test.ts | 37 + src/main/skills/skill-bundle-artifacts.ts | 164 +++ .../skills/skill-candidate-concurrency.ts | 24 + src/main/skills/skill-discovery-sources.ts | 10 +- .../skill-freshness-eligibility.test.ts | 122 ++ .../skills/skill-freshness-eligibility.ts | 41 + .../skill-freshness-inventory-limits.test.ts | 26 + .../skills/skill-freshness-inventory.test.ts | 363 +++++ src/main/skills/skill-freshness-inventory.ts | 179 +++ .../skill-freshness-placement-observation.ts | 251 ++++ .../skills/skill-installation-topology.ts | 172 +++ .../skills/skill-package-identity.test.ts | 92 ++ src/main/skills/skill-package-identity.ts | 229 +++ .../skills/skill-plugin-cache-scan.test.ts | 40 + src/main/skills/skill-plugin-cache-scan.ts | 142 ++ src/preload/api-types.ts | 2 + src/preload/index.ts | 5 +- src/renderer/src/App.tsx | 12 + .../settings/AgentSkillSetupPanel.tsx | 31 +- .../src/components/settings/CliSection.tsx | 1 + .../settings/ComputerUseSkillSetupPanel.tsx | 3 + .../components/settings/EphemeralVmsPane.tsx | 3 + .../settings/OrchestrationSetupCard.tsx | 4 + .../src/components/settings/Settings.tsx | 21 +- .../components/settings/SettingsSidebar.tsx | 13 +- .../skills/SkillFreshnessNudge.test.tsx | 230 +++ .../components/skills/SkillFreshnessNudge.tsx | 183 +++ .../skills/SkillFreshnessStatusPill.test.tsx | 107 ++ .../skills/SkillFreshnessStatusPill.tsx | 40 + .../SkillFreshnessUpdateDialog.test.tsx | 371 +++++ .../skills/SkillFreshnessUpdateDialog.tsx | 310 +++++ .../skills/skill-freshness-group.tsx | 203 +++ .../skills/skill-freshness-grouping.test.ts | 98 ++ .../skills/skill-freshness-grouping.ts | 90 ++ .../skills/skill-freshness-update-dialog.ts | 31 + .../installed-agent-skills-change-event.ts | 1 + .../src/hooks/useInstalledAgentSkills.ts | 2 +- .../src/hooks/useSkillFreshness.test.tsx | 238 ++++ src/renderer/src/hooks/useSkillFreshness.ts | 178 +++ src/renderer/src/i18n/locales/en.json | 58 + src/renderer/src/i18n/locales/es.json | 58 + src/renderer/src/i18n/locales/ja.json | 58 + src/renderer/src/i18n/locales/ko.json | 58 + src/renderer/src/i18n/locales/zh.json | 58 + .../src/lib/settings-navigation-types.ts | 2 +- src/renderer/src/web/web-preload-api.ts | 12 +- src/shared/skill-freshness.ts | 102 ++ src/shared/types.ts | 3 + 64 files changed, 7542 insertions(+), 54 deletions(-) create mode 100644 .github/workflows/skill-update-roundtrip.yml create mode 100644 config/scripts/generate-skill-bundle-manifest.mjs create mode 100644 config/scripts/generate-skill-bundle-manifest.test.mjs create mode 100644 config/scripts/verify-skill-update-roundtrip.mjs create mode 100644 resources/skills/current-manifest.json create mode 100644 resources/skills/release-mapping.json create mode 100644 resources/skills/snapshot-registry.json create mode 100644 src/main/skills/skill-bundle-artifacts.test.ts create mode 100644 src/main/skills/skill-bundle-artifacts.ts create mode 100644 src/main/skills/skill-candidate-concurrency.ts create mode 100644 src/main/skills/skill-freshness-eligibility.test.ts create mode 100644 src/main/skills/skill-freshness-eligibility.ts create mode 100644 src/main/skills/skill-freshness-inventory-limits.test.ts create mode 100644 src/main/skills/skill-freshness-inventory.test.ts create mode 100644 src/main/skills/skill-freshness-inventory.ts create mode 100644 src/main/skills/skill-freshness-placement-observation.ts create mode 100644 src/main/skills/skill-installation-topology.ts create mode 100644 src/main/skills/skill-package-identity.test.ts create mode 100644 src/main/skills/skill-package-identity.ts create mode 100644 src/main/skills/skill-plugin-cache-scan.test.ts create mode 100644 src/main/skills/skill-plugin-cache-scan.ts create mode 100644 src/renderer/src/components/skills/SkillFreshnessNudge.test.tsx create mode 100644 src/renderer/src/components/skills/SkillFreshnessNudge.tsx create mode 100644 src/renderer/src/components/skills/SkillFreshnessStatusPill.test.tsx create mode 100644 src/renderer/src/components/skills/SkillFreshnessStatusPill.tsx create mode 100644 src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx create mode 100644 src/renderer/src/components/skills/SkillFreshnessUpdateDialog.tsx create mode 100644 src/renderer/src/components/skills/skill-freshness-group.tsx create mode 100644 src/renderer/src/components/skills/skill-freshness-grouping.test.ts create mode 100644 src/renderer/src/components/skills/skill-freshness-grouping.ts create mode 100644 src/renderer/src/components/skills/skill-freshness-update-dialog.ts create mode 100644 src/renderer/src/hooks/installed-agent-skills-change-event.ts create mode 100644 src/renderer/src/hooks/useSkillFreshness.test.tsx create mode 100644 src/renderer/src/hooks/useSkillFreshness.ts create mode 100644 src/shared/skill-freshness.ts diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 47bf6b83a406..1e3fb4e6bdb3 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -15,6 +15,11 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + # Why: the freshness registry is derived from immutable release tags, + # so shallow PR checkouts cannot verify historical official identities. + fetch-depth: 0 + persist-credentials: false - name: Install native build tools run: sudo apt-get update && sudo apt-get install -y build-essential python3 zlib1g-dev zsh @@ -82,6 +87,9 @@ jobs: - name: Verify bundled skill guides run: pnpm run verify:bundled-skill-guides + - name: Verify skill freshness manifest + run: pnpm run verify:skill-bundle-manifest + # Why: project-owned type declarations must live in .ts so tsc # actually checks them. TypeScript's skipLibCheck: true (inherited # from @electron-toolkit/tsconfig) silently widens unresolved names diff --git a/.github/workflows/skill-update-roundtrip.yml b/.github/workflows/skill-update-roundtrip.yml new file mode 100644 index 000000000000..6c9418e1f64f --- /dev/null +++ b/.github/workflows/skill-update-roundtrip.yml @@ -0,0 +1,50 @@ +name: Skill update round trip + +on: + pull_request: + paths: + - 'skills/**' + - 'resources/skills/**' + - 'config/scripts/verify-skill-update-roundtrip.mjs' + - 'src/main/skills/skill-freshness-eligibility.ts' + - 'src/shared/skill-freshness.ts' + - '.github/workflows/skill-update-roundtrip.yml' + merge_group: + push: + branches: + - main + +jobs: + roundtrip: + strategy: + fail-fast: false + matrix: + os: [macos-latest, ubuntu-latest, windows-latest] + shape: [symlink, copy] + autocrlf: ['false', 'true'] + skills-cli: ['1.5.17'] + include: + - os: ubuntu-latest + shape: symlink + autocrlf: 'false' + skills-cli: latest + continue-on-error: ${{ matrix.skills-cli == 'latest' }} + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@v6 + with: + node-version-file: package.json + - name: Verify targeted update convergence and copy behavior + env: + GITHUB_TOKEN: ${{ github.token }} + SKILL_UPDATE_SOURCE: ${{ github.event.pull_request.head.repo.full_name || github.repository }} + SKILL_UPDATE_REF: ${{ github.head_ref || github.ref_name }} + run: >- + node config/scripts/verify-skill-update-roundtrip.mjs + --cli=${{ matrix.skills-cli }} + --autocrlf=${{ matrix.autocrlf }} + --shape=${{ matrix.shape }} diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 8f67e1068b71..98beb0c1c1f5 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -18,6 +18,12 @@ const featureWallResources = { from: 'resources/onboarding/feature-wall', to: 'onboarding/feature-wall' } +// Why: freshness detection needs immutable identity metadata from this exact +// app build, but never needs the skill package bytes or a runtime network read. +const skillFreshnessResources = { + from: 'resources/skills', + to: 'skills' +} // Why: SSH relay deploy resolves bundles from process.resourcesPath in packaged // apps. Keeping relay assets as extraResources makes them real directories // instead of paths hidden inside app.asar. @@ -31,7 +37,11 @@ const relayExtraResource = { // do not fall through to a developer checkout's node_modules. const packagedRuntimeNodeModuleResources = createPackagedRuntimeNodeModuleResources() -const commonExtraResources = [relayExtraResource, ...packagedRuntimeNodeModuleResources] +const commonExtraResources = [ + relayExtraResource, + ...packagedRuntimeNodeModuleResources, + skillFreshnessResources +] const macSpeechNativeResource = { from: 'node_modules/sherpa-onnx-darwin-${arch}', to: 'node_modules/sherpa-onnx-darwin-${arch}' @@ -79,7 +89,8 @@ module.exports = { '!tsconfig.json', // Why: feature-wall media is copied via extraResources so runtime can read // it from process.resourcesPath; exclude the source copy from app.asar. - '!resources/onboarding/feature-wall/**' + '!resources/onboarding/feature-wall/**', + '!resources/skills/**' ], // Why: the CLI entry-point lives in out/cli/ but imports shared modules // from out/shared/ and local hook mutators from out/main/. These paths must be diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index 7ada984eb291..d56714984687 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -29,6 +29,7 @@ describe('electron-builder config', () => { '!native{,/**/*}', '!skills{,/**/*}', '!skill-guides{,/**/*}', + '!resources/skills/**', '!tests{,/**/*}', '!pr-evidence{,/**/*}', '!Casks{,/**/*}', @@ -39,6 +40,12 @@ describe('electron-builder config', () => { }) it('keeps runtime resources available through extraResources', () => { + for (const platform of ['mac', 'linux', 'win']) { + expect(electronBuilderConfig[platform].extraResources).toContainEqual({ + from: 'resources/skills', + to: 'skills' + }) + } expect(electronBuilderConfig.mac.extraResources).toEqual( expect.arrayContaining([ expect.objectContaining({ diff --git a/config/scripts/generate-skill-bundle-manifest.mjs b/config/scripts/generate-skill-bundle-manifest.mjs new file mode 100644 index 000000000000..c3542d6f0e26 --- /dev/null +++ b/config/scripts/generate-skill-bundle-manifest.mjs @@ -0,0 +1,522 @@ +import { createHash } from 'node:crypto' +import { execFileSync } from 'node:child_process' +import { constants } from 'node:fs' +import { access, lstat, mkdir, readFile, readdir, writeFile } from 'node:fs/promises' +import path from 'node:path' +import process from 'node:process' +import { isDeepStrictEqual } from 'node:util' + +const SCHEMA_VERSION = 1 +const SCRIPT_DIR = import.meta.dirname +const REPO_ROOT = path.resolve(SCRIPT_DIR, '..', '..') +const SKILLS_ROOT = path.join(REPO_ROOT, 'skills') +const OUTPUT_ROOT = path.join(REPO_ROOT, 'resources', 'skills') +const CURRENT_MANIFEST_PATH = path.join(OUTPUT_ROOT, 'current-manifest.json') +const SNAPSHOT_REGISTRY_PATH = path.join(OUTPUT_ROOT, 'snapshot-registry.json') +const RELEASE_MAPPING_PATH = path.join(OUTPUT_ROOT, 'release-mapping.json') + +function sha256(bytes) { + return createHash('sha256').update(bytes).digest('hex') +} + +function compareCodeUnits(left, right) { + return left === right ? 0 : left < right ? -1 : 1 +} + +function gitObjectSha(kind, bytes) { + return createHash('sha1').update(`${kind} ${bytes.length}\0`).update(bytes).digest() +} + +function normalizeText(bytes) { + const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes) + return Buffer.from(text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'), 'utf8') +} + +function classifyFile(bytes) { + if (bytes.includes(0)) { + return 'binary' + } + try { + normalizeText(bytes) + return 'text' + } catch { + return 'binary' + } +} + +function assertSafeRelativePath(relativePath) { + if ( + path.isAbsolute(relativePath) || + relativePath === '..' || + relativePath.startsWith(`..${path.sep}`) + ) { + throw new Error(`Unsafe skill package path: ${relativePath}`) + } +} + +function describeFile(manifestPath, bytes, executable) { + const classification = classifyFile(bytes) + const exactSha256 = sha256(bytes) + const textNormalizedSha256 = classification === 'text' ? sha256(normalizeText(bytes)) : null + return { + path: manifestPath, + size: bytes.length, + executable, + classification, + exactSha256, + textNormalizedSha256, + identitySha256: classification === 'text' && !executable ? textNormalizedSha256 : exactSha256, + gitBlobSha: gitObjectSha('blob', bytes).toString('hex') + } +} + +function gitTreeSha(entries) { + const root = { directories: new Map(), files: [] } + for (const entry of entries) { + const parts = entry.path.split('/') + const filename = parts.pop() + let directory = root + for (const part of parts) { + let child = directory.directories.get(part) + if (!child) { + child = { directories: new Map(), files: [] } + directory.directories.set(part, child) + } + directory = child + } + directory.files.push({ filename, ...entry }) + } + + function hashDirectory(directory) { + const children = [ + ...[...directory.directories].map(([name, child]) => ({ + mode: '40000', + name, + hash: hashDirectory(child) + })), + ...directory.files.map((file) => ({ + mode: file.executable ? '100755' : '100644', + name: file.filename, + hash: Buffer.from(file.gitBlobSha, 'hex') + })) + ].sort((left, right) => { + const leftName = left.mode === '40000' ? `${left.name}/` : left.name + const rightName = right.mode === '40000' ? `${right.name}/` : right.name + return Buffer.from(leftName).compare(Buffer.from(rightName)) + }) + const body = Buffer.concat( + children.map(({ mode, name, hash }) => + Buffer.concat([Buffer.from(`${mode} ${name}\0`, 'utf8'), hash]) + ) + ) + return gitObjectSha('tree', body) + } + + return hashDirectory(root).toString('hex') +} + +async function collectPackageFiles(packageRoot) { + const files = [] + const caseFoldedPaths = new Map() + + async function visit(directory) { + const entries = await readdir(directory, { withFileTypes: true }) + // Why: build-time Node and packaged Electron may ship different ICU data; + // package identity order must use the same locale-independent comparison. + entries.sort((left, right) => compareCodeUnits(left.name, right.name)) + for (const entry of entries) { + const absolutePath = path.join(directory, entry.name) + const relativePath = path.relative(packageRoot, absolutePath) + assertSafeRelativePath(relativePath) + const manifestPath = relativePath.split(path.sep).join('/') + const foldedPath = manifestPath.toLocaleLowerCase('en-US') + const collision = caseFoldedPaths.get(foldedPath) + if (collision && collision !== manifestPath) { + throw new Error(`Case-colliding skill paths: ${collision} and ${manifestPath}`) + } + caseFoldedPaths.set(foldedPath, manifestPath) + const fileStat = await lstat(absolutePath) + if (fileStat.isSymbolicLink()) { + throw new Error(`Symlink is not allowed in a shipped skill: ${manifestPath}`) + } + if (fileStat.isDirectory()) { + await visit(absolutePath) + continue + } + if (!fileStat.isFile()) { + throw new Error(`Special file is not allowed in a shipped skill: ${manifestPath}`) + } + // Why: Windows observation cannot see execute bits, so an executable file in + // a shipped skill would misclassify every pristine Windows install as unrecognized. + if ((fileStat.mode & 0o111) !== 0) { + throw new Error(`Executable file is not allowed in a shipped skill: ${manifestPath}`) + } + files.push(describeFile(manifestPath, await readFile(absolutePath), false)) + } + } + + await visit(packageRoot) + return sortManifestFiles(files) +} + +function collectGitSkillTreeEntries(treeSha) { + const output = execFileSync('git', ['ls-tree', '-r', '-z', treeSha]) + .toString('utf8') + .split('\0') + .filter(Boolean) + const packages = new Map() + for (const line of output) { + const match = /^(\d+) (\w+) ([a-f0-9]+)\t(.+)$/.exec(line) + if (!match) { + throw new Error(`Unexpected git tree entry in ${treeSha}: ${line}`) + } + const [, mode, type, objectSha, sourcePath] = match + const separator = sourcePath.indexOf('/') + if (separator <= 0 || separator === sourcePath.length - 1) { + throw new Error(`Unsupported shipped skill path in ${treeSha}: ${sourcePath}`) + } + const name = sourcePath.slice(0, separator) + const manifestPath = sourcePath.slice(separator + 1) + const entries = packages.get(name) ?? [] + entries.push({ mode, type, objectSha, manifestPath }) + packages.set(name, entries) + } + return packages +} + +function readGitBlobs(objectShas) { + const uniqueShas = [...new Set(objectShas)] + if (uniqueShas.length === 0) { + return new Map() + } + // Why: released history spans hundreds of tags. Batch mode avoids a Git + // subprocess per historical file while remaining available on Git 2.25. + const output = execFileSync('git', ['cat-file', '--batch'], { + input: `${uniqueShas.join('\n')}\n`, + maxBuffer: 64 * 1024 * 1024 + }) + const blobs = new Map() + let offset = 0 + for (const requestedSha of uniqueShas) { + const headerEnd = output.indexOf(10, offset) + if (headerEnd < 0) { + throw new Error(`Missing git cat-file header for ${requestedSha}`) + } + const header = output.subarray(offset, headerEnd).toString('utf8') + const match = /^([a-f0-9]+) blob (\d+)$/.exec(header) + if (!match || match[1] !== requestedSha) { + throw new Error(`Unexpected git cat-file header for ${requestedSha}: ${header}`) + } + const size = Number(match[2]) + const contentStart = headerEnd + 1 + const contentEnd = contentStart + size + if (contentEnd >= output.length || output[contentEnd] !== 10) { + throw new Error(`Truncated git blob for ${requestedSha}`) + } + blobs.set(requestedSha, Buffer.from(output.subarray(contentStart, contentEnd))) + offset = contentEnd + 1 + } + return blobs +} + +function collectGitPackageFiles(treeSha, name, entries, blobs) { + const caseFoldedPaths = new Map() + const files = entries.map(({ mode, type, objectSha, manifestPath }) => { + if (type !== 'blob' || (mode !== '100644' && mode !== '100755')) { + throw new Error(`Unsupported shipped skill entry in ${treeSha}: ${name}/${manifestPath}`) + } + assertSafeRelativePath(manifestPath) + const foldedPath = manifestPath.toLocaleLowerCase('en-US') + const collision = caseFoldedPaths.get(foldedPath) + if (collision && collision !== manifestPath) { + throw new Error(`Case-colliding skill paths in ${treeSha}: ${collision} and ${manifestPath}`) + } + caseFoldedPaths.set(foldedPath, manifestPath) + const bytes = blobs.get(objectSha) + if (!bytes) { + throw new Error(`Missing git blob ${objectSha} for ${name}/${manifestPath}`) + } + return describeFile(manifestPath, bytes, mode === '100755') + }) + // Why: git ls-tree emits git byte-order, not the canonical walk order. + return sortManifestFiles(files) +} + +// Why: snapshot matching compares files by array index, so every producer — +// working-tree walk, git history, and runtime observation — must emit one +// canonical order. This mirrors the sorted depth-first filesystem walk. +function compareManifestPaths(left, right) { + const leftParts = left.split('/') + const rightParts = right.split('/') + const shared = Math.min(leftParts.length, rightParts.length) + for (let index = 0; index < shared; index += 1) { + const order = compareCodeUnits(leftParts[index], rightParts[index]) + if (order !== 0) { + return order + } + } + return leftParts.length - rightParts.length +} + +function sortManifestFiles(files) { + return [...files].sort((left, right) => compareManifestPaths(left.path, right.path)) +} + +function packageDigest(files) { + return sha256( + Buffer.from( + JSON.stringify( + files.map((file) => ({ + path: file.path, + executable: file.executable, + classification: file.classification, + identitySha256: file.identitySha256 + })) + ), + 'utf8' + ) + ) +} + +function releaseTags() { + return execFileSync( + 'git', + ['for-each-ref', '--sort=creatordate', '--format=%(refname:short)', 'refs/tags/v*'], + { encoding: 'utf8' } + ) + .split('\n') + .filter((tag) => /^v\d+\.\d+\.\d+(?:[-.][0-9A-Za-z.-]+)?$/.test(tag)) +} + +function skillsTreeShasAtRefs(refs) { + if (refs.length === 0) { + return [] + } + const output = execFileSync('git', ['cat-file', '--batch-check=%(objectname) %(objecttype)'], { + input: `${refs.map((ref) => `${ref}:skills`).join('\n')}\n`, + encoding: 'utf8' + }) + const lines = output.trimEnd().split('\n') + if (lines.length !== refs.length) { + throw new Error(`Expected ${refs.length} skills tree identities, received ${lines.length}`) + } + return lines.map((line, index) => { + if (line.endsWith(' missing')) { + return null + } + const match = /^([a-f0-9]+) tree$/.exec(line) + if (!match) { + throw new Error(`Unexpected skills tree identity at ${refs[index]}: ${line}`) + } + return match[1] + }) +} + +function buildReleasedHistory() { + const registry = { schemaVersion: SCHEMA_VERSION, skills: {} } + const mapping = { schemaVersion: SCHEMA_VERSION, releases: [] } + const tags = releaseTags() + const treeShas = skillsTreeShasAtRefs(tags) + const distinctTreeShas = [...new Set(treeShas.filter(Boolean))] + const packagesByTree = new Map( + distinctTreeShas.map((treeSha) => [treeSha, collectGitSkillTreeEntries(treeSha)]) + ) + const blobs = readGitBlobs( + [...packagesByTree.values()].flatMap((packages) => + [...packages.values()].flatMap((entries) => entries.map((entry) => entry.objectSha)) + ) + ) + let previousSkillsTreeSha = null + for (const [index, tag] of tags.entries()) { + const skillsTreeSha = treeShas[index] + if (!skillsTreeSha || skillsTreeSha === previousSkillsTreeSha) { + continue + } + previousSkillsTreeSha = skillsTreeSha + const revisions = {} + const packages = packagesByTree.get(skillsTreeSha) + if (!packages) { + throw new Error(`Missing released skill tree ${skillsTreeSha} at ${tag}`) + } + for (const name of [...packages.keys()].sort(compareCodeUnits)) { + const entries = packages.get(name) + const filesWithGitHashes = collectGitPackageFiles(skillsTreeSha, name, entries, blobs) + if (!filesWithGitHashes.some((file) => file.path === 'SKILL.md')) { + continue + } + const digest = packageDigest(filesWithGitHashes) + const snapshots = registry.skills[name] ?? [] + const latest = snapshots.at(-1) + if (!latest || latest.packageDigest !== digest) { + const files = filesWithGitHashes.map(({ gitBlobSha: _gitBlobSha, ...file }) => file) + snapshots.push({ + releaseRevision: (latest?.releaseRevision ?? 0) + 1, + packageDigest: digest, + gitTreeSha: gitTreeSha(filesWithGitHashes), + files + }) + registry.skills[name] = snapshots + } + revisions[name] = snapshots.at(-1).releaseRevision + } + if (Object.keys(revisions).length > 0) { + mapping.releases.push({ appVersion: tag.slice(1), skills: revisions }) + } + } + return { registry, mapping } +} + +async function buildArtifacts(appVersion) { + const { registry, mapping } = buildReleasedHistory() + const releasedSnapshotCounts = Object.fromEntries( + Object.entries(registry.skills).map(([name, snapshots]) => [name, snapshots.length]) + ) + const skillDirectories = (await readdir(SKILLS_ROOT, { withFileTypes: true })) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(compareCodeUnits) + const currentSkills = [] + for (const name of skillDirectories) { + const filesWithGitHashes = await collectPackageFiles(path.join(SKILLS_ROOT, name)) + if (!filesWithGitHashes.some((file) => file.path === 'SKILL.md')) { + throw new Error(`Skill package ${name} has no top-level SKILL.md`) + } + const digest = packageDigest(filesWithGitHashes) + const snapshots = registry.skills[name] ?? [] + const latest = snapshots.at(-1) + let snapshot = latest + if (!latest || latest.packageDigest !== digest) { + const files = filesWithGitHashes.map(({ gitBlobSha: _gitBlobSha, ...file }) => file) + snapshot = { + releaseRevision: (latest?.releaseRevision ?? 0) + 1, + packageDigest: digest, + gitTreeSha: gitTreeSha(filesWithGitHashes), + files + } + snapshots.push(snapshot) + registry.skills[name] = snapshots + } + currentSkills.push({ + name, + sourcePath: `skills/${name}`, + appVersion, + ...snapshot + }) + } + return { + currentManifest: { + schemaVersion: SCHEMA_VERSION, + appVersion, + skills: currentSkills + }, + snapshotRegistry: registry, + releaseMapping: mapping, + releasedSnapshotCounts + } +} + +// Why: released snapshots are the detection ground truth for existing installs, +// so a generation-logic change must not rewrite them silently. Only the one +// unreleased working-tree append per skill may change between runs. +function assertReleasedHistoryPreserved(committedRegistry, artifacts) { + if (!committedRegistry || committedRegistry.schemaVersion !== SCHEMA_VERSION) { + return + } + for (const [name, committedSnapshots] of Object.entries(committedRegistry.skills ?? {})) { + const releasedCount = artifacts.releasedSnapshotCounts[name] ?? 0 + const regenerated = artifacts.snapshotRegistry.skills[name] ?? [] + if (releasedCount < Math.max(0, committedSnapshots.length - 1)) { + throw new Error( + `Released snapshot history is incomplete for ${name}. ` + + 'Fetch all release tags before regenerating skill artifacts.' + ) + } + const protectedCount = Math.min(committedSnapshots.length, releasedCount) + for (let index = 0; index < protectedCount; index += 1) { + const committed = committedSnapshots[index] + const rebuilt = regenerated[index] + if (!rebuilt || !isDeepStrictEqual(rebuilt, committed)) { + throw new Error( + `Released snapshot history changed for ${name} at revision ${committed.releaseRevision}. ` + + 'Released snapshots are append-only; a deliberate identity migration must update this check.' + ) + } + } + } +} + +async function readCommittedRegistry() { + try { + return JSON.parse(await readFile(SNAPSHOT_REGISTRY_PATH, 'utf8')) + } catch { + return null + } +} + +function serialized(value) { + return `${JSON.stringify(value, null, 2)}\n` +} + +async function writeArtifacts(artifacts) { + await mkdir(OUTPUT_ROOT, { recursive: true }) + await Promise.all([ + writeFile(CURRENT_MANIFEST_PATH, serialized(artifacts.currentManifest)), + writeFile(SNAPSHOT_REGISTRY_PATH, serialized(artifacts.snapshotRegistry)), + writeFile(RELEASE_MAPPING_PATH, serialized(artifacts.releaseMapping)) + ]) +} + +async function verifyArtifacts(artifacts) { + const expected = [ + [CURRENT_MANIFEST_PATH, artifacts.currentManifest], + [SNAPSHOT_REGISTRY_PATH, artifacts.snapshotRegistry], + [RELEASE_MAPPING_PATH, artifacts.releaseMapping] + ] + const stale = [] + for (const [filePath, value] of expected) { + try { + await access(filePath, constants.R_OK) + if ((await readFile(filePath, 'utf8')) !== serialized(value)) { + stale.push(filePath) + } + } catch { + stale.push(filePath) + } + } + if (stale.length > 0) { + throw new Error( + `Generated skill artifacts are stale:\n${stale + .map((filePath) => path.relative(REPO_ROOT, filePath)) + .join('\n')}\nRun pnpm generate:skill-bundle-manifest.` + ) + } +} + +async function main() { + const packageJson = JSON.parse(await readFile(path.join(REPO_ROOT, 'package.json'), 'utf8')) + const artifacts = await buildArtifacts(packageJson.version) + assertReleasedHistoryPreserved(await readCommittedRegistry(), artifacts) + await (process.argv.includes('--write') ? writeArtifacts : verifyArtifacts)(artifacts) +} + +if (process.argv[1] && path.resolve(process.argv[1]) === import.meta.filename) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 + }) +} + +export { + assertReleasedHistoryPreserved, + buildArtifacts, + buildReleasedHistory, + classifyFile, + collectPackageFiles, + describeFile, + gitTreeSha, + normalizeText, + packageDigest, + sortManifestFiles, + verifyArtifacts, + writeArtifacts +} diff --git a/config/scripts/generate-skill-bundle-manifest.test.mjs b/config/scripts/generate-skill-bundle-manifest.test.mjs new file mode 100644 index 000000000000..cf4a8a100e5d --- /dev/null +++ b/config/scripts/generate-skill-bundle-manifest.test.mjs @@ -0,0 +1,199 @@ +import { execFileSync } from 'node:child_process' +import { chmod, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + assertReleasedHistoryPreserved, + classifyFile, + collectPackageFiles, + describeFile, + gitTreeSha, + normalizeText, + packageDigest, + sortManifestFiles +} from './generate-skill-bundle-manifest.mjs' + +const temporaryDirectories = [] + +async function createPackage() { + const directory = await mkdtemp(path.join(tmpdir(), 'orca-skill-manifest-')) + temporaryDirectories.push(directory) + return directory +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })) + ) +}) + +describe('skill bundle manifest generator', () => { + it('folds platform line endings for text identity', () => { + const lf = Buffer.from('first\nsecond\n') + const crlf = Buffer.from('first\r\nsecond\r\n') + + expect(classifyFile(lf)).toBe('text') + expect(normalizeText(crlf)).toEqual(lf) + }) + + it('classifies null-containing and invalid UTF-8 content as binary', () => { + expect(classifyFile(Buffer.from([0, 1, 2]))).toBe('binary') + expect(classifyFile(Buffer.from([0xc3, 0x28]))).toBe('binary') + }) + + it('uses normalized text identity but exact executable identity', () => { + const skillFile = describeFile('SKILL.md', Buffer.from('line one\r\nline two\r\n'), false) + const executable = describeFile('run.sh', Buffer.from('#!/bin/sh\r\necho ok\r\n'), true) + + expect(skillFile.identitySha256).toBe(skillFile.textNormalizedSha256) + expect(skillFile.identitySha256).not.toBe(skillFile.exactSha256) + expect(executable.exactSha256).not.toBe(executable.textNormalizedSha256) + expect(executable.identitySha256).toBe(executable.exactSha256) + expect(packageDigest([skillFile, executable])).toMatch(/^[a-f0-9]{64}$/) + }) + + it('orders git-history files identically to the filesystem walk', async () => { + const packageRoot = await createPackage() + await mkdir(path.join(packageRoot, 'sub')) + for (const name of ['apple.md', 'sub.md', 'Zebra.md', path.join('sub', 'inner.txt')]) { + await writeFile(path.join(packageRoot, name), `${name}\n`) + } + const walked = await collectPackageFiles(packageRoot) + + // Why: git ls-tree emits [Zebra.md, apple.md, sub.md, sub/inner.txt]; index-based + // snapshot matching requires history and observation to share one order. + const gitOrdered = ['Zebra.md', 'apple.md', 'sub.md', 'sub/inner.txt'].map((manifestPath) => + walked.find((file) => file.path === manifestPath) + ) + + expect(sortManifestFiles(gitOrdered)).toEqual(walked) + expect(packageDigest(sortManifestFiles(gitOrdered))).toBe(packageDigest(walked)) + expect(walked.map((file) => file.path)).toEqual([ + 'Zebra.md', + 'apple.md', + 'sub/inner.txt', + 'sub.md' + ]) + }) + + it('rejects rewrites of released snapshots and allows floating-tail replacement', () => { + const snapshot = (releaseRevision, packageDigest) => ({ releaseRevision, packageDigest }) + const artifacts = { + releasedSnapshotCounts: { 'orca-cli': 2 }, + snapshotRegistry: { + schemaVersion: 1, + skills: { 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb'), snapshot(3, 'ccc')] } + } + } + + expect(() => + assertReleasedHistoryPreserved( + { schemaVersion: 1, skills: { 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb')] } }, + artifacts + ) + ).not.toThrow() + expect(() => + assertReleasedHistoryPreserved( + { + schemaVersion: 1, + skills: { 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb'), snapshot(3, 'stale')] } + }, + artifacts + ) + ).not.toThrow() + expect(() => + assertReleasedHistoryPreserved( + { + schemaVersion: 1, + skills: { 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'rewritten')] } + }, + artifacts + ) + ).toThrow('Released snapshot history changed for orca-cli at revision 2') + expect(() => + assertReleasedHistoryPreserved( + { + schemaVersion: 1, + skills: { + 'orca-cli': [snapshot(1, 'aaa'), { ...snapshot(2, 'bbb'), gitTreeSha: 'rewritten' }] + } + }, + artifacts + ) + ).toThrow('Released snapshot history changed for orca-cli at revision 2') + expect(() => + assertReleasedHistoryPreserved( + { + schemaVersion: 1, + skills: { + 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb'), snapshot(3, 'stale')] + } + }, + { ...artifacts, releasedSnapshotCounts: { 'orca-cli': 1 } } + ) + ).toThrow('Released snapshot history is incomplete for orca-cli') + expect(() => assertReleasedHistoryPreserved(null, artifacts)).not.toThrow() + }) + + it.runIf(process.platform !== 'win32')( + 'rejects executable files in shipped skill packages', + async () => { + const packageRoot = await createPackage() + await writeFile(path.join(packageRoot, 'SKILL.md'), 'skill\n') + await writeFile(path.join(packageRoot, 'run.sh'), '#!/bin/sh\necho ok\n') + await chmod(path.join(packageRoot, 'run.sh'), 0o755) + + await expect(collectPackageFiles(packageRoot)).rejects.toThrow( + 'Executable file is not allowed in a shipped skill: run.sh' + ) + } + ) + + it.runIf(process.platform === 'linux')('rejects case-colliding paths', async () => { + const packageRoot = await createPackage() + await writeFile(path.join(packageRoot, 'SKILL.md'), 'skill') + await writeFile(path.join(packageRoot, 'Readme.md'), 'one') + await writeFile(path.join(packageRoot, 'README.md'), 'two') + + await expect(collectPackageFiles(packageRoot)).rejects.toThrow('Case-colliding skill paths') + }) + + it.runIf(process.platform !== 'win32')('rejects symlinks inside shipped packages', async () => { + const packageRoot = await createPackage() + await writeFile(path.join(packageRoot, 'SKILL.md'), 'skill') + await symlink('SKILL.md', path.join(packageRoot, 'linked.md')) + + await expect(collectPackageFiles(packageRoot)).rejects.toThrow( + 'Symlink is not allowed in a shipped skill' + ) + }) + + it('computes the same Git tree identity as Git', async () => { + const packageRoot = path.resolve('skills', 'orca-cli') + const files = await collectPackageFiles(packageRoot) + const expected = execFileSync('git', ['ls-tree', 'HEAD:skills', 'orca-cli'], { + encoding: 'utf8' + }) + .trim() + .split(/\s+/)[2] + + expect(gitTreeSha(files)).toBe(expected) + }) + + it('matches Git when a directory and file share a name prefix', async () => { + const packageRoot = await createPackage() + await mkdir(path.join(packageRoot, 'sub')) + await writeFile(path.join(packageRoot, 'sub', 'inner.txt'), 'nested\n') + await writeFile(path.join(packageRoot, 'sub.md'), 'sibling\n') + const files = await collectPackageFiles(packageRoot) + execFileSync('git', ['init', '--quiet'], { cwd: packageRoot }) + execFileSync('git', ['add', '-A'], { cwd: packageRoot }) + const expected = execFileSync('git', ['write-tree'], { + cwd: packageRoot, + encoding: 'utf8' + }).trim() + + expect(gitTreeSha(files)).toBe(expected) + }) +}) diff --git a/config/scripts/verify-skill-update-roundtrip.mjs b/config/scripts/verify-skill-update-roundtrip.mjs new file mode 100644 index 000000000000..bfb10a50494c --- /dev/null +++ b/config/scripts/verify-skill-update-roundtrip.mjs @@ -0,0 +1,249 @@ +import { execFileSync } from 'node:child_process' +import { + chmod, + cp, + lstat, + mkdir, + mkdtemp, + readFile, + realpath, + rm, + symlink, + writeFile +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import process from 'node:process' +import { collectPackageFiles, packageDigest } from './generate-skill-bundle-manifest.mjs' + +function option(name) { + return process.argv.find((value) => value.startsWith(`--${name}=`))?.slice(name.length + 3) +} + +const cliVersion = option('cli') +const autocrlf = option('autocrlf') +const shape = option('shape') +// Why: PR branch names are untrusted workflow input. Keep them out of the +// generated shell command and pass them to Node through the environment. +const source = option('source') ?? process.env.SKILL_UPDATE_SOURCE +const ref = option('ref') ?? process.env.SKILL_UPDATE_REF +if ( + !cliVersion || + (autocrlf !== 'true' && autocrlf !== 'false') || + (shape !== 'symlink' && shape !== 'copy') || + !source || + !ref || + !/^[^/\s]+\/[^/\s]+$/.test(source) +) { + throw new Error( + 'Usage: verify-skill-update-roundtrip.mjs --cli= --autocrlf=true|false --shape=symlink|copy --source= --ref=' + ) +} + +const sandbox = await mkdtemp(path.join(tmpdir(), 'orca-skill-update-roundtrip-')) +const home = path.join(sandbox, 'home') +const stateHome = path.join(home, '.state') +const fakeBin = path.join(sandbox, 'bin') +const targetName = 'orca-cli' +const controlName = 'orchestration' +const manifest = JSON.parse(await readFile('resources/skills/current-manifest.json', 'utf8')) +const registry = JSON.parse(await readFile('resources/skills/snapshot-registry.json', 'utf8')) +const releaseMapping = JSON.parse(await readFile('resources/skills/release-mapping.json', 'utf8')) + +function currentSkill(name) { + const skill = manifest.skills.find((entry) => entry.name === name) + if (!skill) { + throw new Error(`Current manifest is missing ${name}`) + } + return skill +} + +function historicalRelease(name) { + const current = currentSkill(name) + for (const release of releaseMapping.releases.toReversed()) { + const revision = release.skills[name] + if (typeof revision !== 'number' || revision >= current.releaseRevision) { + continue + } + const snapshot = registry.skills[name]?.find((entry) => entry.releaseRevision === revision) + if (snapshot) { + return { tag: `v${release.appVersion}`, snapshot } + } + } + throw new Error(`No historical released snapshot is available for ${name}`) +} + +async function materializePackage(name, tag, destination) { + const prefix = `skills/${name}/` + const entries = execFileSync('git', ['ls-tree', '-r', '-z', tag, '--', `skills/${name}`]) + .toString('utf8') + .split('\0') + .filter(Boolean) + if (entries.length === 0) { + throw new Error(`${tag} does not contain ${name}`) + } + for (const entry of entries) { + const match = /^(\d+) (\w+) ([a-f0-9]+)\t(.+)$/.exec(entry) + if (!match || match[2] !== 'blob') { + throw new Error(`Unsupported historical tree entry: ${entry}`) + } + const relativePath = match[4].slice(prefix.length) + const destinationPath = path.join(destination, ...relativePath.split('/')) + await mkdir(path.dirname(destinationPath), { recursive: true }) + await writeFile(destinationPath, execFileSync('git', ['cat-file', 'blob', match[3]])) + if (process.platform !== 'win32' && match[1] === '100755') { + await chmod(destinationPath, 0o755) + } + } +} + +async function seedPlacement(name, tag) { + const canonical = path.join(home, '.agents', 'skills', name) + await materializePackage(name, tag, canonical) + const providerRoot = path.join(home, '.claude', 'skills') + const provider = path.join(providerRoot, name) + await mkdir(providerRoot, { recursive: true }) + await (shape === 'copy' + ? cp(canonical, provider, { recursive: true }) + : symlink(canonical, provider, process.platform === 'win32' ? 'junction' : 'dir')) +} + +async function installFakeAgentCommands() { + await mkdir(fakeBin, { recursive: true }) + for (const name of ['codex', 'claude']) { + const executable = path.join(fakeBin, process.platform === 'win32' ? `${name}.cmd` : name) + await writeFile( + executable, + process.platform === 'win32' ? '@exit /b 0\r\n' : '#!/bin/sh\nexit 0\n' + ) + if (process.platform !== 'win32') { + await chmod(executable, 0o755) + } + } +} + +async function packageDigestAt(pathValue) { + return packageDigest(await collectPackageFiles(pathValue)) +} + +async function assertCurrentCanonical(name) { + const expected = currentSkill(name).packageDigest + const canonical = path.join(home, '.agents', 'skills', name) + if ((await packageDigestAt(canonical)) !== expected) { + throw new Error(`${name} canonical placement did not update to the PR content`) + } +} + +function execSkills(args) { + const executable = process.platform === 'win32' ? (process.env.ComSpec ?? 'cmd.exe') : 'npx' + const cliArgs = ['--yes', `skills@${cliVersion}`, ...args] + execFileSync( + executable, + process.platform === 'win32' ? ['/d', '/s', '/c', 'npx.cmd', ...cliArgs] : cliArgs, + { + cwd: process.cwd(), + env: { + ...process.env, + HOME: home, + USERPROFILE: home, + CODEX_HOME: path.join(home, '.codex'), + CLAUDE_CONFIG_DIR: path.join(home, '.claude'), + XDG_STATE_HOME: stateHome, + GIT_CONFIG_COUNT: '1', + GIT_CONFIG_KEY_0: 'core.autocrlf', + GIT_CONFIG_VALUE_0: autocrlf, + PATH: `${fakeBin}${path.delimiter}${process.env.PATH ?? ''}`, + CI: '1' + }, + stdio: 'inherit' + } + ) +} + +try { + const targetHistorical = historicalRelease(targetName) + const controlHistorical = historicalRelease(controlName) + await installFakeAgentCommands() + await mkdir(path.join(home, '.codex'), { recursive: true }) + await mkdir(path.join(home, '.claude'), { recursive: true }) + await seedPlacement(targetName, targetHistorical.tag) + await seedPlacement(controlName, controlHistorical.tag) + const targetProvider = path.join(home, '.claude', 'skills', targetName) + const controlCanonical = path.join(home, '.agents', 'skills', controlName) + const controlProvider = path.join(home, '.claude', 'skills', controlName) + const targetProviderBefore = await packageDigestAt(await realpath(targetProvider)) + const controlBefore = await packageDigestAt(controlCanonical) + const controlProviderBefore = await packageDigestAt(await realpath(controlProvider)) + + const timestamp = new Date().toISOString() + const lock = { + version: 3, + skills: { + [targetName]: { + source, + sourceType: 'github', + sourceUrl: `https://github.com/${source}.git`, + ref, + skillPath: `skills/${targetName}/SKILL.md`, + skillFolderHash: targetHistorical.snapshot.gitTreeSha, + installedAt: timestamp, + updatedAt: timestamp + }, + [controlName]: { + source, + sourceType: 'github', + sourceUrl: `https://github.com/${source}.git`, + ref, + skillPath: `skills/${controlName}/SKILL.md`, + skillFolderHash: controlHistorical.snapshot.gitTreeSha, + installedAt: timestamp, + updatedAt: timestamp + } + } + } + const lockPath = path.join(stateHome, 'skills', '.skill-lock.json') + await mkdir(path.dirname(lockPath), { recursive: true }) + await writeFile(lockPath, `${JSON.stringify(lock, null, 2)}\n`) + + // Why: this is the exact user-visible rail. A bare update would include + // unrelated vendors, while this command must leave the control skill alone. + execSkills(['update', targetName, '--global']) + await assertCurrentCanonical(targetName) + const targetProviderAfter = await packageDigestAt(await realpath(targetProvider)) + const targetProviderStat = await lstat(targetProvider) + if (shape === 'symlink' && !targetProviderStat.isSymbolicLink()) { + throw new Error(`${targetName} provider alias was replaced with an independent copy`) + } + if (shape === 'symlink' && targetProviderAfter !== currentSkill(targetName).packageDigest) { + throw new Error(`${targetName} provider alias did not converge with the canonical update`) + } + if ( + shape === 'copy' && + targetProviderAfter !== targetProviderBefore && + targetProviderAfter !== currentSkill(targetName).packageDigest + ) { + throw new Error('Independent provider copy changed to an unexpected package identity') + } + if (shape === 'copy') { + // Why: hosted 1.5.17 replaces copies with aliases while equivalent local runs + // retain the copy. Both prove this input topology must remain ineligible. + const outcome = targetProviderStat.isSymbolicLink() + ? 'converged to an alias' + : targetProviderAfter === targetProviderBefore + ? 'remained a historical copy' + : 'converged as a copy' + console.log(`[skill-update-roundtrip] independent copy ${outcome}`) + } + if ((await packageDigestAt(controlCanonical)) !== controlBefore) { + throw new Error('Targeted update changed the non-targeted control skill') + } + if ((await packageDigestAt(await realpath(controlProvider))) !== controlProviderBefore) { + throw new Error('Targeted update changed the non-targeted control provider placement') + } + const controlProviderStat = await lstat(controlProvider) + if (shape === 'symlink' && !controlProviderStat.isSymbolicLink()) { + throw new Error('Targeted update changed the non-targeted control topology') + } +} finally { + await rm(sandbox, { recursive: true, force: true }) +} diff --git a/notes/skill-freshness-design.md b/notes/skill-freshness-design.md index 5da3b2428171..8d12f8915a73 100644 --- a/notes/skill-freshness-design.md +++ b/notes/skill-freshness-design.md @@ -126,6 +126,7 @@ Rules: ### C. Read-only detection (kept from Phase 1, slimmed) Kept as-is: + - Bundled `skills/` packages + current manifest + released-snapshot registry + release mapping, with the generation script and merge-queue monotonicity gate (static data + CI, not runtime machinery). @@ -135,9 +136,12 @@ Kept as-is: plugin caches and repo scopes excluded), and the launch / focus / post-install triggers. - The skills-CLI round-trip CI on macOS/Linux/Windows — extended from current-install tests to historical-fat-install → targeted global update → stub migration. The matrix covers - copy/symlink shapes, LF/CRLF, supported lock migrations, and post-update identity. + LF/CRLF and provider aliases as a positive convergence contract, plus independent-copy + observation that accepts only unchanged historical or exact-current bytes. Post-update + bytes, not exit status, decide. Slimmed: + - Statuses collapse to: `current`, `outdated` (exact match of an older released snapshot), `newer-known`, `unrecognized`, and `inaccessible`. Without a ledger, Orca cannot honestly distinguish a locally modified official copy from unrelated same-named content; @@ -151,32 +155,51 @@ Slimmed: ### D. Surfacing -- **Settings rows** (read-only): name, status badge, one-line explanation. `newer-known`, - `unrecognized`, `inaccessible`, and unsupported-topology rows are informational. -- **Name-scoped update eligibility:** the skills CLI reinstalls every placement of a selected - skill name, so eligibility is computed across all discovered placements of that name, not - per row. Offer a name only when at least one placement is `outdated` and every placement is - an exact `current` or `outdated` official snapshot in a supported global topology. One - `newer-known`, unrecognized, external, read-only, inaccessible, or otherwise unsupported - provider copy poisons the update offer for that name entirely. +- **Surfaces (venue decision 2026-07-14):** a lingering toast, an update modal, and the + existing Settings setup rails for CLI, Orchestration, Computer Use, and Per-Workspace + Environments. Their installed pills carry safe freshness status, while their existing + Update and Re-check actions remain the per-skill path. The Skills page was + de-linked by #4535 (2026-06-02) — its only entry, the sidebar toolbox menu, was removed — + so it is no longer a venue; the freshness surface moved off it entirely. The behavior + contracts below (name-scoped eligibility, no auto-run, dismissal keys, re-inventory + triggers) are unchanged; only the venue moved. +- **Per-placement rows** (read-only): name, status badge, one-line explanation. `newer-known`, + `unrecognized`, `inaccessible`, and unsupported-topology rows are informational. They live in + the modal's collapsed **Details** section (auto-expanded when a placement is blocked). +- **Name-scoped update eligibility:** eligibility is computed across all discovered placements + of a name, not per row. Offer a name only when at least one placement is `outdated` and every + placement is an exact `current` or `outdated` official snapshot in a topology the validated + rail actually converges. With skills CLI 1.5.17 that means the canonical global copy and + provider aliases to it. Independent provider copies are informational and poison the offer: + empirical copy-mode testing produced both stale and converged provider copies in otherwise + equivalent 1.5.17 environments, so that topology is not deterministic enough to offer. One + `newer-known`, unrecognized, external, read-only, + inaccessible, repo/plugin, independent-copy, or otherwise unsupported placement poisons the + update offer for that name entirely. - The action combines only eligible outdated Orca names into - `npx skills update --global`, opens the existing run-command terminal with that - command pre-filled, and leaves execution to the user. Never use an unscoped bulk update and - never auto-submit the command. Re-inventory after terminal exit or focus; only observed - bytes, not the skills CLI exit status, determine success. -- **One non-repeating nudge**: count only eligible outdated skill names and offer the same - targeted run-the-command action. An outdated name poisoned by another placement remains - visible in settings but never produces an unsafe nudge action. Dismissal is recorded per - (install, bundled revision), so a newly outdated official placement or genuinely newer - stub revision may prompt once more. No toggle — nothing automatic happens that would need - one. + `npx skills update --global` and opens the update modal's editable terminal with + that command pre-filled, leaving execution to the user. Never use an unscoped bulk update and + never auto-submit the command. Re-inventory after terminal exit, modal close, or focus; only + observed bytes, not the skills CLI exit status, determine success. When the eligible set + empties and every placement is `current`, the modal shows an up-to-date state; if placements + remain outdated-but-blocked or unrecognized, it says so honestly instead. +- **One lingering, non-repeating nudge**: count only eligible outdated skill names and offer the + same targeted action, which opens the update modal. The toast lingers (no auto-close) until the + user opens the modal or explicitly dismisses it; ignoring it (app quit) records nothing, so a + still-outdated skill may prompt once more next launch. A later inventory that resolves or blocks + the offered tuple retracts the stale toast without recording a dismissal. An outdated name + poisoned by another placement remains visible in the modal's Details but never produces an + unsafe nudge action. Dismissal is recorded per (physical identity, name, bundled revision) only + on explicit dismissal, so a newly outdated official placement or genuinely newer stub revision + may prompt once more. No + toggle — nothing automatic happens that would need one. ### E. Migration (fat → stub) 1. **Implemented, pending release:** from a fresh main-based PR, add authoritative guide sources, generated embedded data, `orca skills list/get`, aliases, generated-output checks, and local/SSH/WSL/dev tests. Keep distributed skills fat and ship this release first. -2. From a separate PR, land slim read-only detection and settings/nudge UI, including the +2. From a separate PR, land slim read-only detection and Skills-page/nudge UI, including the name-scoped targeted update action and the real migration-rail CI. Keep distributed skills fat. 3. Run the pointer-compliance spike against the released guide-serving binary, not a checkout @@ -211,15 +234,17 @@ not thin, until the relevant variant passes. global Windows failures, missing global lock tracking, lossy lock migration, and copy-mode topology changes. The historical-fat → targeted-global-update → stub CI is a release gate, not an early-warning job. Detection always re-checks bytes after the user updates, so a - failed or no-op update re-surfaces `outdated` instead of lying. Choose and document a - validated CLI-version policy before rollout; monitor and contribute upstream fixes. + failed or no-op update re-surfaces `outdated` instead of lying. Minimum validated version is + 1.5.17: 1.5.16 failed the provider-alias convergence contract, while 1.5.17 copy convergence + still varies by environment. CI pins 1.5.17 and probes latest; monitor and contribute upstream + fixes before broadening eligibility. - **Trigger-copy iteration slows.** Improvements to stub descriptions reach existing installs only when users run the npx command. Acceptable at stub-change cadence; the compiled guides (the content that matters) are exempt by construction. - **Multi-file skills.** Current shipped packages are single-file. If a future skill needs scripts/assets, either the binary serves them (`--full` / `--script`) or that skill accepts the fat-file decay model. Decide when it happens. -- **Remote hosts.** Detection ships local-host-only. Stubs make remote *content* a non-issue: +- **Remote hosts.** Detection ships local-host-only. Stubs make remote _content_ a non-issue: SSH/WSL launchers forward to the host's bundled CLI, so the guide matches the command surface that will handle subsequent requests. Remote stub installs can lag on trigger copy, which is the accepted residual. The WSL/SSH reconciler phases of the old design are diff --git a/package.json b/package.json index 6510dbf0de69..c7e41d8ce0bd 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "main": "./out/main/index.js", "scripts": { "format": "oxfmt --write .", - "lint": "oxlint && pnpm run lint:switch-exhaustiveness && node config/scripts/check-styled-scrollbars.mjs && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run verify:bundled-skill-guides && pnpm run verify:localization-catalog && pnpm run verify:localization-coverage", + "lint": "oxlint && pnpm run lint:switch-exhaustiveness && node config/scripts/check-styled-scrollbars.mjs && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalog && pnpm run verify:localization-coverage", "lint:react-doctor": "oxlint --config config/oxlint-react-doctor.json", "lint:react-doctor:changed": "node config/scripts/lint-react-doctor-changed.mjs", "lint:switch-exhaustiveness": "oxlint --type-aware --config config/oxlint-switch-exhaustiveness.json src/main src/preload src/shared src/relay src/cli src/renderer/src config tests --quiet", @@ -23,6 +23,8 @@ "check:feature-wall-assets": "node config/scripts/check-feature-wall-assets.mjs", "generate:bundled-skill-guides": "node config/scripts/generate-bundled-skill-guides.mjs --write", "verify:bundled-skill-guides": "node config/scripts/generate-bundled-skill-guides.mjs --check", + "generate:skill-bundle-manifest": "node config/scripts/generate-skill-bundle-manifest.mjs --write", + "verify:skill-bundle-manifest": "node config/scripts/generate-skill-bundle-manifest.mjs", "verify:macos-entitlements": "node config/scripts/verify-macos-entitlements.mjs", "vendor:feature-wall-assets": "node config/scripts/vendor-feature-wall-assets.mjs", "tc:node": "pnpm run typecheck:node", diff --git a/resources/skills/current-manifest.json b/resources/skills/current-manifest.json new file mode 100644 index 000000000000..fca1162fa5e5 --- /dev/null +++ b/resources/skills/current-manifest.json @@ -0,0 +1,158 @@ +{ + "schemaVersion": 1, + "appVersion": "1.4.144-rc.1", + "skills": [ + { + "name": "computer-use", + "sourcePath": "skills/computer-use", + "appVersion": "1.4.144-rc.1", + "releaseRevision": 5, + "packageDigest": "cd2809474d57fd7277adb277448e6fa446810d3cbad71ac0b473b9e8ff1bad68", + "gitTreeSha": "306c0f8cb63bcac265a5b7975dc2f855be4f1344", + "files": [ + { + "path": "SKILL.md", + "size": 11241, + "executable": false, + "classification": "text", + "exactSha256": "f49b29fb6b209956907688692387adcdc509fad344555f09badaf383106f5f39", + "textNormalizedSha256": "f49b29fb6b209956907688692387adcdc509fad344555f09badaf383106f5f39", + "identitySha256": "f49b29fb6b209956907688692387adcdc509fad344555f09badaf383106f5f39" + } + ] + }, + { + "name": "linear-tickets", + "sourcePath": "skills/linear-tickets", + "appVersion": "1.4.144-rc.1", + "releaseRevision": 4, + "packageDigest": "f198d7b22e5ee1673dac403f9cca0553b124e0a90e4fdd05d2c23b7344e32d2b", + "gitTreeSha": "de9fc106bbb4e313a90ff9a9513a720909bbd176", + "files": [ + { + "path": "SKILL.md", + "size": 10596, + "executable": false, + "classification": "text", + "exactSha256": "0c3077c93328b9965430cd6951f1a35889b8c0775037870ea3a8bcf303d9d2c5", + "textNormalizedSha256": "0c3077c93328b9965430cd6951f1a35889b8c0775037870ea3a8bcf303d9d2c5", + "identitySha256": "0c3077c93328b9965430cd6951f1a35889b8c0775037870ea3a8bcf303d9d2c5" + } + ] + }, + { + "name": "orca-cli", + "sourcePath": "skills/orca-cli", + "appVersion": "1.4.144-rc.1", + "releaseRevision": 32, + "packageDigest": "51740ff13f379ac5743d3fd28a14b17168dcef40f7048c20182dce166098c45f", + "gitTreeSha": "ded93000a5f654e2b4f324501282459bd56afe19", + "files": [ + { + "path": "SKILL.md", + "size": 21557, + "executable": false, + "classification": "text", + "exactSha256": "b4c36e19fc158fc8c286bdfdf05a537985cb2159a596c93862968c5417bf15be", + "textNormalizedSha256": "b4c36e19fc158fc8c286bdfdf05a537985cb2159a596c93862968c5417bf15be", + "identitySha256": "b4c36e19fc158fc8c286bdfdf05a537985cb2159a596c93862968c5417bf15be" + } + ] + }, + { + "name": "orca-emulator", + "sourcePath": "skills/orca-emulator", + "appVersion": "1.4.144-rc.1", + "releaseRevision": 4, + "packageDigest": "453b1d9aa20b51b8a4d32c7b6def6a93f7ef9c730de32abbcbc1788ad1b1820b", + "gitTreeSha": "66be6abe99f1807da85934aee0e22daefc8f7656", + "files": [ + { + "path": "SKILL.md", + "size": 11527, + "executable": false, + "classification": "text", + "exactSha256": "84dbfacf6854874e369840c011e78603e533273fb848d21dac3cfb08e0346429", + "textNormalizedSha256": "84dbfacf6854874e369840c011e78603e533273fb848d21dac3cfb08e0346429", + "identitySha256": "84dbfacf6854874e369840c011e78603e533273fb848d21dac3cfb08e0346429" + } + ] + }, + { + "name": "orca-emulator-android", + "sourcePath": "skills/orca-emulator-android", + "appVersion": "1.4.144-rc.1", + "releaseRevision": 2, + "packageDigest": "12272cf82e0731f11e424822b961882457034e730358cc65ea28e4eb9c8ff7f5", + "gitTreeSha": "f7b0fc8cbf5cd78ca5156f6bbe3a20f1462d8f83", + "files": [ + { + "path": "SKILL.md", + "size": 8886, + "executable": false, + "classification": "text", + "exactSha256": "1035d4db357923e98d5075c0c21bc9995b00a36a3739543516fe45ae5ded0332", + "textNormalizedSha256": "1035d4db357923e98d5075c0c21bc9995b00a36a3739543516fe45ae5ded0332", + "identitySha256": "1035d4db357923e98d5075c0c21bc9995b00a36a3739543516fe45ae5ded0332" + } + ] + }, + { + "name": "orca-linear", + "sourcePath": "skills/orca-linear", + "appVersion": "1.4.144-rc.1", + "releaseRevision": 2, + "packageDigest": "d44d09e6ecb6a64da177083aad26a95f031cd1cf26ba059fdc888c2628aef64f", + "gitTreeSha": "c34f42030f43e5a85737996fa375bbd79cb5bea8", + "files": [ + { + "path": "SKILL.md", + "size": 10320, + "executable": false, + "classification": "text", + "exactSha256": "48ded55ec3842ce65105e6db7adf9bc9ed263ece08555cec056e68e90321c3d5", + "textNormalizedSha256": "48ded55ec3842ce65105e6db7adf9bc9ed263ece08555cec056e68e90321c3d5", + "identitySha256": "48ded55ec3842ce65105e6db7adf9bc9ed263ece08555cec056e68e90321c3d5" + } + ] + }, + { + "name": "orca-per-workspace-env", + "sourcePath": "skills/orca-per-workspace-env", + "appVersion": "1.4.144-rc.1", + "releaseRevision": 2, + "packageDigest": "fa3b65a1a107fca3f0375c696852477b62f58c154b9eb5c0663c41edc4bcd30d", + "gitTreeSha": "354e775b79ea6952ec63acac4d3ee8a9ae07a650", + "files": [ + { + "path": "SKILL.md", + "size": 43769, + "executable": false, + "classification": "text", + "exactSha256": "58e479bd18c4c553df0dfcb408eece2fbe550a0f9688bc289414420f72ed7ea7", + "textNormalizedSha256": "58e479bd18c4c553df0dfcb408eece2fbe550a0f9688bc289414420f72ed7ea7", + "identitySha256": "58e479bd18c4c553df0dfcb408eece2fbe550a0f9688bc289414420f72ed7ea7" + } + ] + }, + { + "name": "orchestration", + "sourcePath": "skills/orchestration", + "appVersion": "1.4.144-rc.1", + "releaseRevision": 24, + "packageDigest": "9fbfa2ae3f3f99441563a4b8b1c6302107944480db8718ddb326a862a51f7ab9", + "gitTreeSha": "086c41e0b353b4908d2963694b4a7c791d4b3982", + "files": [ + { + "path": "SKILL.md", + "size": 22861, + "executable": false, + "classification": "text", + "exactSha256": "0cb898d37560ab7ac4cb477a2d7e8ba3e593d45affd6d7e9f99151879aaeb1a5", + "textNormalizedSha256": "0cb898d37560ab7ac4cb477a2d7e8ba3e593d45affd6d7e9f99151879aaeb1a5", + "identitySha256": "0cb898d37560ab7ac4cb477a2d7e8ba3e593d45affd6d7e9f99151879aaeb1a5" + } + ] + } + ] +} diff --git a/resources/skills/release-mapping.json b/resources/skills/release-mapping.json new file mode 100644 index 000000000000..12baa4b6e7f6 --- /dev/null +++ b/resources/skills/release-mapping.json @@ -0,0 +1,501 @@ +{ + "schemaVersion": 1, + "releases": [ + { + "appVersion": "1.0.81", + "skills": { + "orca-cli": 1 + } + }, + { + "appVersion": "1.0.86", + "skills": { + "orca-cli": 2 + } + }, + { + "appVersion": "1.0.94-rc.0", + "skills": { + "orca-cli": 3 + } + }, + { + "appVersion": "1.3.8-rc.1", + "skills": { + "orca-cli": 4 + } + }, + { + "appVersion": "1.3.12-rc.4", + "skills": { + "orca-cli": 5 + } + }, + { + "appVersion": "1.3.24-rc.2", + "skills": { + "orca-cli": 6, + "orchestration": 1 + } + }, + { + "appVersion": "1.3.33-rc.3", + "skills": { + "mobile-fit-debug": 1, + "orca-cli": 6, + "orchestration": 1 + } + }, + { + "appVersion": "0.0.1", + "skills": { + "orca-cli": 6, + "orchestration": 1 + } + }, + { + "appVersion": "1.3.35-rc.0", + "skills": { + "mobile-fit-debug": 1, + "orca-cli": 6, + "orchestration": 1 + } + }, + { + "appVersion": "1.3.38-rc.3", + "skills": { + "orca-cli": 6, + "orchestration": 1 + } + }, + { + "appVersion": "1.3.38", + "skills": { + "mobile-fit-debug": 1, + "orca-cli": 6, + "orchestration": 1 + } + }, + { + "appVersion": "1.3.39", + "skills": { + "orca-cli": 6, + "orchestration": 1 + } + }, + { + "appVersion": "1.3.46-rc.3", + "skills": { + "orca-cli": 7, + "orchestration": 2 + } + }, + { + "appVersion": "1.3.47-rc.0", + "skills": { + "orca-cli": 8, + "orchestration": 3 + } + }, + { + "appVersion": "1.3.49-rc.0", + "skills": { + "computer-use": 1, + "orca-cli": 8, + "orchestration": 3 + } + }, + { + "appVersion": "1.4.2-rc.10", + "skills": { + "computer-use": 1, + "orca-cli": 9, + "orchestration": 4 + } + }, + { + "appVersion": "1.4.2", + "skills": { + "computer-use": 1, + "orca-cli": 10, + "orchestration": 5 + } + }, + { + "appVersion": "1.4.3-rc.0", + "skills": { + "computer-use": 1, + "orca-cli": 11, + "orchestration": 6 + } + }, + { + "appVersion": "1.4.9-rc.1", + "skills": { + "computer-use": 1, + "orca-cli": 12, + "orchestration": 6 + } + }, + { + "appVersion": "1.4.10-rc.3", + "skills": { + "computer-use": 1, + "orca-cli": 13, + "orchestration": 6 + } + }, + { + "appVersion": "1.4.11-rc.0", + "skills": { + "computer-use": 1, + "orca-cli": 14, + "orchestration": 6 + } + }, + { + "appVersion": "1.4.18-rc.2", + "skills": { + "computer-use": 1, + "orca-cli": 15, + "orchestration": 6 + } + }, + { + "appVersion": "1.4.18-rc.3", + "skills": { + "computer-use": 1, + "orca-cli": 16, + "orchestration": 6 + } + }, + { + "appVersion": "1.4.22", + "skills": { + "computer-use": 1, + "orca-cli": 16, + "orchestration": 7 + } + }, + { + "appVersion": "1.4.36-rc.2", + "skills": { + "computer-use": 1, + "orca-cli": 17, + "orchestration": 8 + } + }, + { + "appVersion": "1.4.36-rc.6", + "skills": { + "computer-use": 1, + "orca-cli": 18, + "orchestration": 9 + } + }, + { + "appVersion": "1.4.42-rc.8", + "skills": { + "computer-use": 2, + "orca-cli": 19, + "orchestration": 10 + } + }, + { + "appVersion": "1.4.46-rc.0", + "skills": { + "computer-use": 2, + "orca-cli": 19, + "orchestration": 11 + } + }, + { + "appVersion": "1.4.51-rc.8", + "skills": { + "computer-use": 3, + "orca-cli": 19, + "orchestration": 11 + } + }, + { + "appVersion": "1.4.51-rc.9", + "skills": { + "computer-use": 3, + "orca-cli": 20, + "orca-emulator": 1, + "orchestration": 12 + } + }, + { + "appVersion": "1.4.65-rc.3", + "skills": { + "computer-use": 3, + "linear-tickets": 1, + "orca-cli": 20, + "orca-emulator": 1, + "orchestration": 12 + } + }, + { + "appVersion": "1.4.65", + "skills": { + "computer-use": 3, + "orca-cli": 20, + "orca-emulator": 1, + "orchestration": 12 + } + }, + { + "appVersion": "1.4.66-rc.0", + "skills": { + "computer-use": 3, + "linear-tickets": 1, + "orca-cli": 20, + "orca-emulator": 1, + "orchestration": 12 + } + }, + { + "appVersion": "1.4.67-rc.0", + "skills": { + "computer-use": 3, + "linear-tickets": 2, + "orca-cli": 20, + "orca-emulator": 1, + "orchestration": 12 + } + }, + { + "appVersion": "1.4.68", + "skills": { + "computer-use": 3, + "linear-tickets": 2, + "orca-cli": 20, + "orca-emulator": 1, + "orchestration": 13 + } + }, + { + "appVersion": "1.4.74-rc.0", + "skills": { + "computer-use": 3, + "linear-tickets": 2, + "orca-cli": 21, + "orca-emulator": 1, + "orchestration": 14 + } + }, + { + "appVersion": "1.4.78-rc.1", + "skills": { + "computer-use": 3, + "linear-tickets": 2, + "orca-cli": 22, + "orca-emulator": 1, + "orchestration": 15 + } + }, + { + "appVersion": "1.4.78-rc.2", + "skills": { + "computer-use": 3, + "linear-tickets": 2, + "orca-cli": 23, + "orca-emulator": 1, + "orchestration": 16 + } + }, + { + "appVersion": "1.4.81-rc.2", + "skills": { + "computer-use": 3, + "linear-tickets": 2, + "orca-cli": 24, + "orca-emulator": 1, + "orchestration": 16 + } + }, + { + "appVersion": "1.4.81-rc.3", + "skills": { + "computer-use": 3, + "linear-tickets": 2, + "orca-cli": 25, + "orca-emulator": 1, + "orchestration": 16 + } + }, + { + "appVersion": "1.4.88", + "skills": { + "computer-use": 3, + "linear-tickets": 2, + "orca-cli": 26, + "orca-emulator": 1, + "orchestration": 17 + } + }, + { + "appVersion": "1.4.90", + "skills": { + "computer-use": 3, + "linear-tickets": 3, + "orca-cli": 26, + "orca-emulator": 1, + "orca-linear": 1, + "orchestration": 17 + } + }, + { + "appVersion": "1.4.92-rc.1", + "skills": { + "computer-use": 3, + "linear-tickets": 3, + "orca-cli": 27, + "orca-emulator": 1, + "orca-linear": 1, + "orchestration": 17 + } + }, + { + "appVersion": "1.4.96-rc.1", + "skills": { + "computer-use": 3, + "linear-tickets": 3, + "orca-cli": 28, + "orca-emulator": 1, + "orca-linear": 1, + "orchestration": 17 + } + }, + { + "appVersion": "1.4.105-rc.2", + "skills": { + "computer-use": 3, + "linear-tickets": 3, + "orca-cli": 29, + "orca-emulator": 2, + "orca-emulator-android": 1, + "orca-linear": 1, + "orchestration": 18 + } + }, + { + "appVersion": "1.4.111-rc.0", + "skills": { + "computer-use": 3, + "linear-tickets": 3, + "orca-cli": 29, + "orca-emulator": 2, + "orca-emulator-android": 1, + "orca-linear": 1, + "orca-per-workspace-env": 1, + "orchestration": 18 + } + }, + { + "appVersion": "1.4.124-rc.6", + "skills": { + "computer-use": 3, + "linear-tickets": 3, + "orca-cli": 29, + "orca-emulator": 2, + "orca-emulator-android": 1, + "orca-linear": 1, + "orca-per-workspace-env": 2, + "orchestration": 18 + } + }, + { + "appVersion": "1.4.124-rc.9", + "skills": { + "computer-use": 3, + "linear-tickets": 3, + "orca-cli": 29, + "orca-emulator": 2, + "orca-emulator-android": 1, + "orca-linear": 1, + "orca-per-workspace-env": 2, + "orchestration": 19 + } + }, + { + "appVersion": "1.4.124", + "skills": { + "computer-use": 3, + "linear-tickets": 4, + "orca-cli": 29, + "orca-emulator": 2, + "orca-emulator-android": 1, + "orca-linear": 2, + "orca-per-workspace-env": 2, + "orchestration": 20 + } + }, + { + "appVersion": "1.4.137-rc.1", + "skills": { + "computer-use": 3, + "linear-tickets": 4, + "orca-cli": 30, + "orca-emulator": 2, + "orca-emulator-android": 1, + "orca-linear": 2, + "orca-per-workspace-env": 2, + "orchestration": 21 + } + }, + { + "appVersion": "1.4.138-rc.0", + "skills": { + "computer-use": 4, + "linear-tickets": 4, + "orca-cli": 31, + "orca-emulator": 3, + "orca-emulator-android": 1, + "orca-linear": 2, + "orca-per-workspace-env": 2, + "orchestration": 22 + } + }, + { + "appVersion": "1.4.138-rc.3", + "skills": { + "computer-use": 4, + "linear-tickets": 4, + "orca-cli": 31, + "orca-emulator": 3, + "orca-emulator-android": 1, + "orca-linear": 2, + "orca-per-workspace-env": 2, + "orchestration": 23 + } + }, + { + "appVersion": "1.4.138-rc.4", + "skills": { + "computer-use": 4, + "linear-tickets": 4, + "orca-cli": 31, + "orca-emulator": 3, + "orca-emulator-android": 1, + "orca-linear": 2, + "orca-per-workspace-env": 2, + "orchestration": 24 + } + }, + { + "appVersion": "1.4.141", + "skills": { + "computer-use": 5, + "linear-tickets": 4, + "orca-cli": 32, + "orca-emulator": 4, + "orca-emulator-android": 2, + "orca-linear": 2, + "orca-per-workspace-env": 2, + "orchestration": 24 + } + } + ] +} diff --git a/resources/skills/snapshot-registry.json b/resources/skills/snapshot-registry.json new file mode 100644 index 000000000000..58d6c7f6c0e1 --- /dev/null +++ b/resources/skills/snapshot-registry.json @@ -0,0 +1,1239 @@ +{ + "schemaVersion": 1, + "skills": { + "orca-cli": [ + { + "releaseRevision": 1, + "packageDigest": "a3831a8a5561b24f085cb3c185c23ccc368970898119d90723aa3406778aecb6", + "gitTreeSha": "aed90e508a1acac62d3f22ceba3afa5489cffb84", + "files": [ + { + "path": "SKILL.md", + "size": 5547, + "executable": false, + "classification": "text", + "exactSha256": "5a3111c5a5b0edcf53217a3b93a9dafd9ee6dbc77eaa78ab850e3706d27fb3b1", + "textNormalizedSha256": "5a3111c5a5b0edcf53217a3b93a9dafd9ee6dbc77eaa78ab850e3706d27fb3b1", + "identitySha256": "5a3111c5a5b0edcf53217a3b93a9dafd9ee6dbc77eaa78ab850e3706d27fb3b1" + } + ] + }, + { + "releaseRevision": 2, + "packageDigest": "e0ed74622f5f77a6339f6f2632dc6edb0b724c7f69a4d2ffecaa45bd0995865a", + "gitTreeSha": "d9d879b3040843ae434462db46939b1eac410304", + "files": [ + { + "path": "SKILL.md", + "size": 5850, + "executable": false, + "classification": "text", + "exactSha256": "6f85d7a0d4dde6bf8310d1db6373865143053aa97fbe69f7a3f96fc2be697aeb", + "textNormalizedSha256": "6f85d7a0d4dde6bf8310d1db6373865143053aa97fbe69f7a3f96fc2be697aeb", + "identitySha256": "6f85d7a0d4dde6bf8310d1db6373865143053aa97fbe69f7a3f96fc2be697aeb" + } + ] + }, + { + "releaseRevision": 3, + "packageDigest": "0032941edd38a56de97a56f50c7688e38bcb566735310a9321bb8a7959a06878", + "gitTreeSha": "57ab93e99a12dc947d4329ef7d1227d24bf3dd26", + "files": [ + { + "path": "SKILL.md", + "size": 8871, + "executable": false, + "classification": "text", + "exactSha256": "3637f50e20b0305d1b5b5962d117f121bd2323651023e74c6dfd1a2aa170f681", + "textNormalizedSha256": "3637f50e20b0305d1b5b5962d117f121bd2323651023e74c6dfd1a2aa170f681", + "identitySha256": "3637f50e20b0305d1b5b5962d117f121bd2323651023e74c6dfd1a2aa170f681" + } + ] + }, + { + "releaseRevision": 4, + "packageDigest": "d30e6b53ff35008ffe467e2557326326575ac5d37fab88019b1d8c2d6fb8eeba", + "gitTreeSha": "f43280abdf0e484966ce840600a6cae4963f9d64", + "files": [ + { + "path": "SKILL.md", + "size": 24622, + "executable": false, + "classification": "text", + "exactSha256": "baa87d5188d2cab8f0ffcfa694fdcbd6ca745c4b0690851241bfd81c2b038e56", + "textNormalizedSha256": "baa87d5188d2cab8f0ffcfa694fdcbd6ca745c4b0690851241bfd81c2b038e56", + "identitySha256": "baa87d5188d2cab8f0ffcfa694fdcbd6ca745c4b0690851241bfd81c2b038e56" + } + ] + }, + { + "releaseRevision": 5, + "packageDigest": "1f2ae2e3922a93e020feebfa799b993557fead2e8c83ce03b5646767ea354f19", + "gitTreeSha": "f8a840864ad01f1c3892a1bf05fb699c5f4543e5", + "files": [ + { + "path": "SKILL.md", + "size": 26576, + "executable": false, + "classification": "text", + "exactSha256": "5f7a6cdad62ebee25adad2a9e76d9623bee690846eaf8c7e1739b27c4a9e0efc", + "textNormalizedSha256": "5f7a6cdad62ebee25adad2a9e76d9623bee690846eaf8c7e1739b27c4a9e0efc", + "identitySha256": "5f7a6cdad62ebee25adad2a9e76d9623bee690846eaf8c7e1739b27c4a9e0efc" + } + ] + }, + { + "releaseRevision": 6, + "packageDigest": "6e77196af8e6b9123861d7f09c0727e3f982722940eaff2ca53521d76134362f", + "gitTreeSha": "35f50f11a68d2f8f532df28d677ba1b0bc71b740", + "files": [ + { + "path": "SKILL.md", + "size": 26970, + "executable": false, + "classification": "text", + "exactSha256": "640374995d830fd3607e3b162d7bf03237b8ee9afb846b8a753e45c91c8e322a", + "textNormalizedSha256": "640374995d830fd3607e3b162d7bf03237b8ee9afb846b8a753e45c91c8e322a", + "identitySha256": "640374995d830fd3607e3b162d7bf03237b8ee9afb846b8a753e45c91c8e322a" + } + ] + }, + { + "releaseRevision": 7, + "packageDigest": "d15b79aee7d15f1cc089467284f2454a8ad4f351c08e1e3758e8c58fdcf07b02", + "gitTreeSha": "d7788dc3cd1f1ed705981299c7b8ff9b26cdd5e6", + "files": [ + { + "path": "SKILL.md", + "size": 27011, + "executable": false, + "classification": "text", + "exactSha256": "28105318e676de18e27389002fdbe1b33c3c27f8c001ba82d769cef4adde1ef1", + "textNormalizedSha256": "28105318e676de18e27389002fdbe1b33c3c27f8c001ba82d769cef4adde1ef1", + "identitySha256": "28105318e676de18e27389002fdbe1b33c3c27f8c001ba82d769cef4adde1ef1" + } + ] + }, + { + "releaseRevision": 8, + "packageDigest": "6821844c4336d2673c85c968efd8cab14b4e34b8c798e4f11d6afb67b65f12b8", + "gitTreeSha": "a0f98aea5ecdafae6dff25d31d0925a4affae774", + "files": [ + { + "path": "SKILL.md", + "size": 27038, + "executable": false, + "classification": "text", + "exactSha256": "92f645b9db8bef6eb1278e87622cb18d5e146254039fc927a1f37313f32bf347", + "textNormalizedSha256": "92f645b9db8bef6eb1278e87622cb18d5e146254039fc927a1f37313f32bf347", + "identitySha256": "92f645b9db8bef6eb1278e87622cb18d5e146254039fc927a1f37313f32bf347" + } + ] + }, + { + "releaseRevision": 9, + "packageDigest": "7486314542c03f682e7640ad61825cc709004e8c076c2971f707bdf839018233", + "gitTreeSha": "c556268a21e785b01be2dfb7cd0988100c7d7cde", + "files": [ + { + "path": "SKILL.md", + "size": 27197, + "executable": false, + "classification": "text", + "exactSha256": "80f8b98301c0d4c0cce6d522d56ae248312d6a386a17b275078c618cc7ddaa5e", + "textNormalizedSha256": "80f8b98301c0d4c0cce6d522d56ae248312d6a386a17b275078c618cc7ddaa5e", + "identitySha256": "80f8b98301c0d4c0cce6d522d56ae248312d6a386a17b275078c618cc7ddaa5e" + } + ] + }, + { + "releaseRevision": 10, + "packageDigest": "6821844c4336d2673c85c968efd8cab14b4e34b8c798e4f11d6afb67b65f12b8", + "gitTreeSha": "a0f98aea5ecdafae6dff25d31d0925a4affae774", + "files": [ + { + "path": "SKILL.md", + "size": 27038, + "executable": false, + "classification": "text", + "exactSha256": "92f645b9db8bef6eb1278e87622cb18d5e146254039fc927a1f37313f32bf347", + "textNormalizedSha256": "92f645b9db8bef6eb1278e87622cb18d5e146254039fc927a1f37313f32bf347", + "identitySha256": "92f645b9db8bef6eb1278e87622cb18d5e146254039fc927a1f37313f32bf347" + } + ] + }, + { + "releaseRevision": 11, + "packageDigest": "7486314542c03f682e7640ad61825cc709004e8c076c2971f707bdf839018233", + "gitTreeSha": "c556268a21e785b01be2dfb7cd0988100c7d7cde", + "files": [ + { + "path": "SKILL.md", + "size": 27197, + "executable": false, + "classification": "text", + "exactSha256": "80f8b98301c0d4c0cce6d522d56ae248312d6a386a17b275078c618cc7ddaa5e", + "textNormalizedSha256": "80f8b98301c0d4c0cce6d522d56ae248312d6a386a17b275078c618cc7ddaa5e", + "identitySha256": "80f8b98301c0d4c0cce6d522d56ae248312d6a386a17b275078c618cc7ddaa5e" + } + ] + }, + { + "releaseRevision": 12, + "packageDigest": "962904e301eef6917af25eccbfe74ab8a119503749030e44c67fd34415d85002", + "gitTreeSha": "4fde9a02a5a77e7ff1a47c04819b764378557a28", + "files": [ + { + "path": "SKILL.md", + "size": 28908, + "executable": false, + "classification": "text", + "exactSha256": "f3e3112e666d3735acac3e41b768fcb7be3e2daf692e8c03b481a50798783137", + "textNormalizedSha256": "f3e3112e666d3735acac3e41b768fcb7be3e2daf692e8c03b481a50798783137", + "identitySha256": "f3e3112e666d3735acac3e41b768fcb7be3e2daf692e8c03b481a50798783137" + } + ] + }, + { + "releaseRevision": 13, + "packageDigest": "d73f45feb6ccf88947aba3095e9ae36c2c02e9fb4dfeaba67f3f8e3af1cb6f74", + "gitTreeSha": "b5c98b55e0b10e4525d63f5a8df3bc95b62158e6", + "files": [ + { + "path": "SKILL.md", + "size": 30358, + "executable": false, + "classification": "text", + "exactSha256": "5d5a5c630b581fe2e3ae047baddd795434dbb3ecb4ff6e1384fcd60ead665d07", + "textNormalizedSha256": "5d5a5c630b581fe2e3ae047baddd795434dbb3ecb4ff6e1384fcd60ead665d07", + "identitySha256": "5d5a5c630b581fe2e3ae047baddd795434dbb3ecb4ff6e1384fcd60ead665d07" + } + ] + }, + { + "releaseRevision": 14, + "packageDigest": "d1adae602b254aa2de6d65d09066ca1dcd9e89685bc1ba7c72e9964beb3f6539", + "gitTreeSha": "b545ca22a27b8cfff80fd2d33c77db8b03efd7ed", + "files": [ + { + "path": "SKILL.md", + "size": 30787, + "executable": false, + "classification": "text", + "exactSha256": "41451300db3ebaea51931b325aea20195923d631125588614fd1be5ebcfdcdc1", + "textNormalizedSha256": "41451300db3ebaea51931b325aea20195923d631125588614fd1be5ebcfdcdc1", + "identitySha256": "41451300db3ebaea51931b325aea20195923d631125588614fd1be5ebcfdcdc1" + } + ] + }, + { + "releaseRevision": 15, + "packageDigest": "23a6bb7a6069a8c4d6506aaa688a44c8421699e3575bb8c73185561579c1f623", + "gitTreeSha": "8b7caa9655971745a8ea782f6d05d76ab0f3812c", + "files": [ + { + "path": "SKILL.md", + "size": 31985, + "executable": false, + "classification": "text", + "exactSha256": "2ada72dac2e503723f3046433995756549fd1e44d445ec4d13c8f01764e6304d", + "textNormalizedSha256": "2ada72dac2e503723f3046433995756549fd1e44d445ec4d13c8f01764e6304d", + "identitySha256": "2ada72dac2e503723f3046433995756549fd1e44d445ec4d13c8f01764e6304d" + } + ] + }, + { + "releaseRevision": 16, + "packageDigest": "c882bcd1293ef6f601e7f48199f6e755692f668419f8c2098e00757d9349d557", + "gitTreeSha": "22de521546df142d7fd540dd3a329462c548e86b", + "files": [ + { + "path": "SKILL.md", + "size": 32276, + "executable": false, + "classification": "text", + "exactSha256": "7ef384a2687f655acd0cc40be40ea5a7df46a0e6d6bdb1ea3d43d9d7ae25778c", + "textNormalizedSha256": "7ef384a2687f655acd0cc40be40ea5a7df46a0e6d6bdb1ea3d43d9d7ae25778c", + "identitySha256": "7ef384a2687f655acd0cc40be40ea5a7df46a0e6d6bdb1ea3d43d9d7ae25778c" + } + ] + }, + { + "releaseRevision": 17, + "packageDigest": "a1bfdab91a68b3441b548de158e2c6a56452a499600625c6a9a3523d325837ec", + "gitTreeSha": "c694b183230bface19ef8ec9218726f82e391f69", + "files": [ + { + "path": "SKILL.md", + "size": 32853, + "executable": false, + "classification": "text", + "exactSha256": "ad2bbe6f75cc98ce09781ed1345f09d1fc3f2042c4f9a5c5f75032ca149a2672", + "textNormalizedSha256": "ad2bbe6f75cc98ce09781ed1345f09d1fc3f2042c4f9a5c5f75032ca149a2672", + "identitySha256": "ad2bbe6f75cc98ce09781ed1345f09d1fc3f2042c4f9a5c5f75032ca149a2672" + } + ] + }, + { + "releaseRevision": 18, + "packageDigest": "afb5026bf156e8d411898ecc84c48348010129558499a18240194b0d5533028c", + "gitTreeSha": "3b9164e0fba50923c9ce3a4a298a9c8b81677291", + "files": [ + { + "path": "SKILL.md", + "size": 33310, + "executable": false, + "classification": "text", + "exactSha256": "320539431c03434355a2186095f782444a4dbec5adbcb8560cebcea3ed49fe1f", + "textNormalizedSha256": "320539431c03434355a2186095f782444a4dbec5adbcb8560cebcea3ed49fe1f", + "identitySha256": "320539431c03434355a2186095f782444a4dbec5adbcb8560cebcea3ed49fe1f" + } + ] + }, + { + "releaseRevision": 19, + "packageDigest": "2c07a8155f0a0f3fdc6a038c25e0758486dc2ce19d8e0f5bb19d4722f6acbae7", + "gitTreeSha": "dc8ef30fef07f81167e948a9bfc90d14670ea10c", + "files": [ + { + "path": "SKILL.md", + "size": 11055, + "executable": false, + "classification": "text", + "exactSha256": "9d10496ccc998f58472e902b4131910d7bde48f305f70a400c211deecd670c0b", + "textNormalizedSha256": "9d10496ccc998f58472e902b4131910d7bde48f305f70a400c211deecd670c0b", + "identitySha256": "9d10496ccc998f58472e902b4131910d7bde48f305f70a400c211deecd670c0b" + } + ] + }, + { + "releaseRevision": 20, + "packageDigest": "6f57bb7833fd4e6acf743921ca25fd57bb51df76caa8ea2aea98c149e20f22e1", + "gitTreeSha": "535bf7b62e1e374adb380e16f837a3c575e302d4", + "files": [ + { + "path": "SKILL.md", + "size": 13423, + "executable": false, + "classification": "text", + "exactSha256": "aa9307c3db1e8d96870955fa2f6260b220e840401ce7ea9c034021658601a3ea", + "textNormalizedSha256": "aa9307c3db1e8d96870955fa2f6260b220e840401ce7ea9c034021658601a3ea", + "identitySha256": "aa9307c3db1e8d96870955fa2f6260b220e840401ce7ea9c034021658601a3ea" + } + ] + }, + { + "releaseRevision": 21, + "packageDigest": "ce5459d22254faf23a3af527a22fa8981f1c3f88afe48487a9fd79c44d0729b9", + "gitTreeSha": "de4999bb21e7009bf1e453d593e7726d917d3fc4", + "files": [ + { + "path": "SKILL.md", + "size": 13764, + "executable": false, + "classification": "text", + "exactSha256": "879f328595d4148a5b0dba9dd3cc31c4d30740cb574d754f38ee82f9d1cc782c", + "textNormalizedSha256": "879f328595d4148a5b0dba9dd3cc31c4d30740cb574d754f38ee82f9d1cc782c", + "identitySha256": "879f328595d4148a5b0dba9dd3cc31c4d30740cb574d754f38ee82f9d1cc782c" + } + ] + }, + { + "releaseRevision": 22, + "packageDigest": "6f57bb7833fd4e6acf743921ca25fd57bb51df76caa8ea2aea98c149e20f22e1", + "gitTreeSha": "535bf7b62e1e374adb380e16f837a3c575e302d4", + "files": [ + { + "path": "SKILL.md", + "size": 13423, + "executable": false, + "classification": "text", + "exactSha256": "aa9307c3db1e8d96870955fa2f6260b220e840401ce7ea9c034021658601a3ea", + "textNormalizedSha256": "aa9307c3db1e8d96870955fa2f6260b220e840401ce7ea9c034021658601a3ea", + "identitySha256": "aa9307c3db1e8d96870955fa2f6260b220e840401ce7ea9c034021658601a3ea" + } + ] + }, + { + "releaseRevision": 23, + "packageDigest": "ce5459d22254faf23a3af527a22fa8981f1c3f88afe48487a9fd79c44d0729b9", + "gitTreeSha": "de4999bb21e7009bf1e453d593e7726d917d3fc4", + "files": [ + { + "path": "SKILL.md", + "size": 13764, + "executable": false, + "classification": "text", + "exactSha256": "879f328595d4148a5b0dba9dd3cc31c4d30740cb574d754f38ee82f9d1cc782c", + "textNormalizedSha256": "879f328595d4148a5b0dba9dd3cc31c4d30740cb574d754f38ee82f9d1cc782c", + "identitySha256": "879f328595d4148a5b0dba9dd3cc31c4d30740cb574d754f38ee82f9d1cc782c" + } + ] + }, + { + "releaseRevision": 24, + "packageDigest": "30b067d09f9f9ee9c048f0b3d809c25e269fd46cc948486cdb66690205ae8c4b", + "gitTreeSha": "833e5ce46df5bf55d29fbbd3bd710aa2dfe6b5c4", + "files": [ + { + "path": "SKILL.md", + "size": 14298, + "executable": false, + "classification": "text", + "exactSha256": "a1a98ff994d134217b32ccd13130a1d0af44db9df0c90a8ea7c1e46b5092603f", + "textNormalizedSha256": "a1a98ff994d134217b32ccd13130a1d0af44db9df0c90a8ea7c1e46b5092603f", + "identitySha256": "a1a98ff994d134217b32ccd13130a1d0af44db9df0c90a8ea7c1e46b5092603f" + } + ] + }, + { + "releaseRevision": 25, + "packageDigest": "2ae59c0bea7f2a12ef0c78e3cba65895ff67b311b40b97b46223460b4d1a7acd", + "gitTreeSha": "ba4e183d53b70fd01b763a9cc19f757550a18e2e", + "files": [ + { + "path": "SKILL.md", + "size": 14320, + "executable": false, + "classification": "text", + "exactSha256": "ed3994def086f5a8aeecfee545116d1f8fa4ef46b48656dfa797a507a7a078a4", + "textNormalizedSha256": "ed3994def086f5a8aeecfee545116d1f8fa4ef46b48656dfa797a507a7a078a4", + "identitySha256": "ed3994def086f5a8aeecfee545116d1f8fa4ef46b48656dfa797a507a7a078a4" + } + ] + }, + { + "releaseRevision": 26, + "packageDigest": "5d7bdc664990cd84771eceea9393b0a566aee4ad9cdbf0883910cd8d4d46d5ac", + "gitTreeSha": "6b459e105bb77cbfa67ba0086f3afcbe32e12093", + "files": [ + { + "path": "SKILL.md", + "size": 14636, + "executable": false, + "classification": "text", + "exactSha256": "86f75826ec0ed735a3363fe6b17c7b29431f1ddcf16736398bd38ee6a7e9c7ce", + "textNormalizedSha256": "86f75826ec0ed735a3363fe6b17c7b29431f1ddcf16736398bd38ee6a7e9c7ce", + "identitySha256": "86f75826ec0ed735a3363fe6b17c7b29431f1ddcf16736398bd38ee6a7e9c7ce" + } + ] + }, + { + "releaseRevision": 27, + "packageDigest": "94f535ede189381ae1cf1a7cf6effee03335b2e98e9bdf57fed72dcba39036e3", + "gitTreeSha": "1b02d6a7c06465e3ba6661bef201c6db6552c9e6", + "files": [ + { + "path": "SKILL.md", + "size": 14829, + "executable": false, + "classification": "text", + "exactSha256": "7b1b746d4f080e0ac9a4b30f1fcb32f24433efccf70e03b66342a07cac54c835", + "textNormalizedSha256": "7b1b746d4f080e0ac9a4b30f1fcb32f24433efccf70e03b66342a07cac54c835", + "identitySha256": "7b1b746d4f080e0ac9a4b30f1fcb32f24433efccf70e03b66342a07cac54c835" + } + ] + }, + { + "releaseRevision": 28, + "packageDigest": "d70d2e70a9ac4ae8cf8ec5cb958524521842d558e40ea24dd882b90a846a2cdc", + "gitTreeSha": "3d734f7f385d66ab408c56e8814b9c115d53556c", + "files": [ + { + "path": "SKILL.md", + "size": 15055, + "executable": false, + "classification": "text", + "exactSha256": "399d3e63eb8db02632870428e9465866787540599a39e168516a30621fa84995", + "textNormalizedSha256": "399d3e63eb8db02632870428e9465866787540599a39e168516a30621fa84995", + "identitySha256": "399d3e63eb8db02632870428e9465866787540599a39e168516a30621fa84995" + } + ] + }, + { + "releaseRevision": 29, + "packageDigest": "ca9d1d5e2ad2dc6814be80af642c5fb485644713e9bd7ef19acd36d4228b94c5", + "gitTreeSha": "d1175a5a5807a6783a3d896f720645b39f736dbb", + "files": [ + { + "path": "SKILL.md", + "size": 17257, + "executable": false, + "classification": "text", + "exactSha256": "65dddf473219f4324d6b81ece2af29d512948dd9ebd67f15b424a1b205ea814e", + "textNormalizedSha256": "65dddf473219f4324d6b81ece2af29d512948dd9ebd67f15b424a1b205ea814e", + "identitySha256": "65dddf473219f4324d6b81ece2af29d512948dd9ebd67f15b424a1b205ea814e" + } + ] + }, + { + "releaseRevision": 30, + "packageDigest": "4994ac99e6563e53f85c404d52470ab05945679ecb3e0f6a1ac4dbba83d70b35", + "gitTreeSha": "91c0234c3e64dfcf45ac1515ecc77c973a7af185", + "files": [ + { + "path": "SKILL.md", + "size": 19660, + "executable": false, + "classification": "text", + "exactSha256": "236008f441d16f1d4c92681776798b787dc67357f9110dac11d8b0191b00a368", + "textNormalizedSha256": "236008f441d16f1d4c92681776798b787dc67357f9110dac11d8b0191b00a368", + "identitySha256": "236008f441d16f1d4c92681776798b787dc67357f9110dac11d8b0191b00a368" + } + ] + }, + { + "releaseRevision": 31, + "packageDigest": "c52a999a35f8e96ec72c97a537ce5906ef098879495382d85f50c70a888ba901", + "gitTreeSha": "77be03ea57ddbc4c4067bca22ab58c3af5ae3d2c", + "files": [ + { + "path": "SKILL.md", + "size": 20862, + "executable": false, + "classification": "text", + "exactSha256": "4304a86de2844899f730650c48a4ac7db812bc9f3bfb0c447d5e6415139abe14", + "textNormalizedSha256": "4304a86de2844899f730650c48a4ac7db812bc9f3bfb0c447d5e6415139abe14", + "identitySha256": "4304a86de2844899f730650c48a4ac7db812bc9f3bfb0c447d5e6415139abe14" + } + ] + }, + { + "releaseRevision": 32, + "packageDigest": "51740ff13f379ac5743d3fd28a14b17168dcef40f7048c20182dce166098c45f", + "gitTreeSha": "ded93000a5f654e2b4f324501282459bd56afe19", + "files": [ + { + "path": "SKILL.md", + "size": 21557, + "executable": false, + "classification": "text", + "exactSha256": "b4c36e19fc158fc8c286bdfdf05a537985cb2159a596c93862968c5417bf15be", + "textNormalizedSha256": "b4c36e19fc158fc8c286bdfdf05a537985cb2159a596c93862968c5417bf15be", + "identitySha256": "b4c36e19fc158fc8c286bdfdf05a537985cb2159a596c93862968c5417bf15be" + } + ] + } + ], + "orchestration": [ + { + "releaseRevision": 1, + "packageDigest": "427a9ae737ae258ccf2c1f7659549da1b6b00a65c1fad8ac9c6baf4f42af8c4e", + "gitTreeSha": "13840d1ad238a462bdc34f7fca11cb4d224b514c", + "files": [ + { + "path": "SKILL.md", + "size": 12589, + "executable": false, + "classification": "text", + "exactSha256": "e4aa2bde868fdb45f905189361ab09e613ea9d83875ba7e951b6f3addfb58c75", + "textNormalizedSha256": "e4aa2bde868fdb45f905189361ab09e613ea9d83875ba7e951b6f3addfb58c75", + "identitySha256": "e4aa2bde868fdb45f905189361ab09e613ea9d83875ba7e951b6f3addfb58c75" + } + ] + }, + { + "releaseRevision": 2, + "packageDigest": "122aa952ff591cddbda47767a7de738b05dbeb0c5d26065010bcb6b788ef329d", + "gitTreeSha": "2c0e80be2d5ff6760f461711179d9fd94a7454b2", + "files": [ + { + "path": "SKILL.md", + "size": 12972, + "executable": false, + "classification": "text", + "exactSha256": "68761e4b247b7a5bc22087eb4d801287e120b9a3746054b50c96d9f5e7e0bcca", + "textNormalizedSha256": "68761e4b247b7a5bc22087eb4d801287e120b9a3746054b50c96d9f5e7e0bcca", + "identitySha256": "68761e4b247b7a5bc22087eb4d801287e120b9a3746054b50c96d9f5e7e0bcca" + } + ] + }, + { + "releaseRevision": 3, + "packageDigest": "8e8ce7fc8dc644841bb9a62426eb4bf3e0ca15dd49c33f23d442ec0b2b72aaab", + "gitTreeSha": "2775e9a44fec9135321fdf92051c34a4bc9214ff", + "files": [ + { + "path": "SKILL.md", + "size": 13003, + "executable": false, + "classification": "text", + "exactSha256": "f3bae76495657f27458969fe2d6510098f39b429580870f5e79fac3493f891ee", + "textNormalizedSha256": "f3bae76495657f27458969fe2d6510098f39b429580870f5e79fac3493f891ee", + "identitySha256": "f3bae76495657f27458969fe2d6510098f39b429580870f5e79fac3493f891ee" + } + ] + }, + { + "releaseRevision": 4, + "packageDigest": "10d51bc7f30b924ac32f05a16352c9b2172e21082db81562316c7d7d897de049", + "gitTreeSha": "a75a8a7124054ebfa2898c13a399e8fb6578bbb0", + "files": [ + { + "path": "SKILL.md", + "size": 13167, + "executable": false, + "classification": "text", + "exactSha256": "abb639ec034ab3f50fddf3dc02c4a041876c34278f529ec2c044c0cda7b74059", + "textNormalizedSha256": "abb639ec034ab3f50fddf3dc02c4a041876c34278f529ec2c044c0cda7b74059", + "identitySha256": "abb639ec034ab3f50fddf3dc02c4a041876c34278f529ec2c044c0cda7b74059" + } + ] + }, + { + "releaseRevision": 5, + "packageDigest": "8e8ce7fc8dc644841bb9a62426eb4bf3e0ca15dd49c33f23d442ec0b2b72aaab", + "gitTreeSha": "2775e9a44fec9135321fdf92051c34a4bc9214ff", + "files": [ + { + "path": "SKILL.md", + "size": 13003, + "executable": false, + "classification": "text", + "exactSha256": "f3bae76495657f27458969fe2d6510098f39b429580870f5e79fac3493f891ee", + "textNormalizedSha256": "f3bae76495657f27458969fe2d6510098f39b429580870f5e79fac3493f891ee", + "identitySha256": "f3bae76495657f27458969fe2d6510098f39b429580870f5e79fac3493f891ee" + } + ] + }, + { + "releaseRevision": 6, + "packageDigest": "10d51bc7f30b924ac32f05a16352c9b2172e21082db81562316c7d7d897de049", + "gitTreeSha": "a75a8a7124054ebfa2898c13a399e8fb6578bbb0", + "files": [ + { + "path": "SKILL.md", + "size": 13167, + "executable": false, + "classification": "text", + "exactSha256": "abb639ec034ab3f50fddf3dc02c4a041876c34278f529ec2c044c0cda7b74059", + "textNormalizedSha256": "abb639ec034ab3f50fddf3dc02c4a041876c34278f529ec2c044c0cda7b74059", + "identitySha256": "abb639ec034ab3f50fddf3dc02c4a041876c34278f529ec2c044c0cda7b74059" + } + ] + }, + { + "releaseRevision": 7, + "packageDigest": "0c9848cc3d1e135f6d0105c49ec6f16a126e675125408744d9e6c940645fd427", + "gitTreeSha": "d12449bc4ba94699a9c9ec636a15ad3b6f095bfd", + "files": [ + { + "path": "SKILL.md", + "size": 12749, + "executable": false, + "classification": "text", + "exactSha256": "6c38221cc0c280320264dbcba35af1371b3bf525d430a9d6b7a21c33bcda2223", + "textNormalizedSha256": "6c38221cc0c280320264dbcba35af1371b3bf525d430a9d6b7a21c33bcda2223", + "identitySha256": "6c38221cc0c280320264dbcba35af1371b3bf525d430a9d6b7a21c33bcda2223" + } + ] + }, + { + "releaseRevision": 8, + "packageDigest": "fbb016bd4ec67677fea3ffa16aacc2e95d13968b33520409385bef5d1a025c16", + "gitTreeSha": "413ae24e6011f86372aa44c8611d8fe76d936f8a", + "files": [ + { + "path": "SKILL.md", + "size": 12795, + "executable": false, + "classification": "text", + "exactSha256": "9248461f4224081452ef8aed56748a456b99b79fb3f78a525857f8d12e44f7f1", + "textNormalizedSha256": "9248461f4224081452ef8aed56748a456b99b79fb3f78a525857f8d12e44f7f1", + "identitySha256": "9248461f4224081452ef8aed56748a456b99b79fb3f78a525857f8d12e44f7f1" + } + ] + }, + { + "releaseRevision": 9, + "packageDigest": "cb991258e829f2a1256bcaf4f90018046d2bc91b5fba301bd12b046e9e2e6e02", + "gitTreeSha": "820faebf9aa70e7c1792909ceb9fbf35db93795a", + "files": [ + { + "path": "SKILL.md", + "size": 15044, + "executable": false, + "classification": "text", + "exactSha256": "ee559224b6047d01db02d9aabdfbcc94475394e6f11f4076ce785c42faee63c6", + "textNormalizedSha256": "ee559224b6047d01db02d9aabdfbcc94475394e6f11f4076ce785c42faee63c6", + "identitySha256": "ee559224b6047d01db02d9aabdfbcc94475394e6f11f4076ce785c42faee63c6" + } + ] + }, + { + "releaseRevision": 10, + "packageDigest": "0d8470040c3c15570fe2745fc159b5ff57b5a229ec4e27ea600a8ced594c0455", + "gitTreeSha": "5113d720ff1a96ed158befcb8752f3a26aadd161", + "files": [ + { + "path": "SKILL.md", + "size": 10338, + "executable": false, + "classification": "text", + "exactSha256": "d37fa68601e6b383146976baf4ac28b1ecc8acef808394e9cede434a08be82ce", + "textNormalizedSha256": "d37fa68601e6b383146976baf4ac28b1ecc8acef808394e9cede434a08be82ce", + "identitySha256": "d37fa68601e6b383146976baf4ac28b1ecc8acef808394e9cede434a08be82ce" + } + ] + }, + { + "releaseRevision": 11, + "packageDigest": "9008c30bdf8a20857642968753013bba4961a6297b02d31afe65105606b0bcfd", + "gitTreeSha": "993c9e1861ad97047e34ab82e5dbd045e888a4a5", + "files": [ + { + "path": "SKILL.md", + "size": 10348, + "executable": false, + "classification": "text", + "exactSha256": "3899a8a6da1d7a962e1029d002f7187058543e47a230b6459756a41bc08af4a7", + "textNormalizedSha256": "3899a8a6da1d7a962e1029d002f7187058543e47a230b6459756a41bc08af4a7", + "identitySha256": "3899a8a6da1d7a962e1029d002f7187058543e47a230b6459756a41bc08af4a7" + } + ] + }, + { + "releaseRevision": 12, + "packageDigest": "c2a45891a381db4376a61f2e0f170cd512883e0f817a5c28266b744e04d00006", + "gitTreeSha": "2f7404841129a80c8b9add53c761f0ff11174dae", + "files": [ + { + "path": "SKILL.md", + "size": 10973, + "executable": false, + "classification": "text", + "exactSha256": "4bb49dd8fb3062af4424f912909d52914f377e6dd3fcd12ed5296e7781bdabe4", + "textNormalizedSha256": "4bb49dd8fb3062af4424f912909d52914f377e6dd3fcd12ed5296e7781bdabe4", + "identitySha256": "4bb49dd8fb3062af4424f912909d52914f377e6dd3fcd12ed5296e7781bdabe4" + } + ] + }, + { + "releaseRevision": 13, + "packageDigest": "b1a4930b9a368892f54a7364c76fab3a63cea1638ebfefd0a556d96fd0af2ef8", + "gitTreeSha": "bf470e96a3411ef20ce67ee9a900bc773010775c", + "files": [ + { + "path": "SKILL.md", + "size": 11536, + "executable": false, + "classification": "text", + "exactSha256": "1150a30d854ad1d6c4f107af4c115bac6afd00122e4b011ee5e8f9c49a9d9bab", + "textNormalizedSha256": "1150a30d854ad1d6c4f107af4c115bac6afd00122e4b011ee5e8f9c49a9d9bab", + "identitySha256": "1150a30d854ad1d6c4f107af4c115bac6afd00122e4b011ee5e8f9c49a9d9bab" + } + ] + }, + { + "releaseRevision": 14, + "packageDigest": "a8c4f3fbc51a6a4d310ab5cddb015224884ac834b40530b6dbb7fcc2f91b52d1", + "gitTreeSha": "141e1de10ebe68fe5e72da23eb900b72746d217f", + "files": [ + { + "path": "SKILL.md", + "size": 12177, + "executable": false, + "classification": "text", + "exactSha256": "d6490454f5a2814e98bb3848f05574beb81b7c5111295efd8c1d26c8b16bee4a", + "textNormalizedSha256": "d6490454f5a2814e98bb3848f05574beb81b7c5111295efd8c1d26c8b16bee4a", + "identitySha256": "d6490454f5a2814e98bb3848f05574beb81b7c5111295efd8c1d26c8b16bee4a" + } + ] + }, + { + "releaseRevision": 15, + "packageDigest": "b1a4930b9a368892f54a7364c76fab3a63cea1638ebfefd0a556d96fd0af2ef8", + "gitTreeSha": "bf470e96a3411ef20ce67ee9a900bc773010775c", + "files": [ + { + "path": "SKILL.md", + "size": 11536, + "executable": false, + "classification": "text", + "exactSha256": "1150a30d854ad1d6c4f107af4c115bac6afd00122e4b011ee5e8f9c49a9d9bab", + "textNormalizedSha256": "1150a30d854ad1d6c4f107af4c115bac6afd00122e4b011ee5e8f9c49a9d9bab", + "identitySha256": "1150a30d854ad1d6c4f107af4c115bac6afd00122e4b011ee5e8f9c49a9d9bab" + } + ] + }, + { + "releaseRevision": 16, + "packageDigest": "a8c4f3fbc51a6a4d310ab5cddb015224884ac834b40530b6dbb7fcc2f91b52d1", + "gitTreeSha": "141e1de10ebe68fe5e72da23eb900b72746d217f", + "files": [ + { + "path": "SKILL.md", + "size": 12177, + "executable": false, + "classification": "text", + "exactSha256": "d6490454f5a2814e98bb3848f05574beb81b7c5111295efd8c1d26c8b16bee4a", + "textNormalizedSha256": "d6490454f5a2814e98bb3848f05574beb81b7c5111295efd8c1d26c8b16bee4a", + "identitySha256": "d6490454f5a2814e98bb3848f05574beb81b7c5111295efd8c1d26c8b16bee4a" + } + ] + }, + { + "releaseRevision": 17, + "packageDigest": "f51066a029f17a1d87f7ac587df9850440cde08f5bdda80cb810a100ea45a169", + "gitTreeSha": "df7b970dda7e03e87bd9fd1dfe62c9aaf8769adc", + "files": [ + { + "path": "SKILL.md", + "size": 13415, + "executable": false, + "classification": "text", + "exactSha256": "277dd8372f4b3b1773f8debe6e02eae575f01d568de328ae06d78ba71fc045cb", + "textNormalizedSha256": "277dd8372f4b3b1773f8debe6e02eae575f01d568de328ae06d78ba71fc045cb", + "identitySha256": "277dd8372f4b3b1773f8debe6e02eae575f01d568de328ae06d78ba71fc045cb" + } + ] + }, + { + "releaseRevision": 18, + "packageDigest": "77663355eef8ed82ff6918d9745bfecc4a50550ea86edc0f2a151f989c205a23", + "gitTreeSha": "7ff543c1701d55ffb5dffb2cd8dc5dd3fc47c700", + "files": [ + { + "path": "SKILL.md", + "size": 17357, + "executable": false, + "classification": "text", + "exactSha256": "e72ecaf625b4a839189e8f13717b0c5b7a309fc82c9ed0dbfb8e861e31a2124a", + "textNormalizedSha256": "e72ecaf625b4a839189e8f13717b0c5b7a309fc82c9ed0dbfb8e861e31a2124a", + "identitySha256": "e72ecaf625b4a839189e8f13717b0c5b7a309fc82c9ed0dbfb8e861e31a2124a" + } + ] + }, + { + "releaseRevision": 19, + "packageDigest": "3318ddeba58457774a0436b6d2d1e722b8d81e2ab3c67cd8409bfbd8efe62a5f", + "gitTreeSha": "90a57c3d0135992369caa36493ca04278e02e55a", + "files": [ + { + "path": "SKILL.md", + "size": 17600, + "executable": false, + "classification": "text", + "exactSha256": "2c1ac3a94ef4ee085147ee48d813bdcce532f5f6c7040d7836ea7b3d2a882628", + "textNormalizedSha256": "2c1ac3a94ef4ee085147ee48d813bdcce532f5f6c7040d7836ea7b3d2a882628", + "identitySha256": "2c1ac3a94ef4ee085147ee48d813bdcce532f5f6c7040d7836ea7b3d2a882628" + } + ] + }, + { + "releaseRevision": 20, + "packageDigest": "15ec10fdb480836bedc166c54ea15c10fb1e66e7c480f19300ad1227abe2b34d", + "gitTreeSha": "189912ad39f48b0a71f1cf0ee87abfcd55f90716", + "files": [ + { + "path": "SKILL.md", + "size": 19701, + "executable": false, + "classification": "text", + "exactSha256": "e3b81f4d713f97a65a35ca5db0606939cebfdaade3daf1d709a76df1c869fd8a", + "textNormalizedSha256": "e3b81f4d713f97a65a35ca5db0606939cebfdaade3daf1d709a76df1c869fd8a", + "identitySha256": "e3b81f4d713f97a65a35ca5db0606939cebfdaade3daf1d709a76df1c869fd8a" + } + ] + }, + { + "releaseRevision": 21, + "packageDigest": "211f80c301de8afa2623dac3fde79f9ada8ec99a817f54a56dbb3cb43e4325d7", + "gitTreeSha": "a96dcd29ab8618eccc54c5c433f0b2378e50f698", + "files": [ + { + "path": "SKILL.md", + "size": 21546, + "executable": false, + "classification": "text", + "exactSha256": "9550fb923937729fbaa7d6edec8a22f5a88da556fa7834c6a78ba3c65e8cda67", + "textNormalizedSha256": "9550fb923937729fbaa7d6edec8a22f5a88da556fa7834c6a78ba3c65e8cda67", + "identitySha256": "9550fb923937729fbaa7d6edec8a22f5a88da556fa7834c6a78ba3c65e8cda67" + } + ] + }, + { + "releaseRevision": 22, + "packageDigest": "c8eddb2814dda4c4b000048e628f147ef730088f8c9628afb04529a4863a410b", + "gitTreeSha": "ad1024ebab1f464a743d1c91a7005211beed0917", + "files": [ + { + "path": "SKILL.md", + "size": 21701, + "executable": false, + "classification": "text", + "exactSha256": "6053ed840323522c3bac235007b2241df897857a6974dfd784f7e539b2b98a10", + "textNormalizedSha256": "6053ed840323522c3bac235007b2241df897857a6974dfd784f7e539b2b98a10", + "identitySha256": "6053ed840323522c3bac235007b2241df897857a6974dfd784f7e539b2b98a10" + } + ] + }, + { + "releaseRevision": 23, + "packageDigest": "dab5cc1bf87b12838a53dda90f1f4f35d1f77da66bcc63840574531eff600d2b", + "gitTreeSha": "f7a017f3e1f14dafed0c59717bec37c022075be2", + "files": [ + { + "path": "SKILL.md", + "size": 22850, + "executable": false, + "classification": "text", + "exactSha256": "b73c7c665aa233a782ba81982ebc954e220fdf1c1c6ca2b6bf38133c1cd76fc9", + "textNormalizedSha256": "b73c7c665aa233a782ba81982ebc954e220fdf1c1c6ca2b6bf38133c1cd76fc9", + "identitySha256": "b73c7c665aa233a782ba81982ebc954e220fdf1c1c6ca2b6bf38133c1cd76fc9" + } + ] + }, + { + "releaseRevision": 24, + "packageDigest": "9fbfa2ae3f3f99441563a4b8b1c6302107944480db8718ddb326a862a51f7ab9", + "gitTreeSha": "086c41e0b353b4908d2963694b4a7c791d4b3982", + "files": [ + { + "path": "SKILL.md", + "size": 22861, + "executable": false, + "classification": "text", + "exactSha256": "0cb898d37560ab7ac4cb477a2d7e8ba3e593d45affd6d7e9f99151879aaeb1a5", + "textNormalizedSha256": "0cb898d37560ab7ac4cb477a2d7e8ba3e593d45affd6d7e9f99151879aaeb1a5", + "identitySha256": "0cb898d37560ab7ac4cb477a2d7e8ba3e593d45affd6d7e9f99151879aaeb1a5" + } + ] + } + ], + "mobile-fit-debug": [ + { + "releaseRevision": 1, + "packageDigest": "97ff3f85018bdea5e4518325423b159708eabe16cde3fbf7297525fa084d6a46", + "gitTreeSha": "9ff10aaeea448fa956cd4708b7f3b8242d178c0a", + "files": [ + { + "path": "SKILL.md", + "size": 9414, + "executable": false, + "classification": "text", + "exactSha256": "84aa7cc18ed03df90f40c3c70650a0ad3df941a5e1f80870bd00cf6b9b7f7eab", + "textNormalizedSha256": "84aa7cc18ed03df90f40c3c70650a0ad3df941a5e1f80870bd00cf6b9b7f7eab", + "identitySha256": "84aa7cc18ed03df90f40c3c70650a0ad3df941a5e1f80870bd00cf6b9b7f7eab" + } + ] + } + ], + "computer-use": [ + { + "releaseRevision": 1, + "packageDigest": "7c844c42b8e8c1a413cb8755b11a3b89fb3dc6c5a349d7aa63f269df975c1ccb", + "gitTreeSha": "ac1f6e3de0cbd9b488c5cfb1d4c5c076e17afea0", + "files": [ + { + "path": "SKILL.md", + "size": 8514, + "executable": false, + "classification": "text", + "exactSha256": "bdd257ccd34bf4db179a549d2f133ac5e5fa9e4ea5c7e811d38a98e8a939b3cc", + "textNormalizedSha256": "bdd257ccd34bf4db179a549d2f133ac5e5fa9e4ea5c7e811d38a98e8a939b3cc", + "identitySha256": "bdd257ccd34bf4db179a549d2f133ac5e5fa9e4ea5c7e811d38a98e8a939b3cc" + } + ] + }, + { + "releaseRevision": 2, + "packageDigest": "6c882a35e50a00c49d2f87647c54d401fe2af60a95dc3d17a14bf3cf879e5547", + "gitTreeSha": "d83929742fd30aa3d4ab82ed1be4000acbb9cce0", + "files": [ + { + "path": "SKILL.md", + "size": 6368, + "executable": false, + "classification": "text", + "exactSha256": "1b6e39080305dea665ce2c05c4c53ad5b49697db874920dab758d7a5cdc8292c", + "textNormalizedSha256": "1b6e39080305dea665ce2c05c4c53ad5b49697db874920dab758d7a5cdc8292c", + "identitySha256": "1b6e39080305dea665ce2c05c4c53ad5b49697db874920dab758d7a5cdc8292c" + } + ] + }, + { + "releaseRevision": 3, + "packageDigest": "747b9bbe7d08d0c6fc69a47c02064de73fa09ab755244b40a6253aa810d015f7", + "gitTreeSha": "b3fb19187d9775774df2a93d59e9e0003093e4a2", + "files": [ + { + "path": "SKILL.md", + "size": 10454, + "executable": false, + "classification": "text", + "exactSha256": "7f5a7bbe8b2fb51a9637e8df71cd1dca1a84b213ffe5bf56dcdd3417d7fb1601", + "textNormalizedSha256": "7f5a7bbe8b2fb51a9637e8df71cd1dca1a84b213ffe5bf56dcdd3417d7fb1601", + "identitySha256": "7f5a7bbe8b2fb51a9637e8df71cd1dca1a84b213ffe5bf56dcdd3417d7fb1601" + } + ] + }, + { + "releaseRevision": 4, + "packageDigest": "35b6d91d1d3d38f3828c60e5b856a91324bdd09273db33664f508392c5c48dfc", + "gitTreeSha": "b4e2d7e7af8f8f29c6d0df6e92f23c1b2c439e3b", + "files": [ + { + "path": "SKILL.md", + "size": 10625, + "executable": false, + "classification": "text", + "exactSha256": "4ee2af92cf1bcb1a31b0ea87e61c5f99dbce325a6f211e320c05fba3b599a07b", + "textNormalizedSha256": "4ee2af92cf1bcb1a31b0ea87e61c5f99dbce325a6f211e320c05fba3b599a07b", + "identitySha256": "4ee2af92cf1bcb1a31b0ea87e61c5f99dbce325a6f211e320c05fba3b599a07b" + } + ] + }, + { + "releaseRevision": 5, + "packageDigest": "cd2809474d57fd7277adb277448e6fa446810d3cbad71ac0b473b9e8ff1bad68", + "gitTreeSha": "306c0f8cb63bcac265a5b7975dc2f855be4f1344", + "files": [ + { + "path": "SKILL.md", + "size": 11241, + "executable": false, + "classification": "text", + "exactSha256": "f49b29fb6b209956907688692387adcdc509fad344555f09badaf383106f5f39", + "textNormalizedSha256": "f49b29fb6b209956907688692387adcdc509fad344555f09badaf383106f5f39", + "identitySha256": "f49b29fb6b209956907688692387adcdc509fad344555f09badaf383106f5f39" + } + ] + } + ], + "orca-emulator": [ + { + "releaseRevision": 1, + "packageDigest": "fdecfa3957c5502ec5f4933504f7112ab98c2c3b7b8902aad34db6cb1b7dc28a", + "gitTreeSha": "6b6b92ea67eec1661e45a8b37bf8014542f05417", + "files": [ + { + "path": "SKILL.md", + "size": 10715, + "executable": false, + "classification": "text", + "exactSha256": "273123d797b6b6072a13a4e2732feca2b6d391154987836b31d5b105a7096045", + "textNormalizedSha256": "273123d797b6b6072a13a4e2732feca2b6d391154987836b31d5b105a7096045", + "identitySha256": "273123d797b6b6072a13a4e2732feca2b6d391154987836b31d5b105a7096045" + } + ] + }, + { + "releaseRevision": 2, + "packageDigest": "5a25152a06a88be0361f47cc37325e06ab840e81df0092cfea82f5e20e50978b", + "gitTreeSha": "5575f765c3dc31f8bc57032ca3716df38826eabd", + "files": [ + { + "path": "SKILL.md", + "size": 10717, + "executable": false, + "classification": "text", + "exactSha256": "56b880444b2fe1f33061017ae41eb0eac1b11155ac74087f6e511a879891a712", + "textNormalizedSha256": "56b880444b2fe1f33061017ae41eb0eac1b11155ac74087f6e511a879891a712", + "identitySha256": "56b880444b2fe1f33061017ae41eb0eac1b11155ac74087f6e511a879891a712" + } + ] + }, + { + "releaseRevision": 3, + "packageDigest": "1807d287d2c08b1c535ba4e14ca2f29afe39274c4bde5737995a1792e44d5412", + "gitTreeSha": "dbb8e0daba768527885715ee5efebeaa1ee38f0f", + "files": [ + { + "path": "SKILL.md", + "size": 10853, + "executable": false, + "classification": "text", + "exactSha256": "837909c40e2472c24bcfd88684c00b0c19208e35e4446f81772ee6d7854fd834", + "textNormalizedSha256": "837909c40e2472c24bcfd88684c00b0c19208e35e4446f81772ee6d7854fd834", + "identitySha256": "837909c40e2472c24bcfd88684c00b0c19208e35e4446f81772ee6d7854fd834" + } + ] + }, + { + "releaseRevision": 4, + "packageDigest": "453b1d9aa20b51b8a4d32c7b6def6a93f7ef9c730de32abbcbc1788ad1b1820b", + "gitTreeSha": "66be6abe99f1807da85934aee0e22daefc8f7656", + "files": [ + { + "path": "SKILL.md", + "size": 11527, + "executable": false, + "classification": "text", + "exactSha256": "84dbfacf6854874e369840c011e78603e533273fb848d21dac3cfb08e0346429", + "textNormalizedSha256": "84dbfacf6854874e369840c011e78603e533273fb848d21dac3cfb08e0346429", + "identitySha256": "84dbfacf6854874e369840c011e78603e533273fb848d21dac3cfb08e0346429" + } + ] + } + ], + "linear-tickets": [ + { + "releaseRevision": 1, + "packageDigest": "d70fb0e7640182b1b193969c96d2ff0d70821f59f71945915709ae95ddc4e350", + "gitTreeSha": "bb8c28593807f05b5007f89ccc86f9c054728882", + "files": [ + { + "path": "SKILL.md", + "size": 6271, + "executable": false, + "classification": "text", + "exactSha256": "050220f735ce9b0f65520d3ed7fba6f5c1f856a6e8e84093ef564901cfc97ee1", + "textNormalizedSha256": "050220f735ce9b0f65520d3ed7fba6f5c1f856a6e8e84093ef564901cfc97ee1", + "identitySha256": "050220f735ce9b0f65520d3ed7fba6f5c1f856a6e8e84093ef564901cfc97ee1" + } + ] + }, + { + "releaseRevision": 2, + "packageDigest": "cc5901e9ce67829d909ab97910eb22825ca58547d215c42b665b8ff7401d5a05", + "gitTreeSha": "954d226df97ba0034002887bc9d23221667d45f0", + "files": [ + { + "path": "SKILL.md", + "size": 8985, + "executable": false, + "classification": "text", + "exactSha256": "f57dfda0dddd20992b83d8b7a3e85f9651244ae4cb1b80bed80a87bb2c9b8bf2", + "textNormalizedSha256": "f57dfda0dddd20992b83d8b7a3e85f9651244ae4cb1b80bed80a87bb2c9b8bf2", + "identitySha256": "f57dfda0dddd20992b83d8b7a3e85f9651244ae4cb1b80bed80a87bb2c9b8bf2" + } + ] + }, + { + "releaseRevision": 3, + "packageDigest": "948919e8f2b37157c40009f865fa43fc925e56d0c30b544d0508cec823c26b15", + "gitTreeSha": "e00b39a2c9b37916e40b539e08341274bd8db2f7", + "files": [ + { + "path": "SKILL.md", + "size": 9670, + "executable": false, + "classification": "text", + "exactSha256": "0d4fa74e40af8b530ff7071eb6f96b136e1c4cb0c02239305bb6d83b67a8214b", + "textNormalizedSha256": "0d4fa74e40af8b530ff7071eb6f96b136e1c4cb0c02239305bb6d83b67a8214b", + "identitySha256": "0d4fa74e40af8b530ff7071eb6f96b136e1c4cb0c02239305bb6d83b67a8214b" + } + ] + }, + { + "releaseRevision": 4, + "packageDigest": "f198d7b22e5ee1673dac403f9cca0553b124e0a90e4fdd05d2c23b7344e32d2b", + "gitTreeSha": "de9fc106bbb4e313a90ff9a9513a720909bbd176", + "files": [ + { + "path": "SKILL.md", + "size": 10596, + "executable": false, + "classification": "text", + "exactSha256": "0c3077c93328b9965430cd6951f1a35889b8c0775037870ea3a8bcf303d9d2c5", + "textNormalizedSha256": "0c3077c93328b9965430cd6951f1a35889b8c0775037870ea3a8bcf303d9d2c5", + "identitySha256": "0c3077c93328b9965430cd6951f1a35889b8c0775037870ea3a8bcf303d9d2c5" + } + ] + } + ], + "orca-linear": [ + { + "releaseRevision": 1, + "packageDigest": "9015e708218fe86620eee39041d3e4f9ddd4b67ff2c6d049beca9ffe3783e03d", + "gitTreeSha": "bc547faebffa3f5645b7d2aad32560d25d836b7f", + "files": [ + { + "path": "SKILL.md", + "size": 9394, + "executable": false, + "classification": "text", + "exactSha256": "bd158d47ba6f7090d73e8d86b9c4cd02610c14a96bf6a03402fe1f725ca84c3f", + "textNormalizedSha256": "bd158d47ba6f7090d73e8d86b9c4cd02610c14a96bf6a03402fe1f725ca84c3f", + "identitySha256": "bd158d47ba6f7090d73e8d86b9c4cd02610c14a96bf6a03402fe1f725ca84c3f" + } + ] + }, + { + "releaseRevision": 2, + "packageDigest": "d44d09e6ecb6a64da177083aad26a95f031cd1cf26ba059fdc888c2628aef64f", + "gitTreeSha": "c34f42030f43e5a85737996fa375bbd79cb5bea8", + "files": [ + { + "path": "SKILL.md", + "size": 10320, + "executable": false, + "classification": "text", + "exactSha256": "48ded55ec3842ce65105e6db7adf9bc9ed263ece08555cec056e68e90321c3d5", + "textNormalizedSha256": "48ded55ec3842ce65105e6db7adf9bc9ed263ece08555cec056e68e90321c3d5", + "identitySha256": "48ded55ec3842ce65105e6db7adf9bc9ed263ece08555cec056e68e90321c3d5" + } + ] + } + ], + "orca-emulator-android": [ + { + "releaseRevision": 1, + "packageDigest": "cf86dd085b981febd8e4e11b7796c90d69a60bd9c1dfaab5a16b99f77841aaa4", + "gitTreeSha": "6299122f69f0218ec01214e2a14c479b1811cdf6", + "files": [ + { + "path": "SKILL.md", + "size": 8189, + "executable": false, + "classification": "text", + "exactSha256": "ebc1ce862f3e5489e9bd94388386a28116241748f5957116509de7f759250844", + "textNormalizedSha256": "ebc1ce862f3e5489e9bd94388386a28116241748f5957116509de7f759250844", + "identitySha256": "ebc1ce862f3e5489e9bd94388386a28116241748f5957116509de7f759250844" + } + ] + }, + { + "releaseRevision": 2, + "packageDigest": "12272cf82e0731f11e424822b961882457034e730358cc65ea28e4eb9c8ff7f5", + "gitTreeSha": "f7b0fc8cbf5cd78ca5156f6bbe3a20f1462d8f83", + "files": [ + { + "path": "SKILL.md", + "size": 8886, + "executable": false, + "classification": "text", + "exactSha256": "1035d4db357923e98d5075c0c21bc9995b00a36a3739543516fe45ae5ded0332", + "textNormalizedSha256": "1035d4db357923e98d5075c0c21bc9995b00a36a3739543516fe45ae5ded0332", + "identitySha256": "1035d4db357923e98d5075c0c21bc9995b00a36a3739543516fe45ae5ded0332" + } + ] + } + ], + "orca-per-workspace-env": [ + { + "releaseRevision": 1, + "packageDigest": "dc7f471327f55b27a40cd9575ca476fea7a79c2aba74881af00ce4e468cbf714", + "gitTreeSha": "8efb9872787bf874c558393b9a651df91604650c", + "files": [ + { + "path": "SKILL.md", + "size": 37692, + "executable": false, + "classification": "text", + "exactSha256": "a18c3e67b3558d95c07ac80ab7e94c9ddfa0c61946c6b977923ffd65d3df63cc", + "textNormalizedSha256": "a18c3e67b3558d95c07ac80ab7e94c9ddfa0c61946c6b977923ffd65d3df63cc", + "identitySha256": "a18c3e67b3558d95c07ac80ab7e94c9ddfa0c61946c6b977923ffd65d3df63cc" + } + ] + }, + { + "releaseRevision": 2, + "packageDigest": "fa3b65a1a107fca3f0375c696852477b62f58c154b9eb5c0663c41edc4bcd30d", + "gitTreeSha": "354e775b79ea6952ec63acac4d3ee8a9ae07a650", + "files": [ + { + "path": "SKILL.md", + "size": 43769, + "executable": false, + "classification": "text", + "exactSha256": "58e479bd18c4c553df0dfcb408eece2fbe550a0f9688bc289414420f72ed7ea7", + "textNormalizedSha256": "58e479bd18c4c553df0dfcb408eece2fbe550a0f9688bc289414420f72ed7ea7", + "identitySha256": "58e479bd18c4c553df0dfcb408eece2fbe550a0f9688bc289414420f72ed7ea7" + } + ] + } + ] + } +} diff --git a/src/main/ipc/skills.test.ts b/src/main/ipc/skills.test.ts index 680163f8b5d0..bc390b2beb77 100644 --- a/src/main/ipc/skills.test.ts +++ b/src/main/ipc/skills.test.ts @@ -1,13 +1,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { handleMock, discoverSkillsMock, getDefaultWslDistroMock, getWslHomeMock } = vi.hoisted( - () => ({ - handleMock: vi.fn(), - discoverSkillsMock: vi.fn(), - getDefaultWslDistroMock: vi.fn(), - getWslHomeMock: vi.fn() - }) -) +const { + handleMock, + discoverSkillsMock, + inventorySkillFreshnessMock, + getDefaultWslDistroMock, + getWslHomeMock +} = vi.hoisted(() => ({ + handleMock: vi.fn(), + discoverSkillsMock: vi.fn(), + inventorySkillFreshnessMock: vi.fn(), + getDefaultWslDistroMock: vi.fn(), + getWslHomeMock: vi.fn() +})) vi.mock('electron', () => ({ ipcMain: { @@ -19,6 +24,10 @@ vi.mock('../skills/discovery', () => ({ discoverSkills: discoverSkillsMock })) +vi.mock('../skills/skill-freshness-inventory', () => ({ + inventorySkillFreshness: inventorySkillFreshnessMock +})) + vi.mock('../wsl', () => ({ getDefaultWslDistro: getDefaultWslDistroMock, getWslHome: getWslHomeMock @@ -39,6 +48,12 @@ describe('registerSkillsHandlers', () => { getDefaultWslDistroMock.mockReset() getWslHomeMock.mockReset() discoverSkillsMock.mockResolvedValue({ skills: [], sources: [], scannedAt: 1 }) + inventorySkillFreshnessMock.mockResolvedValue({ + schemaVersion: 1, + installations: [], + eligibleUpdateNames: [], + scannedAt: 1 + }) getWslHomeMock.mockReturnValue('\\\\wsl.localhost\\Ubuntu\\home\\alice') Object.defineProperty(process, 'platform', { configurable: true, @@ -61,6 +76,17 @@ describe('registerSkillsHandlers', () => { return call[1] as (_event: unknown, target?: unknown) => Promise } + function getFreshnessHandler() { + registerSkillsHandlers(store as never) + const call = handleMock.mock.calls.find( + (entry: unknown[]) => entry[0] === 'skills:freshnessInventory' + ) + if (!call) { + throw new Error('skills:freshnessInventory handler was not registered') + } + return call[1] as (_event: unknown) => Promise + } + it('uses host skill discovery when resolved project runtime overrides stale WSL target state', async () => { const handler = getDiscoverHandler() @@ -136,4 +162,13 @@ describe('registerSkillsHandlers', () => { ).rejects.toThrow('Project runtime requires repair before skill discovery') expect(discoverSkillsMock).not.toHaveBeenCalled() }) + + it('keeps freshness inventory local and read-only over known repositories', async () => { + const handler = getFreshnessHandler() + + await handler(null) + + expect(inventorySkillFreshnessMock).toHaveBeenCalledWith({ repos }) + expect(getWslHomeMock).not.toHaveBeenCalled() + }) }) diff --git a/src/main/ipc/skills.ts b/src/main/ipc/skills.ts index b81fd6957906..d210123a744d 100644 --- a/src/main/ipc/skills.ts +++ b/src/main/ipc/skills.ts @@ -2,7 +2,9 @@ import { ipcMain } from 'electron' import type { Store } from '../persistence' import { discoverSkills } from '../skills/discovery' import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../../shared/skills' +import type { SkillFreshnessInventory } from '../../shared/skill-freshness' import { getDefaultWslDistro, getWslHome } from '../wsl' +import { inventorySkillFreshness } from '../skills/skill-freshness-inventory' type SkillDiscoveryRuntimeTarget = | { runtime: 'host' } @@ -55,4 +57,10 @@ export function registerSkillsHandlers(store: Store): void { return cwd ? discoverSkills({ repos: [], cwd }) : discoverSkills({ repos: store.getRepos() }) } ) + + ipcMain.handle('skills:freshnessInventory', async (): Promise => { + // Why: the update command targets this machine's global homes. WSL and SSH + // inventories stay out until their installer rail has an equivalent proof. + return inventorySkillFreshness({ repos: store.getRepos() }) + }) } diff --git a/src/main/skills/discovery.test.ts b/src/main/skills/discovery.test.ts index 68ff5e9d017d..d65f30dc391c 100644 --- a/src/main/skills/discovery.test.ts +++ b/src/main/skills/discovery.test.ts @@ -83,6 +83,41 @@ describe('skill discovery', () => { } }) + it('does not add runtime-owned repository paths to local scan roots', () => { + const runtimeRepo = makeRepo('/runtime/repo') + runtimeRepo.executionHostId = 'runtime:environment-1' + + const roots = buildSkillDiscoverySources({ + homeDir: '/home/test', + cwd: '/workspace/current', + repos: [runtimeRepo] + }) + + expect(roots.map((root) => root.path.replace(/\\/g, '/'))).not.toContain( + '/runtime/repo/.agents/skills' + ) + }) + + it('can exclude the implicit cwd without excluding explicit local repositories', () => { + const defaultRoots = buildSkillDiscoverySources({ + homeDir: '/home/test', + cwd: '/workspace/current', + repos: [makeRepo('/workspace/known')] + }) + const explicitRoots = buildSkillDiscoverySources({ + homeDir: '/home/test', + cwd: '/workspace/current', + repos: [makeRepo('/workspace/known')], + includeCwd: false + }) + + const normalizedDefaultPaths = defaultRoots.map((root) => root.path.replace(/\\/g, '/')) + const normalizedExplicitPaths = explicitRoots.map((root) => root.path.replace(/\\/g, '/')) + expect(normalizedDefaultPaths).toContain('/workspace/current/.agents/skills') + expect(normalizedExplicitPaths).not.toContain('/workspace/current/.agents/skills') + expect(normalizedExplicitPaths).toContain('/workspace/known/.agents/skills') + }) + it('discovers skill packages through symlinked skill directories', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-skills-')) const home = join(root, 'home') diff --git a/src/main/skills/discovery.ts b/src/main/skills/discovery.ts index d84f82af4269..ed74ef3ffa6e 100644 --- a/src/main/skills/discovery.ts +++ b/src/main/skills/discovery.ts @@ -234,6 +234,7 @@ export async function discoverSkills(args: { repos?: Repo[] homeDir?: string cwd?: string + includeCwd?: boolean }): Promise { const roots = buildSkillDiscoverySources(args) const sources: SkillDiscoverySource[] = [] diff --git a/src/main/skills/skill-bundle-artifacts.test.ts b/src/main/skills/skill-bundle-artifacts.test.ts new file mode 100644 index 000000000000..f86d5c382d7e --- /dev/null +++ b/src/main/skills/skill-bundle-artifacts.test.ts @@ -0,0 +1,37 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { loadSkillBundleArtifacts } from './skill-bundle-artifacts' + +const temporaryDirectories: string[] = [] + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((root) => rm(root, { recursive: true }))) +}) + +describe('skill bundle artifacts', () => { + it('rejects malformed nested release entries before building provenance', async () => { + const resourceRoot = await mkdtemp(join(tmpdir(), 'orca-skill-artifacts-')) + temporaryDirectories.push(resourceRoot) + const target = join(resourceRoot, 'skills') + const source = resolve('resources', 'skills') + await mkdir(target, { recursive: true }) + const [manifest, registry, releaseMapping] = await Promise.all( + ['current-manifest.json', 'snapshot-registry.json', 'release-mapping.json'].map((name) => + readFile(join(source, name), 'utf8') + ) + ) + const malformedMapping = JSON.parse(releaseMapping) + malformedMapping.releases[0] = { appVersion: 'invalid' } + await Promise.all([ + writeFile(join(target, 'current-manifest.json'), manifest), + writeFile(join(target, 'snapshot-registry.json'), registry), + writeFile(join(target, 'release-mapping.json'), JSON.stringify(malformedMapping)) + ]) + + await expect(loadSkillBundleArtifacts(resourceRoot)).rejects.toThrow( + 'Invalid skill release mapping' + ) + }) +}) diff --git a/src/main/skills/skill-bundle-artifacts.ts b/src/main/skills/skill-bundle-artifacts.ts new file mode 100644 index 000000000000..e7d403442b5f --- /dev/null +++ b/src/main/skills/skill-bundle-artifacts.ts @@ -0,0 +1,164 @@ +import { app } from 'electron' +import { readFile } from 'node:fs/promises' +import { join, resolve } from 'node:path' +import { z, type ZodType } from 'zod' +import type { + SkillBundleManifest, + SkillKnownSnapshot, + SkillReleaseMapping, + SkillSnapshotRegistry +} from '../../shared/skill-freshness' + +export type SkillBundleArtifacts = { + manifest: SkillBundleManifest + registry: SkillSnapshotRegistry + releaseMapping: SkillReleaseMapping + knownSnapshots: Record + releasedAppVersions: Record> +} + +const sha256Schema = z.string().regex(/^[a-f0-9]{64}$/) +const snapshotShape = { + releaseRevision: z.number().int().positive(), + packageDigest: sha256Schema, + gitTreeSha: z.string().regex(/^[a-f0-9]{40}$/), + files: z + .array( + z + .object({ + path: z.string().min(1), + size: z.number().int().nonnegative(), + executable: z.boolean(), + classification: z.enum(['text', 'binary']), + exactSha256: sha256Schema, + textNormalizedSha256: sha256Schema.nullable(), + identitySha256: sha256Schema + }) + .strict() + ) + .min(1) +} +const knownSnapshotSchema = z.object(snapshotShape).strict() +const manifestSchema = z + .object({ + schemaVersion: z.literal(1), + appVersion: z.string().min(1), + skills: z.array( + z + .object({ + name: z.string().regex(/^[a-z0-9][a-z0-9._-]*$/), + sourcePath: z.string().min(1), + appVersion: z.string().min(1), + ...snapshotShape + }) + .strict() + ) + }) + .strict() +const registrySchema = z + .object({ + schemaVersion: z.literal(1), + skills: z.record(z.string().min(1), z.array(knownSnapshotSchema).min(1)) + }) + .strict() +const releaseMappingSchema = z + .object({ + schemaVersion: z.literal(1), + releases: z.array( + z + .object({ + appVersion: z.string().min(1), + skills: z.record(z.string().min(1), z.number().int().positive()) + }) + .strict() + ) + }) + .strict() + +function parseArtifact(schema: ZodType, value: unknown, label: string): T { + const result = schema.safeParse(value) + if (!result.success) { + throw new Error(`Invalid ${label}: ${result.error.issues[0]?.message ?? 'schema mismatch'}`) + } + return result.data +} + +const artifactsByResourceRoot = new Map>() + +// Why: the artifacts ship with the binary and never change within a run, while +// focus-triggered rescans would otherwise re-read and re-parse them every time. +export function loadSkillBundleArtifacts( + resourceRoot = app.isPackaged ? process.resourcesPath : resolve(process.cwd(), 'resources') +): Promise { + const cached = artifactsByResourceRoot.get(resourceRoot) + if (cached) { + return cached + } + const loading = readSkillBundleArtifacts(resourceRoot) + artifactsByResourceRoot.set(resourceRoot, loading) + loading.catch(() => { + artifactsByResourceRoot.delete(resourceRoot) + }) + return loading +} + +async function readSkillBundleArtifacts(resourceRoot: string): Promise { + const bundleRoot = join(resourceRoot, 'skills') + const [manifestValue, registryValue, releaseMappingValue] = await Promise.all([ + readFile(join(bundleRoot, 'current-manifest.json'), 'utf8').then(JSON.parse), + readFile(join(bundleRoot, 'snapshot-registry.json'), 'utf8').then(JSON.parse), + readFile(join(bundleRoot, 'release-mapping.json'), 'utf8').then(JSON.parse) + ]) + const manifest: SkillBundleManifest = parseArtifact( + manifestSchema, + manifestValue, + 'skill bundle manifest' + ) + const registry: SkillSnapshotRegistry = parseArtifact( + registrySchema, + registryValue, + 'skill snapshot registry' + ) + const releaseMapping: SkillReleaseMapping = parseArtifact( + releaseMappingSchema, + releaseMappingValue, + 'skill release mapping' + ) + for (const current of manifest.skills) { + if ( + current.appVersion !== manifest.appVersion || + !registry.skills[current.name]?.some( + (snapshot) => + snapshot.releaseRevision === current.releaseRevision && + snapshot.packageDigest === current.packageDigest + ) + ) { + throw new Error(`Inconsistent current skill snapshot: ${current.name}`) + } + } + + const releasedAppVersions: Record> = {} + for (const release of releaseMapping.releases) { + for (const [name, revision] of Object.entries(release.skills)) { + if (!registry.skills[name]?.some((snapshot) => snapshot.releaseRevision === revision)) { + throw new Error(`Unknown released skill revision: ${name}@${revision}`) + } + releasedAppVersions[name] ??= {} + releasedAppVersions[name][revision] ??= release.appVersion + } + } + for (const current of manifest.skills) { + releasedAppVersions[current.name] ??= {} + releasedAppVersions[current.name][current.releaseRevision] = current.appVersion + } + + return { + manifest, + registry, + releaseMapping, + // Why: newer-known classification needs every identity packaged with this + // build, while release mapping remains the provenance record for shipped revisions. + knownSnapshots: registry.skills, + releasedAppVersions + } +} diff --git a/src/main/skills/skill-candidate-concurrency.ts b/src/main/skills/skill-candidate-concurrency.ts new file mode 100644 index 000000000000..c81f1942ce9d --- /dev/null +++ b/src/main/skills/skill-candidate-concurrency.ts @@ -0,0 +1,24 @@ +const MAX_CONCURRENT_SKILL_CANDIDATES = 4 + +export async function runSkillCandidateTasks( + tasks: readonly (() => Promise)[] +): Promise { + const results = Array.from({ length: tasks.length }) + let nextIndex = 0 + + async function worker(): Promise { + for (;;) { + const index = nextIndex + nextIndex += 1 + if (index >= tasks.length) { + return + } + results[index] = await tasks[index]() + } + } + + await Promise.all( + Array.from({ length: Math.min(MAX_CONCURRENT_SKILL_CANDIDATES, tasks.length) }, () => worker()) + ) + return results +} diff --git a/src/main/skills/skill-discovery-sources.ts b/src/main/skills/skill-discovery-sources.ts index bec34117c4cd..ba3726f43e0a 100644 --- a/src/main/skills/skill-discovery-sources.ts +++ b/src/main/skills/skill-discovery-sources.ts @@ -3,6 +3,7 @@ import { homedir } from 'node:os' import { basename, join } from 'node:path' import type { SkillDiscoverySource, SkillProvider, SkillSourceKind } from '../../shared/skills' import type { Repo } from '../../shared/types' +import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' export type SkillScanRoot = Omit @@ -25,6 +26,7 @@ export function buildSkillDiscoverySources( homeDir?: string cwd?: string repos?: Repo[] + includeCwd?: boolean } = {} ): SkillScanRoot[] { const home = args.homeDir ?? homedir() @@ -62,12 +64,16 @@ export function buildSkillDiscoverySources( const projectPaths = new Set() for (const repo of args.repos ?? []) { - if (repo.connectionId) { + // Why: runtime-owned repos can have no legacy connectionId while their + // paths are meaningful only on a remote host. + if (getRepoExecutionHostId(repo) !== LOCAL_EXECUTION_HOST_ID) { continue } projectPaths.add(repo.path) } - projectPaths.add(cwd) + if (args.includeCwd !== false) { + projectPaths.add(cwd) + } for (const repoPath of projectPaths) { const label = `Repo ${basename(repoPath)}` diff --git a/src/main/skills/skill-freshness-eligibility.test.ts b/src/main/skills/skill-freshness-eligibility.test.ts new file mode 100644 index 000000000000..e3754197559e --- /dev/null +++ b/src/main/skills/skill-freshness-eligibility.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest' +import { + buildTargetedSkillUpdateCommand, + type SkillFreshnessInstallation +} from '../../shared/skill-freshness' +import { eligibleSkillUpdateNames } from './skill-freshness-eligibility' + +function placement( + name: string, + overrides: Partial = {} +): SkillFreshnessInstallation { + return { + id: `${name}-${overrides.rootId ?? 'home-agents'}`, + name, + rootId: 'home-agents', + providers: ['agent-skills'], + sourceKind: 'home', + sourceLabel: 'Agent skills home', + unresolvedPath: `/home/.agents/skills/${name}`, + resolvedPath: `/home/.agents/skills/${name}`, + physicalIdentity: `physical-${name}`, + topology: 'canonical-copy', + status: 'outdated', + installedReleaseRevision: 1, + installedAppVersion: '1.0.0', + currentReleaseRevision: 2, + currentPackageDigest: 'current', + currentAppVersion: '2.0.0', + observedPackageDigest: 'old', + errorCategory: null, + ...overrides + } +} + +describe('skill freshness name-scoped update eligibility', () => { + it('offers a name when at least one supported placement is outdated and all are official', () => { + expect( + eligibleSkillUpdateNames([ + placement('orca-cli'), + placement('orca-cli', { + id: 'orca-cli-claude', + rootId: 'home-claude', + topology: 'provider-alias', + status: 'current' + }) + ]) + ).toEqual(['orca-cli']) + }) + + it.each([ + ['newer-known', 'independent-copy'], + ['unrecognized', 'independent-copy'], + ['inaccessible', 'broken-link'], + ['current', 'external-link'], + ['current', 'read-only'], + ['current', 'repo-scope'], + ['current', 'plugin-cache'] + ] as const)('poisons a name for a %s placement in %s topology', (status, topology) => { + expect( + eligibleSkillUpdateNames([ + placement('orca-cli'), + placement('orca-cli', { id: `poison-${status}-${topology}`, status, topology }) + ]) + ).toEqual([]) + }) + + it('still updates the canonical copy when a clean standalone duplicate exists', () => { + // Why: a duplicate no longer omits the whole name — the canonical copy converges + // and the duplicate row is flagged as maybe-not-reached rather than blocking. + expect( + eligibleSkillUpdateNames([ + placement('orca-cli'), + placement('orca-cli', { + id: 'orca-cli-gemini', + rootId: 'home-gemini', + unresolvedPath: '/home/.gemini/skills/orca-cli', + resolvedPath: '/home/.gemini/skills/orca-cli', + topology: 'independent-copy', + status: 'current' + }) + ]) + ).toEqual(['orca-cli']) + }) + + it('does not offer a skill that exists only as a standalone copy', () => { + // Why: with no canonical or alias to anchor `--global`, the command has no + // reliable target, so a duplicate-only skill stays unoffered. + expect( + eligibleSkillUpdateNames([ + placement('orca-cli', { + rootId: 'home-gemini', + unresolvedPath: '/home/.gemini/skills/orca-cli', + resolvedPath: '/home/.gemini/skills/orca-cli', + topology: 'independent-copy', + status: 'outdated' + }) + ]) + ).toEqual([]) + }) + + it('does not offer an all-current name or let another safe name hide a poisoned one', () => { + expect( + eligibleSkillUpdateNames([ + placement('computer-use', { status: 'current' }), + placement('orchestration'), + placement('orchestration', { + id: 'orchestration-project', + status: 'unrecognized', + topology: 'repo-scope' + }) + ]) + ).toEqual([]) + }) + + it('builds only an explicit, deterministic global command', () => { + expect(buildTargetedSkillUpdateCommand(['orchestration', 'orca-cli', 'orca-cli'])).toBe( + 'npx skills update orca-cli orchestration --global' + ) + expect(buildTargetedSkillUpdateCommand([])).toBeNull() + expect(buildTargetedSkillUpdateCommand(['orca-cli;echo unsafe'])).toBeNull() + }) +}) diff --git a/src/main/skills/skill-freshness-eligibility.ts b/src/main/skills/skill-freshness-eligibility.ts new file mode 100644 index 000000000000..f4eb27a9f5ac --- /dev/null +++ b/src/main/skills/skill-freshness-eligibility.ts @@ -0,0 +1,41 @@ +import { + SUPPORTED_GLOBAL_SKILL_TOPOLOGIES, + type SkillFreshnessInstallation +} from '../../shared/skill-freshness' + +export function eligibleSkillUpdateNames( + installations: readonly SkillFreshnessInstallation[] +): string[] { + const byName = new Map() + for (const installation of installations) { + const entries = byName.get(installation.name) ?? [] + entries.push(installation) + byName.set(installation.name, entries) + } + + const eligible: string[] = [] + for (const [name, entries] of byName) { + const hasOutdated = entries.some((entry) => entry.status === 'outdated') + const everyPlacementIsOfficialAndUpdatable = entries.every( + (entry) => + (entry.status === 'current' || entry.status === 'outdated') && + // Why: the rail reliably converges the canonical copy and its symlink aliases. + // A standalone duplicate no longer blocks the whole name — the canonical copy + // still updates and the duplicate row is flagged as maybe-not-reached — while + // data-loss topologies (unrecognized/read-only/etc.) still poison via these checks. + (SUPPORTED_GLOBAL_SKILL_TOPOLOGIES.has(entry.topology) || + entry.topology === 'independent-copy') && + Boolean(entry.resolvedPath && entry.physicalIdentity) + ) + // Why: only offer the global command when a reliably-convergent placement anchors it, + // so a skill that exists solely as a standalone copy never draws a command that could + // no-op or error against a canonical install that isn't there. + const hasReliableTarget = entries.some((entry) => + SUPPORTED_GLOBAL_SKILL_TOPOLOGIES.has(entry.topology) + ) + if (hasOutdated && everyPlacementIsOfficialAndUpdatable && hasReliableTarget) { + eligible.push(name) + } + } + return eligible.sort((left, right) => left.localeCompare(right, 'en')) +} diff --git a/src/main/skills/skill-freshness-inventory-limits.test.ts b/src/main/skills/skill-freshness-inventory-limits.test.ts new file mode 100644 index 000000000000..c43d137fcd06 --- /dev/null +++ b/src/main/skills/skill-freshness-inventory-limits.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { + boundRepositorySkillRoots, + MAXIMUM_REPOSITORY_SKILL_ROOTS +} from './skill-freshness-inventory' +import type { SkillScanRoot } from './skill-discovery-sources' + +describe('skill freshness inventory limits', () => { + it('caps repository roots before creating candidate probes', () => { + const roots = Array.from( + { length: MAXIMUM_REPOSITORY_SKILL_ROOTS + 3 }, + (_, index): SkillScanRoot => ({ + id: `repo-${index}`, + label: `Repo ${index}`, + path: `/repo-${index}/.agents/skills`, + sourceKind: 'repo', + providers: ['agent-skills'] + }) + ) + + const bounded = boundRepositorySkillRoots(roots) + + expect(bounded.scanned).toHaveLength(MAXIMUM_REPOSITORY_SKILL_ROOTS) + expect(bounded.omitted).toHaveLength(3) + }) +}) diff --git a/src/main/skills/skill-freshness-inventory.test.ts b/src/main/skills/skill-freshness-inventory.test.ts new file mode 100644 index 000000000000..990fbdba1a3e --- /dev/null +++ b/src/main/skills/skill-freshness-inventory.test.ts @@ -0,0 +1,363 @@ +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type { Repo } from '../../shared/types' +import type { + SkillBundleFileIdentity, + SkillCurrentBundleEntry, + SkillKnownSnapshot +} from '../../shared/skill-freshness' +import { + inventorySkillFreshness, + MAXIMUM_REPOSITORY_SKILL_ROOTS +} from './skill-freshness-inventory' +import { describeObservedSkillFile, skillPackageDigest } from './skill-package-identity' + +const temporaryDirectories: string[] = [] + +function snapshot(releaseRevision: number, markdown: string): SkillKnownSnapshot { + const observed = describeObservedSkillFile('SKILL.md', Buffer.from(markdown), false) + const file: SkillBundleFileIdentity = { + path: observed.path, + size: observed.size, + executable: observed.executable, + classification: observed.classification, + exactSha256: observed.exactSha256, + textNormalizedSha256: observed.textNormalizedSha256, + identitySha256: observed.identitySha256 + } + return { + releaseRevision, + packageDigest: skillPackageDigest([file]), + gitTreeSha: releaseRevision.toString(16).padStart(40, '0'), + files: [file] + } +} + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), 'orca-skill-inventory-')) + temporaryDirectories.push(root) + const homeDir = join(root, 'home') + const resourceRoot = join(root, 'resources') + const skillResourceRoot = join(resourceRoot, 'skills') + await mkdir(skillResourceRoot, { recursive: true }) + + const oldMarkdown = '---\nname: orca-cli\ndescription: Old official guide.\n---\n\n# Old\n' + const currentMarkdown = + '---\nname: orca-cli\ndescription: Current official guide.\n---\n\n# Current\n' + const newerMarkdown = '---\nname: orca-cli\ndescription: Newer official guide.\n---\n\n# Newer\n' + const snapshots = [ + snapshot(1, oldMarkdown), + snapshot(2, currentMarkdown), + snapshot(3, newerMarkdown) + ] + const current: SkillCurrentBundleEntry = { + name: 'orca-cli', + sourcePath: 'skills/orca-cli', + appVersion: '2.0.0', + ...snapshots[1] + } + await Promise.all([ + writeFile( + join(skillResourceRoot, 'current-manifest.json'), + `${JSON.stringify({ schemaVersion: 1, appVersion: '2.0.0', skills: [current] }, null, 2)}\n` + ), + writeFile( + join(skillResourceRoot, 'snapshot-registry.json'), + `${JSON.stringify({ schemaVersion: 1, skills: { 'orca-cli': snapshots } }, null, 2)}\n` + ), + writeFile( + join(skillResourceRoot, 'release-mapping.json'), + `${JSON.stringify( + { + schemaVersion: 1, + releases: [ + { appVersion: '1.0.0', skills: { 'orca-cli': 1 } }, + { appVersion: '2.0.0', skills: { 'orca-cli': 2 } }, + { appVersion: '3.0.0', skills: { 'orca-cli': 3 } } + ] + }, + null, + 2 + )}\n` + ) + ]) + + const writeSkill = async (rootPath: string, markdown: string): Promise => { + const directory = join(rootPath, 'orca-cli') + await mkdir(directory, { recursive: true }) + await writeFile(join(directory, 'SKILL.md'), markdown) + return directory + } + return { + root, + homeDir, + resourceRoot, + oldMarkdown, + currentMarkdown, + newerMarkdown, + writeSkill + } +} + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((root) => rm(root, { recursive: true }))) +}) + +describe('read-only skill freshness inventory', () => { + it('offers an exact older official name only when all global placements are safe', async () => { + const test = await fixture() + await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.oldMarkdown) + + const inventory = await inventorySkillFreshness({ + homeDir: test.homeDir, + repos: [], + resourceRoot: test.resourceRoot + }) + + expect(inventory.installations.map((entry) => entry.status)).toEqual(['outdated']) + expect(inventory.installations[0]?.installedAppVersion).toBe('1.0.0') + expect(inventory.eligibleUpdateNames).toEqual(['orca-cli']) + }) + + it('labels newer known and unrecognized bytes honestly without calling them modified', async () => { + const test = await fixture() + await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.newerMarkdown) + await test.writeSkill( + join(test.homeDir, '.claude', 'skills'), + '---\nname: orca-cli\ndescription: User copy.\n---\n' + ) + + const inventory = await inventorySkillFreshness({ + homeDir: test.homeDir, + repos: [], + resourceRoot: test.resourceRoot + }) + + expect(inventory.installations.map((entry) => entry.status)).toEqual([ + 'newer-known', + 'unrecognized' + ]) + expect(inventory.eligibleUpdateNames).toEqual([]) + }) + + it('retains full-file identity without projecting unused metadata', async () => { + const test = await fixture() + const lateDescription = 'Description beyond the metadata parsing budget.' + await test.writeSkill( + join(test.homeDir, '.agents', 'skills'), + `${' '.repeat(256 * 1024)}\n${lateDescription}` + ) + + const inventory = await inventorySkillFreshness({ + homeDir: test.homeDir, + repos: [], + resourceRoot: test.resourceRoot + }) + + expect(inventory.installations[0]).toMatchObject({ + status: 'unrecognized' + }) + expect(inventory.installations[0]).not.toHaveProperty('description') + expect(inventory.installations[0]?.observedPackageDigest).toMatch(/^[a-f0-9]{64}$/) + }) + + it.runIf(process.platform !== 'win32')( + 'deduplicates a provider alias to the canonical copy', + async () => { + const test = await fixture() + const canonical = await test.writeSkill( + join(test.homeDir, '.agents', 'skills'), + test.oldMarkdown + ) + const claudeRoot = join(test.homeDir, '.claude', 'skills') + await mkdir(claudeRoot, { recursive: true }) + await symlink(canonical, join(claudeRoot, 'orca-cli')) + + const inventory = await inventorySkillFreshness({ + homeDir: test.homeDir, + repos: [], + resourceRoot: test.resourceRoot + }) + + expect(inventory.installations).toHaveLength(1) + expect(inventory.installations[0]?.providers).toEqual(['agent-skills', 'claude']) + expect(inventory.installations[0]?.topology).toBe('canonical-copy') + expect(inventory.eligibleUpdateNames).toEqual(['orca-cli']) + } + ) + + it.runIf(process.platform !== 'win32')( + 'deduplicates aliases within an unsupported topology without hiding its poison', + async () => { + const test = await fixture() + await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.oldMarkdown) + const shared = await test.writeSkill(join(test.root, 'shared'), test.currentMarkdown) + const repos = await Promise.all( + ['one', 'two'].map(async (id) => { + const repoPath = join(test.root, `repo-${id}`) + const root = join(repoPath, '.agents', 'skills') + await mkdir(root, { recursive: true }) + await symlink(shared, join(root, 'orca-cli')) + return { id, path: repoPath } as unknown as Repo + }) + ) + + const inventory = await inventorySkillFreshness({ + homeDir: test.homeDir, + repos, + resourceRoot: test.resourceRoot + }) + + expect( + inventory.installations.filter((entry) => entry.topology === 'repo-scope') + ).toHaveLength(1) + expect(inventory.eligibleUpdateNames).toEqual([]) + } + ) + + it('keeps inaccessible placements visible and lets them poison the name', async () => { + const test = await fixture() + await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.oldMarkdown) + const inaccessiblePath = join(test.homeDir, '.codex', 'skills', 'orca-cli') + + const inventory = await inventorySkillFreshness({ + homeDir: test.homeDir, + repos: [], + resourceRoot: test.resourceRoot, + candidateLstat: async (path) => { + if (path === inaccessiblePath) { + throw Object.assign(new Error('permission denied'), { code: 'EACCES' }) + } + return import('node:fs/promises').then(({ lstat }) => lstat(path)) + } + }) + + expect(inventory.installations.map((entry) => entry.status)).toEqual([ + 'outdated', + 'inaccessible' + ]) + expect(inventory.eligibleUpdateNames).toEqual([]) + }) + + it('does not lose an inaccessible known repository placement', async () => { + const test = await fixture() + await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.oldMarkdown) + const repoPath = join(test.root, 'repo') + const inaccessiblePath = join(repoPath, '.agents', 'skills', 'orca-cli') + + const inventory = await inventorySkillFreshness({ + homeDir: test.homeDir, + repos: [{ id: 'repo', path: repoPath }] as unknown as Repo[], + resourceRoot: test.resourceRoot, + candidateLstat: async (path) => { + if (path === inaccessiblePath) { + throw Object.assign(new Error('permission denied'), { code: 'EACCES' }) + } + return import('node:fs/promises').then(({ lstat }) => lstat(path)) + } + }) + + expect(inventory.installations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + unresolvedPath: inaccessiblePath, + topology: 'repo-scope', + status: 'inaccessible' + }) + ]) + ) + expect(inventory.eligibleUpdateNames).toEqual([]) + }) + + it.each([ + ['repo', 'repo-scope'], + ['plugin', 'plugin-cache'] + ] as const)( + 'keeps an official %s placement informational and name-poisoning', + async (kind, topology) => { + const test = await fixture() + await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.oldMarkdown) + let repos: Repo[] = [] + if (kind === 'repo') { + const repoPath = join(test.root, 'repo') + await test.writeSkill(join(repoPath, '.agents', 'skills'), test.currentMarkdown) + repos = [{ id: 'repo', path: repoPath }] as unknown as Repo[] + } else { + await test.writeSkill( + join(test.homeDir, '.codex', 'plugins', 'cache', 'vendor', 'skills'), + test.currentMarkdown + ) + } + + const inventory = await inventorySkillFreshness({ + homeDir: test.homeDir, + repos, + resourceRoot: test.resourceRoot + }) + + expect(inventory.installations.some((entry) => entry.topology === topology)).toBe(true) + expect(inventory.eligibleUpdateNames).toEqual([]) + } + ) + + it('accepts CRLF as the same official text identity', async () => { + const test = await fixture() + await test.writeSkill( + join(test.homeDir, '.agents', 'skills'), + test.oldMarkdown.replaceAll('\n', '\r\n') + ) + + const inventory = await inventorySkillFreshness({ + homeDir: test.homeDir, + repos: [], + resourceRoot: test.resourceRoot + }) + expect(inventory.installations[0]?.status).toBe('outdated') + }) + + it('classifies exact current bytes as current when a later snapshot reuses them', async () => { + const test = await fixture() + const resourceRoot = join(test.resourceRoot, 'skills') + const registryPath = join(resourceRoot, 'snapshot-registry.json') + const registry = JSON.parse(await readFile(registryPath, 'utf8')) + registry.skills['orca-cli'].push(snapshot(4, test.currentMarkdown)) + await writeFile(registryPath, `${JSON.stringify(registry, null, 2)}\n`) + await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.currentMarkdown) + + const inventory = await inventorySkillFreshness({ + homeDir: test.homeDir, + repos: [], + resourceRoot: test.resourceRoot + }) + + expect(inventory.installations[0]).toMatchObject({ + status: 'current', + installedReleaseRevision: 2, + installedAppVersion: '2.0.0' + }) + }) + + it('withholds updates when stored repositories exceed the probe budget', async () => { + const test = await fixture() + await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.oldMarkdown) + const repos = Array.from( + { length: MAXIMUM_REPOSITORY_SKILL_ROOTS / 2 + 1 }, + (_, index) => ({ id: `repo-${index}`, path: join(test.root, `repo-${index}`) }) as Repo + ) + + const inventory = await inventorySkillFreshness({ + homeDir: test.homeDir, + repos, + resourceRoot: test.resourceRoot + }) + + expect(inventory.installations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ errorCategory: 'repository-scan-limit', status: 'inaccessible' }) + ]) + ) + expect(inventory.eligibleUpdateNames).toEqual([]) + }) +}) diff --git a/src/main/skills/skill-freshness-inventory.ts b/src/main/skills/skill-freshness-inventory.ts new file mode 100644 index 000000000000..e5bbae31da91 --- /dev/null +++ b/src/main/skills/skill-freshness-inventory.ts @@ -0,0 +1,179 @@ +import { lstat } from 'node:fs/promises' +import { join } from 'node:path' +import type { Repo } from '../../shared/types' +import type { + SkillFreshnessInstallation, + SkillFreshnessInventory +} from '../../shared/skill-freshness' +import { buildSkillDiscoverySources, type SkillScanRoot } from './skill-discovery-sources' +import { loadSkillBundleArtifacts } from './skill-bundle-artifacts' +import { eligibleSkillUpdateNames } from './skill-freshness-eligibility' +import { runSkillCandidateTasks } from './skill-candidate-concurrency' +import { + classifyHomeSkillCandidate, + classifyUnsupportedSkillCandidate, + dedupeSkillFreshnessPlacements, + observeSkillFreshnessInstallation, + type CandidateLstat +} from './skill-freshness-placement-observation' +import { scanKnownPluginSkillCandidates } from './skill-plugin-cache-scan' + +export const MAXIMUM_REPOSITORY_SKILL_ROOTS = 128 + +export function boundRepositorySkillRoots(roots: readonly SkillScanRoot[]): { + scanned: SkillScanRoot[] + omitted: SkillScanRoot[] +} { + return { + scanned: roots.slice(0, MAXIMUM_REPOSITORY_SKILL_ROOTS), + omitted: roots.slice(MAXIMUM_REPOSITORY_SKILL_ROOTS) + } +} + +export async function inventorySkillFreshness( + args: { + homeDir?: string + cwd?: string + repos?: Repo[] + resourceRoot?: string + candidateLstat?: CandidateLstat + } = {} +): Promise { + const artifacts = await loadSkillBundleArtifacts(args.resourceRoot) + const currentByName = new Map(artifacts.manifest.skills.map((skill) => [skill.name, skill])) + const discoveryArgs = { + homeDir: args.homeDir, + cwd: args.cwd, + repos: args.repos, + // Why: freshness scans known repositories explicitly; treating the app's + // launch cwd as another repo would create phantom poison placements. + includeCwd: false + } + const roots = buildSkillDiscoverySources(discoveryArgs) + const homeRoots = roots.filter((root) => root.sourceKind === 'home') + const allRepoRoots = roots.filter((root) => root.sourceKind === 'repo') + const { scanned: repoRoots, omitted: omittedRepoRoots } = boundRepositorySkillRoots(allRepoRoots) + const pluginRoots = roots.filter((root) => root.sourceKind === 'plugin') + const canonicalRootPath = homeRoots.find((root) => root.id === 'home-agents')?.path + if (!canonicalRootPath) { + throw new Error('Missing canonical agent skills root') + } + + const homeTasks = artifacts.manifest.skills.flatMap((current) => + homeRoots.map( + (root) => () => + classifyHomeSkillCandidate({ + root, + current, + artifacts, + canonicalRootPath, + candidateLstat: args.candidateLstat ?? ((path) => lstat(path)) + }) + ) + ) + // Why: each observation may retain the package byte ceiling while hashing; + // launch/focus scans must not fan out across every known placement. + const homeInstallations = (await runSkillCandidateTasks(homeTasks)).filter( + (installation): installation is SkillFreshnessInstallation => installation !== null + ) + + const candidateLstat = args.candidateLstat ?? ((path) => lstat(path)) + const repoTasks = artifacts.manifest.skills.flatMap((current) => + repoRoots.map( + (root) => () => + classifyUnsupportedSkillCandidate({ + root, + current, + artifacts, + unresolvedPath: join(root.path, current.name), + candidateLstat + }) + ) + ) + // Why: stored repositories can grow without bound. If the probe budget is + // exhausted, one sentinel per name preserves safety without hashing more packages. + const omittedRepoTasks = + omittedRepoRoots.length === 0 + ? [] + : artifacts.manifest.skills.map( + (current) => () => + observeSkillFreshnessInstallation({ + current, + artifacts, + rootId: 'repo-scan-limit', + providers: [...new Set(omittedRepoRoots.flatMap((root) => root.providers))], + sourceKind: 'repo', + sourceLabel: 'Additional repositories', + unresolvedPath: omittedRepoRoots[0]?.path ?? 'repo-scan-limit', + topology: { + topology: 'repo-scope', + resolvedPath: null, + identity: null, + errorCategory: 'repository-scan-limit' + } + }) + ) + const pluginScans = await Promise.all( + pluginRoots.map(async (root) => ({ + root, + scan: await scanKnownPluginSkillCandidates(root.path, new Set(currentByName.keys())) + })) + ) + const pluginTasks = pluginScans.flatMap(({ root, scan }) => [ + ...scan.candidates.flatMap((candidate) => { + const current = currentByName.get(candidate.name) + return current + ? [ + () => + classifyUnsupportedSkillCandidate({ + root, + current, + artifacts, + unresolvedPath: candidate.path, + candidateLstat + }) + ] + : [] + }), + // Why: unreadable plugin subtrees could hide any official name. An + // incomplete scan must conservatively poison every name rather than imply absence. + ...scan.incompletePaths.flatMap((incompletePath) => + artifacts.manifest.skills.map( + (current) => () => + observeSkillFreshnessInstallation({ + current, + artifacts, + rootId: root.id, + providers: root.providers, + sourceKind: 'plugin', + sourceLabel: root.label, + unresolvedPath: join(incompletePath, current.name), + topology: { + topology: 'plugin-cache', + resolvedPath: null, + identity: null, + errorCategory: 'plugin-cache-scan-incomplete' + } + }) + ) + ) + ]) + const unsupportedInstallations = ( + await runSkillCandidateTasks([...repoTasks, ...omittedRepoTasks, ...pluginTasks]) + ).filter((installation): installation is SkillFreshnessInstallation => installation !== null) + const installations = dedupeSkillFreshnessPlacements([ + ...homeInstallations, + ...unsupportedInstallations + ]).sort( + (left, right) => + left.name.localeCompare(right.name, 'en') || + left.unresolvedPath.localeCompare(right.unresolvedPath, 'en') + ) + + return { + schemaVersion: 1, + installations, + eligibleUpdateNames: eligibleSkillUpdateNames(installations), + scannedAt: Date.now() + } +} diff --git a/src/main/skills/skill-freshness-placement-observation.ts b/src/main/skills/skill-freshness-placement-observation.ts new file mode 100644 index 000000000000..2f8d32ca663c --- /dev/null +++ b/src/main/skills/skill-freshness-placement-observation.ts @@ -0,0 +1,251 @@ +import type { Stats } from 'node:fs' +import { join } from 'node:path' +import type { + SkillCurrentBundleEntry, + SkillFreshnessInstallation, + SkillFreshnessStatus, + SkillKnownSnapshot +} from '../../shared/skill-freshness' +import type { SkillScanRoot } from './skill-discovery-sources' +import type { SkillBundleArtifacts } from './skill-bundle-artifacts' +import { matchingKnownSnapshot, observeSkillPackage } from './skill-package-identity' +import { + classifyHomeSkillTopology, + classifyUnsupportedSkillTopology, + skillPlacementId, + skillTopologyPriority, + type ClassifiedSkillTopology +} from './skill-installation-topology' + +export type CandidateLstat = (path: string) => Promise + +function freshnessStatus( + snapshot: SkillKnownSnapshot | null, + current: SkillCurrentBundleEntry +): SkillFreshnessStatus { + if (!snapshot) { + return 'unrecognized' + } + if (snapshot.releaseRevision > current.releaseRevision) { + return 'newer-known' + } + return snapshot.packageDigest === current.packageDigest ? 'current' : 'outdated' +} + +function errorCategory(error: unknown, fallback: string): string { + return error instanceof Error && error.message ? error.message : fallback +} + +function isInaccessibleError(error: unknown): boolean { + return Boolean( + error && + typeof error === 'object' && + 'code' in error && + (error.code === 'EACCES' || error.code === 'EPERM') + ) +} + +function knownSnapshots( + artifacts: SkillBundleArtifacts, + current: SkillCurrentBundleEntry +): SkillKnownSnapshot[] { + const snapshots = artifacts.knownSnapshots[current.name] ?? [] + return snapshots.some((snapshot) => snapshot.packageDigest === current.packageDigest) + ? snapshots + : [...snapshots, current] +} + +export async function observeSkillFreshnessInstallation(args: { + current: SkillCurrentBundleEntry + artifacts: SkillBundleArtifacts + rootId: string + providers: SkillFreshnessInstallation['providers'] + sourceKind: SkillFreshnessInstallation['sourceKind'] + sourceLabel: string + unresolvedPath: string + topology: ClassifiedSkillTopology +}): Promise { + const base = { + id: skillPlacementId(args.unresolvedPath, args.current.name), + name: args.current.name, + rootId: args.rootId, + providers: args.providers, + sourceKind: args.sourceKind, + sourceLabel: args.sourceLabel, + unresolvedPath: args.unresolvedPath, + resolvedPath: args.topology.resolvedPath, + physicalIdentity: args.topology.identity, + topology: args.topology.topology, + currentReleaseRevision: args.current.releaseRevision, + currentPackageDigest: args.current.packageDigest, + currentAppVersion: args.current.appVersion, + errorCategory: args.topology.errorCategory + } + if (!args.topology.resolvedPath || !args.topology.identity) { + return { + ...base, + status: 'inaccessible', + installedReleaseRevision: null, + installedAppVersion: null, + observedPackageDigest: null + } + } + + try { + const observed = await observeSkillPackage(args.topology.resolvedPath) + const matchedSnapshot = matchingKnownSnapshot( + observed, + knownSnapshots(args.artifacts, args.current) + ) + // Why: a later release can reintroduce identical bytes. Exact current + // identity is still current, and cannot honestly be attributed to the later tag. + const snapshot = + observed.observedDigest === args.current.packageDigest ? args.current : matchedSnapshot + return { + ...base, + status: freshnessStatus(snapshot, args.current), + installedReleaseRevision: snapshot?.releaseRevision ?? null, + installedAppVersion: snapshot + ? (args.artifacts.releasedAppVersions[args.current.name]?.[snapshot.releaseRevision] ?? + null) + : null, + observedPackageDigest: observed.observedDigest + } + } catch (error) { + return { + ...base, + status: isInaccessibleError(error) ? 'inaccessible' : 'unrecognized', + installedReleaseRevision: null, + installedAppVersion: null, + observedPackageDigest: null, + errorCategory: errorCategory(error, 'skill-package-read-failed') + } + } +} + +export async function classifyHomeSkillCandidate(args: { + root: SkillScanRoot + current: SkillCurrentBundleEntry + artifacts: SkillBundleArtifacts + canonicalRootPath: string + candidateLstat: CandidateLstat +}): Promise { + const unresolvedPath = join(args.root.path, args.current.name) + try { + await args.candidateLstat(unresolvedPath) + } catch (error) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { + return null + } + return observeSkillFreshnessInstallation({ + current: args.current, + artifacts: args.artifacts, + rootId: args.root.id, + providers: args.root.providers, + sourceKind: args.root.sourceKind, + sourceLabel: args.root.label, + unresolvedPath, + topology: { + topology: 'broken-link', + resolvedPath: null, + identity: null, + errorCategory: errorCategory(error, 'skill-candidate-inaccessible') + } + }) + } + + let topology: ClassifiedSkillTopology + try { + topology = await classifyHomeSkillTopology(args.root, unresolvedPath, args.canonicalRootPath) + } catch (error) { + topology = { + topology: 'broken-link', + resolvedPath: null, + identity: null, + errorCategory: errorCategory(error, 'skill-candidate-topology-failed') + } + } + return observeSkillFreshnessInstallation({ + current: args.current, + artifacts: args.artifacts, + rootId: args.root.id, + providers: args.root.providers, + sourceKind: args.root.sourceKind, + sourceLabel: args.root.label, + unresolvedPath, + topology + }) +} + +export async function classifyUnsupportedSkillCandidate(args: { + root: SkillScanRoot + current: SkillCurrentBundleEntry + artifacts: SkillBundleArtifacts + unresolvedPath: string + candidateLstat: CandidateLstat +}): Promise { + try { + await args.candidateLstat(args.unresolvedPath) + } catch (error) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { + return null + } + return observeSkillFreshnessInstallation({ + current: args.current, + artifacts: args.artifacts, + rootId: args.root.id, + providers: args.root.providers, + sourceKind: args.root.sourceKind, + sourceLabel: args.root.label, + unresolvedPath: args.unresolvedPath, + topology: { + topology: args.root.sourceKind === 'repo' ? 'repo-scope' : 'plugin-cache', + resolvedPath: null, + identity: null, + errorCategory: errorCategory(error, 'unsupported-candidate-inaccessible') + } + }) + } + return observeSkillFreshnessInstallation({ + current: args.current, + artifacts: args.artifacts, + rootId: args.root.id, + providers: args.root.providers, + sourceKind: args.root.sourceKind, + sourceLabel: args.root.label, + unresolvedPath: args.unresolvedPath, + topology: await classifyUnsupportedSkillTopology( + args.unresolvedPath, + args.root.sourceKind === 'repo' ? 'repo' : 'plugin' + ) + }) +} + +function topologyDedupeBucket(installation: SkillFreshnessInstallation): string { + return installation.topology === 'canonical-copy' || installation.topology === 'provider-alias' + ? 'managed-global' + : installation.topology +} + +export function dedupeSkillFreshnessPlacements( + installations: readonly SkillFreshnessInstallation[] +): SkillFreshnessInstallation[] { + const deduped = new Map() + for (const installation of installations) { + const key = installation.physicalIdentity + ? `${installation.name}\0${installation.physicalIdentity}\0${topologyDedupeBucket(installation)}` + : `logical\0${installation.id}` + const existing = deduped.get(key) + if (!existing) { + deduped.set(key, installation) + continue + } + const providers = [...new Set([...existing.providers, ...installation.providers])] + if (skillTopologyPriority(installation.topology) > skillTopologyPriority(existing.topology)) { + deduped.set(key, { ...installation, providers }) + } else { + existing.providers = providers + } + } + return [...deduped.values()] +} diff --git a/src/main/skills/skill-installation-topology.ts b/src/main/skills/skill-installation-topology.ts new file mode 100644 index 000000000000..673fcb3d1e1d --- /dev/null +++ b/src/main/skills/skill-installation-topology.ts @@ -0,0 +1,172 @@ +import { createHash } from 'node:crypto' +import { constants } from 'node:fs' +import { access, lstat, realpath, stat } from 'node:fs/promises' +import { dirname, normalize, resolve } from 'node:path' +import type { SkillInstallationTopology } from '../../shared/skill-freshness' +import type { SkillScanRoot } from './skill-discovery-sources' + +export type ClassifiedSkillTopology = { + topology: SkillInstallationTopology + resolvedPath: string | null + identity: string | null + errorCategory: string | null +} + +export function skillPlacementId(unresolvedPath: string, name: string): string { + return createHash('sha256') + .update(normalizedSkillIdentityPath(unresolvedPath)) + .update('\0') + .update(name) + .digest('hex') + .slice(0, 24) +} + +export function normalizedSkillIdentityPath(value: string): string { + const normalized = normalize(value) + return process.platform === 'win32' ? normalized.toLocaleLowerCase('en-US') : normalized +} + +export function skillPhysicalIdentity( + resolvedPath: string, + fileStat: Awaited> +): string { + const inodeIdentity = fileStat.dev || fileStat.ino ? `${fileStat.dev}:${fileStat.ino}` : null + return inodeIdentity ?? normalizedSkillIdentityPath(resolvedPath) +} + +export function skillTopologyPriority(topology: SkillInstallationTopology): number { + switch (topology) { + case 'canonical-copy': + return 3 + case 'independent-copy': + return 2 + case 'provider-alias': + return 1 + case 'external-link': + case 'broken-link': + case 'read-only': + case 'repo-scope': + case 'plugin-cache': + return 0 + } +} + +async function writableDestination(path: string): Promise { + try { + await Promise.all([ + access(path, constants.R_OK | constants.W_OK), + access(dirname(path), constants.W_OK) + ]) + return true + } catch { + return false + } +} + +async function hasSymlinkedAncestor(path: string, boundary: string): Promise { + let current = resolve(path) + const stop = resolve(boundary) + for (;;) { + const entry = await lstat(current).catch(() => null) + if (!entry || entry.isSymbolicLink()) { + return true + } + const parent = dirname(current) + if (current === stop) { + return false + } + if (parent === current) { + return true + } + current = parent + } +} + +export async function classifyHomeSkillTopology( + root: SkillScanRoot, + unresolvedPath: string, + canonicalRootPath: string +): Promise { + let logicalStat: Awaited> + try { + logicalStat = await lstat(unresolvedPath) + } catch (error) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { + return { + topology: 'broken-link', + resolvedPath: null, + identity: null, + errorCategory: 'missing' + } + } + throw error + } + const linked = logicalStat.isSymbolicLink() + let resolvedPath: string + let resolvedStat: Awaited> + try { + resolvedPath = await realpath(unresolvedPath) + resolvedStat = await stat(resolvedPath) + } catch { + return { + topology: 'broken-link', + resolvedPath: null, + identity: null, + errorCategory: 'dangling-link' + } + } + if (!resolvedStat.isDirectory()) { + return { + topology: 'broken-link', + resolvedPath, + identity: null, + errorCategory: 'not-directory' + } + } + + const identity = skillPhysicalIdentity(resolvedPath, resolvedStat) + const canonicalRoot = await realpath(canonicalRootPath).catch(() => resolve(canonicalRootPath)) + const homeBoundary = dirname(dirname(canonicalRootPath)) + const rootOrProviderParentLinked = await hasSymlinkedAncestor(root.path, homeBoundary) + const isCanonicalTarget = + normalizedSkillIdentityPath(dirname(resolvedPath)) === + normalizedSkillIdentityPath(canonicalRoot) + let topology: SkillInstallationTopology + if (linked) { + topology = isCanonicalTarget ? 'provider-alias' : 'external-link' + } else if (rootOrProviderParentLinked) { + topology = 'external-link' + } else { + topology = root.id === 'home-agents' ? 'canonical-copy' : 'independent-copy' + } + if (topology !== 'external-link' && !(await writableDestination(resolvedPath))) { + topology = 'read-only' + } + return { topology, resolvedPath, identity, errorCategory: null } +} + +export async function classifyUnsupportedSkillTopology( + directoryPath: string, + sourceKind: 'repo' | 'plugin' +): Promise { + try { + const resolvedPath = await realpath(directoryPath) + const resolvedStat = await stat(resolvedPath) + if (!resolvedStat.isDirectory()) { + throw new Error('not-directory') + } + return { + topology: sourceKind === 'repo' ? 'repo-scope' : 'plugin-cache', + resolvedPath, + identity: skillPhysicalIdentity(resolvedPath, resolvedStat), + errorCategory: null + } + } catch (error) { + return { + topology: sourceKind === 'repo' ? 'repo-scope' : 'plugin-cache', + resolvedPath: null, + identity: null, + errorCategory: error instanceof Error ? error.message : 'read-failed' + } + } +} diff --git a/src/main/skills/skill-package-identity.test.ts b/src/main/skills/skill-package-identity.test.ts new file mode 100644 index 000000000000..648b628e82ce --- /dev/null +++ b/src/main/skills/skill-package-identity.test.ts @@ -0,0 +1,92 @@ +import { chmod, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + describeObservedSkillFile, + matchingKnownSnapshot, + observeSkillPackage, + skillPackageDigest +} from './skill-package-identity' + +const temporaryDirectories: string[] = [] + +async function temporarySkill(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-skill-freshness-')) + temporaryDirectories.push(root) + return root +} + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((root) => rm(root, { recursive: true }))) +}) + +describe('skill package identity', () => { + it('matches CRLF installed text to an LF official snapshot', async () => { + const root = await temporarySkill() + await writeFile(join(root, 'SKILL.md'), 'first\r\nsecond\r\n') + const observed = await observeSkillPackage(root) + const expected = describeObservedSkillFile('SKILL.md', Buffer.from('first\nsecond\n'), false) + + // Why: scans can observe several package byte budgets concurrently; only + // hashes, not raw file buffers, should survive each file's identity pass. + expect(observed.files[0]).not.toHaveProperty('bytes') + expect( + matchingKnownSnapshot(observed, [ + { + releaseRevision: 1, + packageDigest: skillPackageDigest([expected]), + gitTreeSha: 'tree', + files: [expected] + } + ])?.releaseRevision + ).toBe(1) + }) + + it('uses exact bytes for executable and binary files', async () => { + const executable = describeObservedSkillFile('run.sh', Buffer.from('#!/bin/sh\r\n'), true) + const binary = describeObservedSkillFile('asset.bin', Buffer.from([0, 13, 10]), false) + expect(executable.identitySha256).toBe(executable.exactSha256) + expect(binary.identitySha256).toBe(binary.exactSha256) + expect(binary.classification).toBe('binary') + }) + + it('orders package files by locale-independent code units', async () => { + const root = await temporarySkill() + await writeFile(join(root, 'apple.md'), 'apple') + await writeFile(join(root, 'Zebra.md'), 'zebra') + + const observed = await observeSkillPackage(root) + + expect(observed.files.map((file) => file.path)).toEqual(['Zebra.md', 'apple.md']) + }) + + it('rejects links and bounded-observation overflows', async () => { + const root = await temporarySkill() + await writeFile(join(root, 'SKILL.md'), 'skill') + if (process.platform !== 'win32') { + await symlink(join(root, 'SKILL.md'), join(root, 'linked.md')) + await expect(observeSkillPackage(root)).rejects.toThrow('skill-package-link') + await rm(join(root, 'linked.md')) + } + await expect( + observeSkillPackage(root, { + maximumDepth: 1, + maximumEntries: 0, + maximumFiles: 1, + maximumSingleFileBytes: 10, + maximumTotalBytes: 10 + }) + ).rejects.toThrow('skill-package-entry-limit') + }) + + it.runIf(process.platform !== 'win32')('tracks executable mode in package identity', async () => { + const root = await temporarySkill() + await mkdir(join(root, 'scripts')) + const script = join(root, 'scripts', 'run.sh') + await writeFile(script, '#!/bin/sh\n') + await chmod(script, 0o755) + const observed = await observeSkillPackage(root) + expect(observed.files[0]?.executable).toBe(true) + }) +}) diff --git a/src/main/skills/skill-package-identity.ts b/src/main/skills/skill-package-identity.ts new file mode 100644 index 000000000000..8189ee0b28bc --- /dev/null +++ b/src/main/skills/skill-package-identity.ts @@ -0,0 +1,229 @@ +import { createHash } from 'node:crypto' +import type { Dirent } from 'node:fs' +import { lstat, open, opendir } from 'node:fs/promises' +import { isAbsolute, join, relative, sep } from 'node:path' +import type { SkillBundleFileIdentity, SkillKnownSnapshot } from '../../shared/skill-freshness' + +type ObservedSkillFile = SkillBundleFileIdentity + +export type ObservedSkillPackage = { + files: ObservedSkillFile[] + observedDigest: string +} + +export const SKILL_PACKAGE_OBSERVATION_LIMITS = { + maximumDepth: 16, + maximumEntries: 2_048, + maximumFiles: 512, + maximumSingleFileBytes: 4 * 1024 * 1024, + maximumTotalBytes: 32 * 1024 * 1024 +} as const + +type SkillPackageObservationLimits = { + maximumDepth: number + maximumEntries: number + maximumFiles: number + maximumSingleFileBytes: number + maximumTotalBytes: number +} + +async function readBoundedSkillFile( + path: string, + remainingTotalBytes: number, + maximumSingleFileBytes: number +): Promise { + const handle = await open(path, 'r') + try { + const before = await handle.stat() + if (before.size > maximumSingleFileBytes) { + throw new Error('skill-package-file-size-limit') + } + if (before.size > remainingTotalBytes) { + throw new Error('skill-package-total-size-limit') + } + const bytes = Buffer.alloc(before.size) + let offset = 0 + while (offset < bytes.length) { + const result = await handle.read(bytes, offset, bytes.length - offset, offset) + if (result.bytesRead === 0) { + throw new Error('skill-package-changed-during-read') + } + offset += result.bytesRead + } + if ((await handle.stat()).size !== before.size) { + throw new Error('skill-package-changed-during-read') + } + return bytes + } finally { + await handle.close() + } +} + +function sha256(bytes: Buffer): string { + return createHash('sha256').update(bytes).digest('hex') +} + +function compareCodeUnits(left: string, right: string): number { + return left === right ? 0 : left < right ? -1 : 1 +} + +function normalizedText(bytes: Buffer): Buffer { + const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes) + return Buffer.from(text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'), 'utf8') +} + +export function describeObservedSkillFile( + path: string, + bytes: Buffer, + executable: boolean +): ObservedSkillFile { + let normalized: Buffer | null = null + if (!bytes.includes(0)) { + try { + normalized = normalizedText(bytes) + } catch { + normalized = null + } + } + const classification = normalized ? 'text' : 'binary' + const exactSha256 = sha256(bytes) + const textNormalizedSha256 = normalized ? sha256(normalized) : null + return { + path, + size: bytes.length, + executable, + classification, + exactSha256, + textNormalizedSha256, + identitySha256: + textNormalizedSha256 !== null && !executable ? textNormalizedSha256 : exactSha256 + } +} + +export function skillPackageDigest(files: readonly SkillBundleFileIdentity[]): string { + return sha256( + Buffer.from( + JSON.stringify( + files.map((file) => ({ + path: file.path, + executable: file.executable, + classification: file.classification, + identitySha256: file.identitySha256 + })) + ) + ) + ) +} + +function matchesFileIdentity( + actual: ObservedSkillFile, + expected: SkillBundleFileIdentity +): boolean { + if ( + actual.path !== expected.path || + actual.executable !== expected.executable || + actual.classification !== expected.classification + ) { + return false + } + return expected.classification === 'text' && !expected.executable + ? actual.textNormalizedSha256 === expected.textNormalizedSha256 + : actual.exactSha256 === expected.exactSha256 +} + +export async function observeSkillPackage( + packageRoot: string, + limits: SkillPackageObservationLimits = SKILL_PACKAGE_OBSERVATION_LIMITS +): Promise { + const files: ObservedSkillFile[] = [] + const caseFoldedPaths = new Map() + let entryCount = 0 + let totalBytes = 0 + + async function visit(directory: string, depth: number): Promise { + const directoryHandle = await opendir(directory) + const entries: Dirent[] = [] + try { + for (;;) { + const entry = await directoryHandle.read() + if (!entry) { + break + } + entryCount += 1 + if (entryCount > limits.maximumEntries) { + throw new Error('skill-package-entry-limit') + } + entries.push(entry) + } + } finally { + await directoryHandle.close().catch(() => undefined) + } + // Why: runtime Electron and the build's Node may carry different ICU data; + // identity order must match the generator without locale-sensitive collation. + entries.sort((left, right) => compareCodeUnits(left.name, right.name)) + for (const entry of entries) { + const absolutePath = join(directory, entry.name) + const relativePath = relative(packageRoot, absolutePath) + if ( + isAbsolute(relativePath) || + relativePath === '..' || + relativePath.startsWith(`..${sep}`) + ) { + throw new Error('skill-path-escape') + } + const manifestPath = relativePath.split(sep).join('/') + const folded = manifestPath.toLocaleLowerCase('en-US') + const collision = caseFoldedPaths.get(folded) + if (collision && collision !== manifestPath) { + throw new Error('skill-case-collision') + } + caseFoldedPaths.set(folded, manifestPath) + const fileStat = await lstat(absolutePath) + if (fileStat.isSymbolicLink()) { + throw new Error('skill-package-link') + } + if (fileStat.isDirectory()) { + if (depth >= limits.maximumDepth) { + throw new Error('skill-package-depth-limit') + } + await visit(absolutePath, depth + 1) + } else if (fileStat.isFile()) { + if (files.length >= limits.maximumFiles) { + throw new Error('skill-package-file-count-limit') + } + const bytes = await readBoundedSkillFile( + absolutePath, + limits.maximumTotalBytes - totalBytes, + limits.maximumSingleFileBytes + ) + totalBytes += bytes.length + files.push(describeObservedSkillFile(manifestPath, bytes, (fileStat.mode & 0o111) !== 0)) + } else { + throw new Error('skill-package-special-file') + } + } + } + + await visit(packageRoot, 0) + return { files, observedDigest: skillPackageDigest(files) } +} + +export function matchingKnownSnapshot( + observed: ObservedSkillPackage, + snapshots: readonly SkillKnownSnapshot[] +): SkillKnownSnapshot | null { + for (const snapshot of snapshots.toReversed()) { + if (snapshot.files.length !== observed.files.length) { + continue + } + if ( + snapshot.files.every((expected, index) => { + const actual = observed.files[index] + return Boolean(actual && matchesFileIdentity(actual, expected)) + }) + ) { + return snapshot + } + } + return null +} diff --git a/src/main/skills/skill-plugin-cache-scan.test.ts b/src/main/skills/skill-plugin-cache-scan.test.ts new file mode 100644 index 000000000000..46216ce6c04f --- /dev/null +++ b/src/main/skills/skill-plugin-cache-scan.test.ts @@ -0,0 +1,40 @@ +import { mkdir, mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { scanKnownPluginSkillCandidates } from './skill-plugin-cache-scan' + +const temporaryDirectories: string[] = [] + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((root) => rm(root, { recursive: true }))) +}) + +describe('plugin skill candidate scan', () => { + it('stops at the package candidate budget and marks the scan incomplete', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-plugin-skill-scan-')) + temporaryDirectories.push(root) + await Promise.all( + ['one', 'two'].map((vendor) => mkdir(join(root, vendor, 'orca-cli'), { recursive: true })) + ) + + const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli']), 1) + + expect(result.candidates).toHaveLength(1) + expect(result.incompletePaths).toEqual([root]) + }) + + it('marks depth-truncated subtrees incomplete so hidden skills poison eligibility', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-plugin-skill-depth-')) + temporaryDirectories.push(root) + const segments = Array.from({ length: 11 }, (_, index) => `level-${index}`) + const hiddenSkill = join(root, ...segments, 'orca-cli') + await mkdir(hiddenSkill, { recursive: true }) + + const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli'])) + + expect(result.candidates).toEqual([]) + expect(result.incompletePaths).toHaveLength(1) + expect(hiddenSkill.startsWith(result.incompletePaths[0] ?? '')).toBe(true) + }) +}) diff --git a/src/main/skills/skill-plugin-cache-scan.ts b/src/main/skills/skill-plugin-cache-scan.ts new file mode 100644 index 000000000000..7162cedbc472 --- /dev/null +++ b/src/main/skills/skill-plugin-cache-scan.ts @@ -0,0 +1,142 @@ +import type { Dirent } from 'node:fs' +import { opendir, realpath, stat } from 'node:fs/promises' +import { join } from 'node:path' + +const MAXIMUM_PLUGIN_SCAN_DEPTH = 9 +const MAXIMUM_PLUGIN_SCAN_ENTRIES = 4_096 +export const MAXIMUM_PLUGIN_SKILL_CANDIDATES = 64 +const MAXIMUM_PLUGIN_INCOMPLETE_PATHS = 16 + +export type KnownPluginSkillCandidate = { + name: string + path: string +} + +export type KnownPluginSkillScan = { + candidates: KnownPluginSkillCandidate[] + incompletePaths: string[] +} + +function errorCode(error: unknown): string | null { + return error && typeof error === 'object' && 'code' in error && typeof error.code === 'string' + ? error.code + : null +} + +export async function scanKnownPluginSkillCandidates( + rootPath: string, + knownNames: ReadonlySet, + maximumCandidates = MAXIMUM_PLUGIN_SKILL_CANDIDATES +): Promise { + const candidates: KnownPluginSkillCandidate[] = [] + const incompletePaths = new Set() + const visited = new Set() + let entryCount = 0 + let limitReached = false + + function recordIncomplete(path: string): void { + if (incompletePaths.has(path)) { + return + } + if (incompletePaths.size >= MAXIMUM_PLUGIN_INCOMPLETE_PATHS) { + // Why: each incomplete path expands to one conservative row per official + // skill. Collapse a hostile cache into one poison sentinel before IPC/render fanout. + incompletePaths.clear() + incompletePaths.add(rootPath) + limitReached = true + return + } + incompletePaths.add(path) + } + + async function visit(directory: string, depth: number): Promise { + if (limitReached) { + return + } + if (depth > MAXIMUM_PLUGIN_SCAN_DEPTH) { + recordIncomplete(directory) + return + } + let resolved: string + try { + resolved = await realpath(directory) + } catch (error) { + if (errorCode(error) !== 'ENOENT') { + recordIncomplete(directory) + } + return + } + if (visited.has(resolved)) { + return + } + visited.add(resolved) + + let handle: Awaited> + try { + handle = await opendir(directory) + } catch { + recordIncomplete(directory) + return + } + const entries: Dirent[] = [] + try { + for (;;) { + const entry = await handle.read() + if (!entry) { + break + } + entryCount += 1 + if (entryCount > MAXIMUM_PLUGIN_SCAN_ENTRIES) { + limitReached = true + recordIncomplete(rootPath) + break + } + entries.push(entry) + } + } catch { + recordIncomplete(directory) + } finally { + await handle.close().catch(() => undefined) + } + + entries.sort((left, right) => (left.name === right.name ? 0 : left.name < right.name ? -1 : 1)) + for (const entry of entries) { + if (limitReached) { + return + } + const entryPath = join(directory, entry.name) + let directoryEntry = entry.isDirectory() + if (entry.isSymbolicLink()) { + try { + directoryEntry = (await stat(entryPath)).isDirectory() + } catch { + if (knownNames.has(entry.name)) { + if (candidates.length >= maximumCandidates) { + limitReached = true + recordIncomplete(rootPath) + return + } + candidates.push({ name: entry.name, path: entryPath }) + } + continue + } + } + if (!directoryEntry) { + continue + } + if (knownNames.has(entry.name)) { + if (candidates.length >= maximumCandidates) { + limitReached = true + recordIncomplete(rootPath) + return + } + candidates.push({ name: entry.name, path: entryPath }) + continue + } + await visit(entryPath, depth + 1) + } + } + + await visit(rootPath, 0) + return { candidates, incompletePaths: [...incompletePaths] } +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index a8793c5f8b10..a3db4bbbc553 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -321,6 +321,7 @@ import type { ResolvedSourceControlAiGenerationParams } from '../shared/source-c import type { SourceControlAiSettings } from '../shared/source-control-ai-types' import type { ShellOpenLocalPathResult } from '../shared/shell-open-types' import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../shared/skills' +import type { SkillFreshnessInventory } from '../shared/skill-freshness' import type { CrashReportBreadcrumbData, CrashReportCopyDiagnosticsArgs, @@ -2255,6 +2256,7 @@ export type PreloadApi = { } skills: { discover: (target?: SkillDiscoveryTarget) => Promise + freshnessInventory: () => Promise } pet: { import: () => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index d1a6dfb5c926..4aa685d7b56e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -72,6 +72,7 @@ import type { import type { GitHistoryOptions, GitHistoryResult } from '../shared/git-history' import type { ShellOpenLocalPathResult } from '../shared/shell-open-types' import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../shared/skills' +import type { SkillFreshnessInventory } from '../shared/skill-freshness' import type { RuntimeBrowserDriverState, RuntimeMobileSessionTabMove, @@ -2216,7 +2217,9 @@ const api = { skills: { discover: (target?: SkillDiscoveryTarget): Promise => - ipcRenderer.invoke('skills:discover', target) + ipcRenderer.invoke('skills:discover', target), + freshnessInventory: (): Promise => + ipcRenderer.invoke('skills:freshnessInventory') }, pet: { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 9fecbe8fd84e..ab19d2e244ac 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -59,6 +59,8 @@ import RightSidebar from './components/right-sidebar' import { StarNagCard } from './components/StarNagCard' import { StarNagAgentValueMomentObserver } from './components/star-nag/StarNagAgentValueMomentObserver' import { StarNagToastHost } from './components/star-nag/StarNagToastHost' +import { SkillFreshnessNudge } from './components/skills/SkillFreshnessNudge' +import { SkillFreshnessUpdateDialog } from './components/skills/SkillFreshnessUpdateDialog' import { TelemetryFirstLaunchSurface } from './components/TelemetryFirstLaunchSurface' import { ZoomOverlay } from './components/ZoomOverlay' import { onOnboardingReopened } from './components/onboarding/show-onboarding-event' @@ -2828,10 +2830,20 @@ function App(): React.JSX.Element { > + {/* Why: the dialog hosts a live terminal pane, which requires the + link-routing preference context; mounting outside crashes it. */} + + + + {/* Why: rendered last so it sits after all -webkit-app-region:drag elements in DOM order. Electron's hit-test for drag regions is DOM-order-based and diff --git a/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx b/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx index d7f4da5b6193..4e986a2686ff 100644 --- a/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx +++ b/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState, type ReactNode } from 'react' import { Copy, Loader2, RefreshCw, Terminal } from 'lucide-react' import { toast } from 'sonner' import { IntegrationStatusPill } from '../integration-status-pill' +import { SkillFreshnessStatusPill } from '../skills/SkillFreshnessStatusPill' import { OnboardingInlineCommandTerminal } from '../onboarding/OnboardingInlineCommandTerminal' import { Button } from '../ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' @@ -47,6 +48,10 @@ type AgentSkillSetupPanelProps = { openingHint?: ReactNode footer?: ReactNode onRecheck: () => void | Promise + // Why: when set, the installed pill reflects skill freshness and Re-check also + // refreshes the freshness inventory. Callers omit it for non-local runtimes, + // which the local-host-only freshness scan cannot vouch for. + freshnessSkillName?: string } export function AgentSkillSetupPanel({ @@ -79,7 +84,8 @@ export function AgentSkillSetupPanel({ actionHint, openingHint, footer, - onRecheck + onRecheck, + freshnessSkillName }: AgentSkillSetupPanelProps): React.JSX.Element { const [terminalOpen, setTerminalOpen] = useState(false) const [terminalCommand, setTerminalCommand] = useState(null) @@ -211,7 +217,12 @@ export function AgentSkillSetupPanel({ variant="ghost" size="sm" className="gap-1.5" - onClick={() => void onRecheck()} + onClick={() => { + void onRecheck() + if (freshnessSkillName) { + notifyInstalledAgentSkillsChanged() + } + }} disabled={loading} > @@ -264,12 +275,16 @@ export function AgentSkillSetupPanel({ )} ) : installed ? ( - - {translate( - 'auto.components.settings.AgentSkillSetupPanel.9fcebceb2a', - 'Installed' - )} - + freshnessSkillName ? ( + + ) : ( + + {translate( + 'auto.components.settings.AgentSkillSetupPanel.9fcebceb2a', + 'Installed' + )} + + ) ) : ( {translate( diff --git a/src/renderer/src/components/settings/CliSection.tsx b/src/renderer/src/components/settings/CliSection.tsx index 203803320097..eae2ce2a76b3 100644 --- a/src/renderer/src/components/settings/CliSection.tsx +++ b/src/renderer/src/components/settings/CliSection.tsx @@ -390,6 +390,7 @@ export function CliSection({ })) }} onRecheck={refreshCliSkill} + freshnessSkillName={agentRuntime.runtime === 'host' ? ORCA_CLI_SKILL_NAME : undefined} /> ) : null} diff --git a/src/renderer/src/components/settings/ComputerUseSkillSetupPanel.tsx b/src/renderer/src/components/settings/ComputerUseSkillSetupPanel.tsx index 270e1785aef4..0ffc0a2b526d 100644 --- a/src/renderer/src/components/settings/ComputerUseSkillSetupPanel.tsx +++ b/src/renderer/src/components/settings/ComputerUseSkillSetupPanel.tsx @@ -79,6 +79,9 @@ export function ComputerUseSkillSetupPanel(): React.JSX.Element { : ensureOrcaCliAvailableForAgentSkillTerminal()) }} onRecheck={refreshComputerUseSkill} + freshnessSkillName={ + activeSkillRuntime.agentRuntime?.runtime === 'wsl' ? undefined : COMPUTER_USE_SKILL_NAME + } /> ) } diff --git a/src/renderer/src/components/settings/EphemeralVmsPane.tsx b/src/renderer/src/components/settings/EphemeralVmsPane.tsx index 85b27f12ed71..f1a34a0396ff 100644 --- a/src/renderer/src/components/settings/EphemeralVmsPane.tsx +++ b/src/renderer/src/components/settings/EphemeralVmsPane.tsx @@ -163,6 +163,9 @@ export function EphemeralVmsPane(): React.JSX.Element { : ensureOrcaCliAvailableForAgentSkillTerminal()) }} onRecheck={refreshSkill} + freshnessSkillName={ + activeSkillRuntime.agentRuntime?.runtime === 'wsl' ? undefined : EPHEMERAL_VMS_SKILL_NAME + } />
diff --git a/src/renderer/src/components/settings/OrchestrationSetupCard.tsx b/src/renderer/src/components/settings/OrchestrationSetupCard.tsx index b757261bba65..d597895d5803 100644 --- a/src/renderer/src/components/settings/OrchestrationSetupCard.tsx +++ b/src/renderer/src/components/settings/OrchestrationSetupCard.tsx @@ -7,6 +7,7 @@ import { ORCHESTRATION_SKILL_INSTALL_COMMAND, ORCHESTRATION_SKILL_UPDATE_COMMAND } from '@/lib/orchestration-install-command' +import { ORCHESTRATION_SKILL_NAME } from '@/lib/agent-feature-install-commands' import type { InstalledAgentSkillState } from '@/hooks/useInstalledAgentSkills' import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime' import { AgentSkillSetupPanel } from './AgentSkillSetupPanel' @@ -75,6 +76,9 @@ export function OrchestrationSetupCard(props: { : ensureOrcaCliAvailableForAgentSkillTerminal()) }} onRecheck={skill.refresh} + freshnessSkillName={ + activeSkillRuntime.agentRuntime?.runtime === 'wsl' ? undefined : ORCHESTRATION_SKILL_NAME + } /> ) diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index 2e0d2baa003d..0d4c2d5d5372 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -100,6 +100,7 @@ import { useInstalledAgentSkill } from '@/hooks/useInstalledAgentSkills' import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime' +import { useSkillFreshness } from '@/hooks/useSkillFreshness' import { deriveNeededSectionIds, getInitialMountedSectionIds } from './settings-load-performance' import { translate } from '@/i18n/i18n' import { getProjectHostSetupProjectionFromState } from '../../store/selectors' @@ -207,11 +208,15 @@ function getSettingsNavGroupDefinitionsForSearch( function getSkillNavInstallStatus(skill: { installed: boolean loading: boolean + updateAvailable?: boolean }): SettingsNavInstallStatus { if (skill.loading) { return 'checking' } - return skill.installed ? 'installed' : 'install' + if (!skill.installed) { + return 'install' + } + return skill.updateAvailable ? 'update-available' : 'installed' } function hasReadyVoiceModel( @@ -346,6 +351,10 @@ function Settings(): React.JSX.Element { discoveryTarget: activeSkillRuntime.discoveryTarget, sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS }) + // Why: mirror the setup cards — freshness only speaks for the validated global + // rail, which doesn't run under WSL, so the nav pill stays presence-only there. + const { inventory: skillFreshnessInventory } = useSkillFreshness() + const skillFreshnessApplies = activeSkillRuntime.agentRuntime?.runtime !== 'wsl' const [voiceModelStatesLoading, setVoiceModelStatesLoading] = useState(showDesktopOnlySettings) // Why: the Terminal settings section shares one search index with the // sidebar. We trim platform-only entries on other platforms so search never @@ -724,13 +733,16 @@ function Settings(): React.JSX.Element { orchestrationSkill const { installed: computerUseSkillInstalled, loading: computerUseSkillLoading } = computerUseSkill + const eligibleUpdateSkillNames = skillFreshnessInventory?.eligibleUpdateNames const capabilityInstallStatusBySectionId = useMemo(() => { + const eligibleUpdates = new Set(skillFreshnessApplies ? (eligibleUpdateSkillNames ?? []) : []) const next = new Map([ [ 'orchestration', getSkillNavInstallStatus({ installed: orchestrationSkillInstalled, - loading: orchestrationSkillLoading + loading: orchestrationSkillLoading, + updateAvailable: eligibleUpdates.has(ORCHESTRATION_SKILL_NAME) }) ] ]) @@ -739,7 +751,8 @@ function Settings(): React.JSX.Element { 'computer-use', getSkillNavInstallStatus({ installed: computerUseSkillInstalled, - loading: computerUseSkillLoading + loading: computerUseSkillLoading, + updateAvailable: eligibleUpdates.has(COMPUTER_USE_SKILL_NAME) }) ) if (settings) { @@ -757,11 +770,13 @@ function Settings(): React.JSX.Element { }, [ computerUseSkillInstalled, computerUseSkillLoading, + eligibleUpdateSkillNames, modelStates, orchestrationSkillInstalled, orchestrationSkillLoading, settings, showDesktopOnlySettings, + skillFreshnessApplies, voiceModelStatesLoading ]) const navSections = useMemo( diff --git a/src/renderer/src/components/settings/SettingsSidebar.tsx b/src/renderer/src/components/settings/SettingsSidebar.tsx index 7bbcea653713..8029eabda1a0 100644 --- a/src/renderer/src/components/settings/SettingsSidebar.tsx +++ b/src/renderer/src/components/settings/SettingsSidebar.tsx @@ -155,6 +155,11 @@ export function SettingsSidebar({ ) case 'installed': return translate('auto.components.settings.AgentSkillSetupPanel.9fcebceb2a', 'Installed') + case 'update-available': + return translate( + 'auto.components.skills.SkillFreshnessStatusPill.updateAvailable', + 'Update available' + ) case 'checking': return translate('auto.components.settings.AgentSkillSetupPanel.68a468752e', 'Checking...') } @@ -164,9 +169,11 @@ export function SettingsSidebar({ 'ml-auto shrink-0 rounded-full border px-1.5 py-0.5 text-[10px] font-medium leading-none', status === 'installed' ? 'border-status-success-border bg-status-success-background text-status-success' - : status === 'install' - ? 'border-foreground/15 bg-foreground/10 text-foreground' - : 'border-border/50 bg-muted/30 text-muted-foreground' + : status === 'update-available' + ? 'border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300' + : status === 'install' + ? 'border-foreground/15 bg-foreground/10 text-foreground' + : 'border-border/50 bg-muted/30 text-muted-foreground' ) return ( diff --git a/src/renderer/src/components/skills/SkillFreshnessNudge.test.tsx b/src/renderer/src/components/skills/SkillFreshnessNudge.test.tsx new file mode 100644 index 000000000000..ca0090a6c683 --- /dev/null +++ b/src/renderer/src/components/skills/SkillFreshnessNudge.test.tsx @@ -0,0 +1,230 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + SkillFreshnessInstallation, + SkillFreshnessInventory +} from '../../../../shared/skill-freshness' +import { SkillFreshnessNudge } from './SkillFreshnessNudge' + +const mocks = vi.hoisted(() => ({ + dismissed: [] as string[], + updateSettings: vi.fn(), + toastInfo: vi.fn(), + toastDismiss: vi.fn(), + requestDialog: vi.fn(), + settingsLoaded: true, + inventory: null as SkillFreshnessInventory | null, + error: null as string | null +})) + +function placement( + overrides: Partial = {} +): SkillFreshnessInstallation { + return { + id: 'orca-cli', + name: 'orca-cli', + rootId: 'home-agents', + providers: ['agent-skills'], + sourceKind: 'home', + sourceLabel: 'Agent skills home', + unresolvedPath: '/home/.agents/skills/orca-cli', + resolvedPath: '/home/.agents/skills/orca-cli', + physicalIdentity: 'physical-orca-cli', + topology: 'canonical-copy', + status: 'outdated', + installedReleaseRevision: 1, + installedAppVersion: '1.0.0', + currentReleaseRevision: 2, + currentPackageDigest: 'current', + currentAppVersion: '2.0.0', + observedPackageDigest: 'old', + errorCategory: null, + ...overrides + } +} + +function eligibleInventory(): SkillFreshnessInventory { + return { + schemaVersion: 1, + installations: [placement()], + eligibleUpdateNames: ['orca-cli'], + scannedAt: 1 + } +} + +vi.mock('@/hooks/useSkillFreshness', () => ({ + useSkillFreshness: () => ({ + inventory: mocks.inventory, + loading: false, + error: mocks.error, + refresh: vi.fn() + }) +})) + +vi.mock('sonner', () => ({ + toast: { info: mocks.toastInfo, dismiss: mocks.toastDismiss } +})) + +vi.mock('./skill-freshness-update-dialog', () => ({ + requestSkillFreshnessUpdateDialog: mocks.requestDialog +})) + +vi.mock('@/store', () => { + const state = () => ({ + settings: mocks.settingsLoaded ? { dismissedSkillFreshnessNudges: mocks.dismissed } : null, + updateSettings: mocks.updateSettings + }) + const useAppStore = (selector: (value: ReturnType) => unknown) => selector(state()) + useAppStore.getState = state + return { useAppStore } +}) + +let root: Root | null = null +let container: HTMLDivElement | null = null + +async function renderNudge(): Promise { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + await act(async () => { + root?.render() + }) +} + +async function rerenderNudge(): Promise { + await act(async () => { + root?.render() + }) +} + +const DISMISSAL_KEY = ['physical-orca-cli', 'orca-cli', '2'].join('\0') + +describe('SkillFreshnessNudge', () => { + beforeEach(() => { + mocks.dismissed = [] + mocks.settingsLoaded = true + mocks.inventory = eligibleInventory() + mocks.error = null + mocks.updateSettings.mockReset() + mocks.updateSettings.mockResolvedValue(undefined) + mocks.toastInfo.mockReset() + mocks.toastInfo.mockReturnValue('freshness-toast') + mocks.toastDismiss.mockReset() + mocks.requestDialog.mockReset() + }) + + afterEach(async () => { + if (root) { + await act(async () => root?.unmount()) + } + root = null + container?.remove() + container = null + }) + + it('lingers without an auto-close timer or auto-close dismissal write', async () => { + await renderNudge() + + expect(mocks.toastInfo).toHaveBeenCalledTimes(1) + const options = mocks.toastInfo.mock.calls[0]?.[1] + expect(options.duration).toBe(Number.POSITIVE_INFINITY) + expect(options.onAutoClose).toBeUndefined() + expect(mocks.updateSettings).not.toHaveBeenCalled() + }) + + it('opens the update dialog on action click without persisting a dismissal', async () => { + await renderNudge() + + const options = mocks.toastInfo.mock.calls[0]?.[1] + options.action.onClick() + options.onDismiss() + + expect(mocks.requestDialog).toHaveBeenCalledTimes(1) + expect(mocks.updateSettings).not.toHaveBeenCalled() + }) + + it('retracts a resolved nudge without recording a dismissal', async () => { + await renderNudge() + const options = mocks.toastInfo.mock.calls[0]?.[1] + + mocks.inventory = { + schemaVersion: 1, + installations: [placement({ status: 'current', observedPackageDigest: 'current' })], + eligibleUpdateNames: [], + scannedAt: 2 + } + await rerenderNudge() + // Sonner invokes onDismiss for programmatic dismissals on its next render. + options.onDismiss() + + expect(mocks.toastDismiss).toHaveBeenCalledWith('freshness-toast') + expect(mocks.updateSettings).not.toHaveBeenCalled() + }) + + it('retracts a stale nudge when re-inventory fails', async () => { + await renderNudge() + const options = mocks.toastInfo.mock.calls[0]?.[1] + + mocks.inventory = null + mocks.error = 'scan failed' + await rerenderNudge() + options.onDismiss() + + expect(mocks.toastDismiss).toHaveBeenCalledWith('freshness-toast') + expect(mocks.updateSettings).not.toHaveBeenCalled() + }) + + it('persists the exact placement/revision key once on explicit dismissal', async () => { + await renderNudge() + + const options = mocks.toastInfo.mock.calls[0]?.[1] + options.onDismiss() + options.onDismiss() + + expect(mocks.updateSettings).toHaveBeenCalledTimes(1) + expect(mocks.updateSettings).toHaveBeenCalledWith({ + dismissedSkillFreshnessNudges: [DISMISSAL_KEY] + }) + }) + + it('does not repeat the same nudge within a session', async () => { + await renderNudge() + // A fresh inventory object with identical content re-runs the effect. + mocks.inventory = eligibleInventory() + await rerenderNudge() + + expect(mocks.toastInfo).toHaveBeenCalledTimes(1) + }) + + it('does not repeat a nudge for an already dismissed exact tuple', async () => { + mocks.dismissed = [DISMISSAL_KEY] + + await renderNudge() + + expect(mocks.toastInfo).not.toHaveBeenCalled() + }) + + it('does not nudge for a poisoned name with no eligible update', async () => { + mocks.inventory = { + schemaVersion: 1, + installations: [placement(), placement({ id: 'repo-copy', topology: 'repo-scope' })], + eligibleUpdateNames: [], + scannedAt: 1 + } + + await renderNudge() + + expect(mocks.toastInfo).not.toHaveBeenCalled() + }) + + it('waits for persisted settings before deciding whether to nudge', async () => { + mocks.settingsLoaded = false + + await renderNudge() + + expect(mocks.toastInfo).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/skills/SkillFreshnessNudge.tsx b/src/renderer/src/components/skills/SkillFreshnessNudge.tsx new file mode 100644 index 000000000000..135d860656ba --- /dev/null +++ b/src/renderer/src/components/skills/SkillFreshnessNudge.tsx @@ -0,0 +1,183 @@ +import { useEffect, useRef } from 'react' +import { Terminal } from 'lucide-react' +import { toast } from 'sonner' +import { useSkillFreshness } from '@/hooks/useSkillFreshness' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import { requestSkillFreshnessUpdateDialog } from './skill-freshness-update-dialog' + +const MAX_DISMISSED_FRESHNESS_NUDGES = 512 +const NO_DISMISSED_FRESHNESS_NUDGES: string[] = [] + +type ActiveFreshnessNudge = { + id: string | number + fingerprint: string + persistDismissal: boolean +} + +function candidateKey(args: { + physicalIdentity: string + name: string + currentReleaseRevision: number +}): string { + return [args.physicalIdentity, args.name, args.currentReleaseRevision].join('\0') +} + +export function SkillFreshnessNudge(): null { + const state = useSkillFreshness() + const settingsLoaded = useAppStore((store) => store.settings !== null) + const dismissed = useAppStore( + (store) => store.settings?.dismissedSkillFreshnessNudges ?? NO_DISMISSED_FRESHNESS_NUDGES + ) + const updateSettings = useAppStore((store) => store.updateSettings) + const shownFingerprints = useRef(new Set()) + const persistedFingerprints = useRef(new Set()) + const activeNudgeRef = useRef(null) + + useEffect(() => { + const inventory = state.inventory + if (!settingsLoaded) { + return + } + if (!inventory) { + const active = activeNudgeRef.current + if (state.error && active) { + // Why: a failed re-check cannot keep advertising authority derived from + // old bytes; retract without turning the scan failure into a dismissal. + active.persistDismissal = false + activeNudgeRef.current = null + toast.dismiss(active.id) + } + return + } + const eligibleNames = new Set(inventory.eligibleUpdateNames) + const candidates = inventory.installations.flatMap((installation) => + installation.status === 'outdated' && + eligibleNames.has(installation.name) && + installation.physicalIdentity + ? [ + { + key: candidateKey({ + physicalIdentity: installation.physicalIdentity, + name: installation.name, + currentReleaseRevision: installation.currentReleaseRevision + }), + name: installation.name + } + ] + : [] + ) + const dismissedKeys = new Set(dismissed) + const unseen = candidates.filter((candidate) => !dismissedKeys.has(candidate.key)) + if (unseen.length === 0) { + const active = activeNudgeRef.current + if (active) { + // Why: a resolved/replaced nudge is stale presentation, not an explicit + // user dismissal, so retract it without persisting its tuple keys. + active.persistDismissal = false + activeNudgeRef.current = null + toast.dismiss(active.id) + } + return + } + const fingerprint = unseen + .map((candidate) => candidate.key) + .sort((left, right) => left.localeCompare(right, 'en')) + .join('\n') + const active = activeNudgeRef.current + if (active?.fingerprint === fingerprint) { + return + } + if (active) { + active.persistDismissal = false + activeNudgeRef.current = null + toast.dismiss(active.id) + } + if (shownFingerprints.current.has(fingerprint)) { + return + } + shownFingerprints.current.add(fingerprint) + + const persistDismissal = (): void => { + if (persistedFingerprints.current.has(fingerprint)) { + return + } + persistedFingerprints.current.add(fingerprint) + const current = useAppStore.getState().settings?.dismissedSkillFreshnessNudges ?? [] + const next = [...new Set([...current, ...unseen.map((candidate) => candidate.key)])].slice( + -MAX_DISMISSED_FRESHNESS_NUDGES + ) + void updateSettings({ dismissedSkillFreshnessNudges: next }).catch(() => { + persistedFingerprints.current.delete(fingerprint) + }) + } + const names = new Set(unseen.map((candidate) => candidate.name)) + // Why: name the outdated skills so the nudge is actionable without opening + // the modal; the sentence is translatable but the identifiers interpolate as-is. + const outdatedNames = [...names] + .sort((left, right) => left.localeCompare(right, 'en')) + .join(', ') + const nextActive: ActiveFreshnessNudge = { + id: '', + fingerprint, + persistDismissal: true + } + nextActive.id = toast.info( + names.size === 1 + ? translate( + 'auto.components.skills.SkillFreshnessNudge.titleOne', + 'An installed Orca skill is out of date' + ) + : translate( + 'auto.components.skills.SkillFreshnessNudge.titleMany', + '{{value0}} installed Orca skills are out of date', + { value0: names.size } + ), + { + description: translate( + 'auto.components.skills.SkillFreshnessNudge.description', + 'Update {{value0}} so agents follow the current instructions for this version of Orca.', + { value0: outdatedNames } + ), + // Why: the nudge lingers until the user acts. Ignoring it (app quit) + // records nothing, so a still-outdated skill may prompt once next launch. + duration: Number.POSITIVE_INFINITY, + // Why: only an explicit dismissal (the close button) records the keys; + // opening the review dialog is engagement, not a decision to hide it. + onDismiss: () => { + if (nextActive.persistDismissal) { + persistDismissal() + } + if (activeNudgeRef.current === nextActive) { + activeNudgeRef.current = null + } + }, + action: { + label: ( + + + {names.size === 1 + ? translate('auto.components.skills.SkillFreshnessNudge.updateOne', 'Update skill') + : translate( + 'auto.components.skills.SkillFreshnessNudge.updateMany', + 'Update skills' + )} + + ), + onClick: () => { + // Sonner closes action toasts without onDismiss; clear ownership so + // a later inventory cannot treat the already-closed toast as active. + nextActive.persistDismissal = false + if (activeNudgeRef.current === nextActive) { + activeNudgeRef.current = null + } + requestSkillFreshnessUpdateDialog() + } + } + } + ) + activeNudgeRef.current = nextActive + }, [dismissed, settingsLoaded, state.error, state.inventory, updateSettings]) + + return null +} diff --git a/src/renderer/src/components/skills/SkillFreshnessStatusPill.test.tsx b/src/renderer/src/components/skills/SkillFreshnessStatusPill.test.tsx new file mode 100644 index 000000000000..785b67aaf07d --- /dev/null +++ b/src/renderer/src/components/skills/SkillFreshnessStatusPill.test.tsx @@ -0,0 +1,107 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { SkillFreshnessInventory } from '../../../../shared/skill-freshness' +import { SkillFreshnessStatusPill } from './SkillFreshnessStatusPill' + +const mocks = vi.hoisted(() => ({ + inventory: null as SkillFreshnessInventory | null +})) + +vi.mock('@/hooks/useSkillFreshness', () => ({ + useSkillFreshness: () => ({ + inventory: mocks.inventory, + loading: false, + error: null, + refresh: vi.fn() + }) +})) + +function inventory( + entries: { name: string; status: 'current' | 'outdated' | 'unrecognized' }[], + eligibleUpdateNames: string[] +): SkillFreshnessInventory { + return { + schemaVersion: 1, + installations: entries.map((entry, index) => ({ + id: `${entry.name}-${index}`, + name: entry.name, + rootId: 'home-agents', + providers: ['agent-skills'], + sourceKind: 'home', + sourceLabel: 'Agent skills home', + unresolvedPath: `/home/.agents/skills/${entry.name}`, + resolvedPath: `/home/.agents/skills/${entry.name}`, + physicalIdentity: `physical-${entry.name}-${index}`, + topology: 'canonical-copy', + status: entry.status, + installedReleaseRevision: 1, + installedAppVersion: '1.0.0', + currentReleaseRevision: 2, + currentPackageDigest: 'current', + currentAppVersion: '2.0.0', + observedPackageDigest: 'old', + errorCategory: null + })), + eligibleUpdateNames, + scannedAt: 1 + } +} + +let root: Root | null = null +let container: HTMLDivElement | null = null + +async function renderPill(skillName: string): Promise { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + await act(async () => { + root?.render() + }) + return container +} + +describe('SkillFreshnessStatusPill', () => { + beforeEach(() => { + mocks.inventory = null + }) + + afterEach(async () => { + if (root) { + await act(async () => root?.unmount()) + } + root = null + container?.remove() + container = null + }) + + it('shows Update available for an eligible outdated skill', async () => { + mocks.inventory = inventory([{ name: 'orca-cli', status: 'outdated' }], ['orca-cli']) + + expect((await renderPill('orca-cli')).textContent).toBe('Update available') + }) + + it('shows Up to date when every placement is current', async () => { + mocks.inventory = inventory([{ name: 'orca-cli', status: 'current' }], []) + + expect((await renderPill('orca-cli')).textContent).toBe('Up to date') + }) + + it('falls back to Installed for a blocked outdated placement', async () => { + mocks.inventory = inventory( + [ + { name: 'orca-cli', status: 'outdated' }, + { name: 'orca-cli', status: 'unrecognized' } + ], + [] + ) + + expect((await renderPill('orca-cli')).textContent).toBe('Installed') + }) + + it('falls back to Installed before the inventory loads', async () => { + expect((await renderPill('orca-cli')).textContent).toBe('Installed') + }) +}) diff --git a/src/renderer/src/components/skills/SkillFreshnessStatusPill.tsx b/src/renderer/src/components/skills/SkillFreshnessStatusPill.tsx new file mode 100644 index 000000000000..3c8586865911 --- /dev/null +++ b/src/renderer/src/components/skills/SkillFreshnessStatusPill.tsx @@ -0,0 +1,40 @@ +import { useSkillFreshness } from '@/hooks/useSkillFreshness' +import { translate } from '@/i18n/i18n' +import { IntegrationStatusPill } from '@/components/integration-status-pill' + +// Why: the setup rails' Installed pill is presence-only; when freshness knows a +// safe update exists (or that every copy is current) the pill should say so. +// Falls back to plain Installed for blocked/unrecognized copies so an unsafe +// placement is never advertised as updatable here. +export function SkillFreshnessStatusPill({ skillName }: { skillName: string }): React.JSX.Element { + const { inventory } = useSkillFreshness() + if (inventory?.eligibleUpdateNames.includes(skillName)) { + return ( + + {translate( + 'auto.components.skills.SkillFreshnessStatusPill.updateAvailable', + 'Update available' + )} + + ) + } + const placements = inventory?.installations.filter( + (installation) => installation.name === skillName + ) + if ( + placements && + placements.length > 0 && + placements.every((installation) => installation.status === 'current') + ) { + return ( + + {translate('auto.components.skills.SkillFreshnessStatusPill.upToDate', 'Up to date')} + + ) + } + return ( + + {translate('auto.components.skills.SkillFreshnessStatusPill.installed', 'Installed')} + + ) +} diff --git a/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx b/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx new file mode 100644 index 000000000000..a810ae06cc61 --- /dev/null +++ b/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx @@ -0,0 +1,371 @@ +// @vitest-environment happy-dom + +import { act, useState, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + SkillFreshnessInstallation, + SkillFreshnessInventory +} from '../../../../shared/skill-freshness' +import { SkillFreshnessUpdateDialog } from './SkillFreshnessUpdateDialog' +import { + consumeSkillFreshnessUpdateDialogRequest, + requestSkillFreshnessUpdateDialog +} from './skill-freshness-update-dialog' + +const mocks = vi.hoisted(() => ({ + inventory: null as SkillFreshnessInventory | null, + loading: false, + error: null as string | null, + refresh: vi.fn(), + terminalProps: [] as { + command: string + description: string + onInteracted?: (method: 'keyboard' | 'pointer', event?: { key?: string }) => void + onTerminalExit?: () => void + }[], + notifyChanged: vi.fn() +})) + +vi.mock('@/hooks/useSkillFreshness', () => ({ + useSkillFreshness: () => ({ + inventory: mocks.inventory, + loading: mocks.loading, + error: mocks.error, + refresh: mocks.refresh + }) +})) + +vi.mock('@/hooks/useInstalledAgentSkills', () => ({ + notifyInstalledAgentSkillsChanged: mocks.notifyChanged +})) + +vi.mock('@/components/onboarding/OnboardingInlineCommandTerminal', () => ({ + OnboardingInlineCommandTerminal: (props: (typeof mocks.terminalProps)[number]) => { + mocks.terminalProps.push(props) + return
{props.command}
+ } +})) + +// Radix Dialog/Collapsible internals (portal, focus-scope) are exercised in +// Electron QA; here the content logic is what matters, so use plain wrappers. +vi.mock('@/components/ui/dialog', () => ({ + Dialog: ({ open, children }: { open: boolean; children?: ReactNode }) => + open ?
{children}
: null, + DialogContent: ({ children }: { children?: ReactNode }) =>
{children}
, + DialogDescription: ({ children }: { children?: ReactNode }) =>

{children}

, + DialogFooter: ({ children }: { children?: ReactNode }) =>
{children}
, + DialogHeader: ({ children }: { children?: ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children?: ReactNode }) =>

{children}

+})) + +vi.mock('@/components/ui/collapsible', () => ({ + Collapsible: ({ + children, + defaultOpen = false + }: { + children?: ReactNode + defaultOpen?: boolean + }) => { + // Model Radix's uncontrolled default: changing defaultOpen only matters + // when the keyed disclosure remounts. + const [open] = useState(defaultOpen) + return
{children}
+ }, + CollapsibleTrigger: ({ children }: { children?: ReactNode }) =>
{children}
, + CollapsibleContent: ({ children }: { children?: ReactNode }) =>
{children}
+})) + +function placement( + name: string, + overrides: Partial = {} +): SkillFreshnessInstallation { + return { + id: `${name}-${overrides.rootId ?? 'home-agents'}`, + name, + rootId: 'home-agents', + providers: ['agent-skills'], + sourceKind: 'home', + sourceLabel: 'Agent skills home', + unresolvedPath: `/home/.agents/skills/${name}`, + resolvedPath: `/home/.agents/skills/${name}`, + physicalIdentity: `physical-${name}`, + topology: 'canonical-copy', + status: 'outdated', + installedReleaseRevision: 1, + installedAppVersion: '1.0.0', + currentReleaseRevision: 2, + currentPackageDigest: 'current', + currentAppVersion: '2.0.0', + observedPackageDigest: 'old', + errorCategory: null, + ...overrides + } +} + +function eligibleInventory(): SkillFreshnessInventory { + return { + schemaVersion: 1, + installations: [placement('orca-cli')], + eligibleUpdateNames: ['orca-cli'], + scannedAt: 1 + } +} + +let root: Root | null = null +let container: HTMLDivElement | null = null + +async function renderDialog(): Promise { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + await act(async () => { + root?.render() + }) +} + +async function rerender(): Promise { + await act(async () => { + root?.render() + }) +} + +async function openViaRequest(): Promise { + await act(async () => { + requestSkillFreshnessUpdateDialog() + }) +} + +async function clickButton(label: string): Promise { + const button = Array.from(container?.querySelectorAll('button') ?? []).find( + (candidate) => candidate.textContent?.trim() === label + ) + expect(button).toBeDefined() + await act(async () => { + button?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) +} + +describe('SkillFreshnessUpdateDialog', () => { + beforeEach(() => { + consumeSkillFreshnessUpdateDialogRequest() + mocks.inventory = eligibleInventory() + mocks.loading = false + mocks.error = null + mocks.refresh.mockReset() + mocks.notifyChanged.mockReset() + mocks.terminalProps.length = 0 + }) + + afterEach(async () => { + if (root) { + await act(async () => root?.unmount()) + } + root = null + container?.remove() + container = null + }) + + it('stays closed until an open request arrives', async () => { + await renderDialog() + expect(container?.querySelector('[data-dialog-open]')).toBeNull() + }) + + it('shows the eligible summary and the exact pre-filled draft command', async () => { + await renderDialog() + await openViaRequest() + + expect(container?.textContent).toContain('Update skills') + expect(container?.textContent).toContain('1 skill can be updated safely') + expect(mocks.terminalProps.at(-1)).toMatchObject({ + command: 'npx skills update orca-cli --global', + description: 'Review the pre-filled command, then press Enter to run it.' + }) + }) + + it('resolves a request made before inventory loads once a safe command arrives', async () => { + mocks.inventory = null + await openViaRequest() + await renderDialog() + + expect(container?.querySelector('[data-dialog-open]')).not.toBeNull() + expect(mocks.terminalProps).toEqual([]) + + mocks.inventory = eligibleInventory() + await rerender() + + expect(mocks.terminalProps.at(-1)?.command).toBe('npx skills update orca-cli --global') + }) + + it('shows the up-to-date state once every installation is current', async () => { + await renderDialog() + await openViaRequest() + + mocks.inventory = { + schemaVersion: 1, + installations: [ + placement('orca-cli', { status: 'current', observedPackageDigest: 'current' }) + ], + eligibleUpdateNames: [], + scannedAt: 2 + } + await rerender() + + expect(container?.textContent).toContain('All installed Orca skills are up to date.') + expect(container?.querySelector('[data-testid="update-terminal"]')).toBeNull() + }) + + it('auto-expands Details when a rescan changes the result to blocked', async () => { + await renderDialog() + await openViaRequest() + expect(container?.querySelector('[data-collapsible-open="false"]')).not.toBeNull() + + mocks.inventory = { + schemaVersion: 1, + installations: [placement('orca-cli', { topology: 'read-only' })], + eligibleUpdateNames: [], + scannedAt: 2 + } + await rerender() + + expect(container?.querySelector('[data-collapsible-open="true"]')).not.toBeNull() + }) + + it('does not replace or tear down a submitted command during a rescan', async () => { + await renderDialog() + await openViaRequest() + + await act(async () => { + mocks.terminalProps.at(-1)?.onInteracted?.('keyboard', { key: 'Enter' }) + }) + mocks.inventory = null + mocks.loading = true + await rerender() + + expect(container?.querySelector('[data-testid="update-terminal"]')?.textContent).toBe( + 'npx skills update orca-cli --global' + ) + + mocks.inventory = { + schemaVersion: 1, + installations: [ + placement('orca-cli', { status: 'current', observedPackageDigest: 'current' }) + ], + eligibleUpdateNames: [], + scannedAt: 2 + } + mocks.loading = false + await rerender() + + expect(container?.textContent).toContain('All installed Orca skills are up to date.') + expect(container?.querySelector('[data-testid="update-terminal"]')?.textContent).toBe( + 'npx skills update orca-cli --global' + ) + }) + + it('removes an unsubmitted draft as soon as its inventory is invalidated', async () => { + await renderDialog() + await openViaRequest() + expect(container?.querySelector('[data-testid="update-terminal"]')).not.toBeNull() + + mocks.inventory = null + mocks.loading = true + await rerender() + + expect(container?.textContent).toContain('Checking installed Orca skills') + expect(container?.querySelector('[data-testid="update-terminal"]')).toBeNull() + }) + + it('shows a failed scan as an error instead of indefinite progress', async () => { + mocks.inventory = null + mocks.error = 'Could not inspect installed skills.' + await renderDialog() + await openViaRequest() + + expect(container?.textContent).toContain('Could not inspect installed skills.') + expect(container?.textContent).not.toContain('Checking installed Orca skills') + expect(container?.querySelector('[data-testid="update-terminal"]')).toBeNull() + }) + + it('creates a fresh draft after a terminal exits and the rescan still finds an update', async () => { + await renderDialog() + await openViaRequest() + const firstTerminal = mocks.terminalProps.at(-1) + + await act(async () => { + firstTerminal?.onTerminalExit?.() + }) + expect(mocks.notifyChanged).toHaveBeenCalledTimes(1) + expect(container?.querySelector('[data-testid="update-terminal"]')).toBeNull() + + mocks.inventory = { ...eligibleInventory(), scannedAt: 2 } + await rerender() + + expect(container?.querySelector('[data-testid="update-terminal"]')?.textContent).toBe( + 'npx skills update orca-cli --global' + ) + expect(mocks.terminalProps.at(-1)).not.toBe(firstTerminal) + }) + + it('explains a poisoned sibling in Details without offering a command', async () => { + mocks.inventory = { + schemaVersion: 1, + installations: [ + placement('orca-cli'), + placement('orca-cli', { + id: 'repo-copy', + rootId: 'repo', + sourceKind: 'repo', + topology: 'repo-scope', + status: 'unrecognized', + unresolvedPath: '/repo/.agents/skills/orca-cli' + }) + ], + eligibleUpdateNames: [], + scannedAt: 1 + } + await renderDialog() + await openViaRequest() + + expect(container?.textContent).toContain( + 'it may be modified, or a different skill with the same name' + ) + expect(container?.textContent).toContain('Unrecognized') + expect(container?.querySelector('[data-testid="update-terminal"]')).toBeNull() + }) + + it('explains a self-blocked read-only placement without blaming a sibling', async () => { + mocks.inventory = { + schemaVersion: 1, + installations: [placement('orca-cli', { topology: 'read-only' })], + eligibleUpdateNames: [], + scannedAt: 1 + } + await renderDialog() + await openViaRequest() + + expect(container?.textContent).toContain( + 'read-only location, so Orca left it out of the update' + ) + expect(container?.textContent).toContain('Read only') + }) + + it('re-inventories installed skills when the dialog closes', async () => { + await renderDialog() + await openViaRequest() + + await clickButton('Close') + + expect(mocks.notifyChanged).toHaveBeenCalledTimes(1) + expect(container?.querySelector('[data-dialog-open]')).toBeNull() + }) + + it('forces a re-check from the Re-check affordance', async () => { + await renderDialog() + await openViaRequest() + + await clickButton('Re-check') + + expect(mocks.refresh).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.tsx b/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.tsx new file mode 100644 index 000000000000..0a0e342c467b --- /dev/null +++ b/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.tsx @@ -0,0 +1,310 @@ +import { + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, + type KeyboardEvent +} from 'react' +import { AlertTriangle, CheckCircle2, ChevronDown, Loader2, RefreshCw } from 'lucide-react' +import type { SkillFreshnessInventory } from '../../../../shared/skill-freshness' +import { buildTargetedSkillUpdateCommand } from '../../../../shared/skill-freshness' +import { useSkillFreshness } from '@/hooks/useSkillFreshness' +import { notifyInstalledAgentSkillsChanged } from '@/hooks/useInstalledAgentSkills' +import { translate } from '@/i18n/i18n' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' +import { OnboardingInlineCommandTerminal } from '@/components/onboarding/OnboardingInlineCommandTerminal' +import { TooltipProvider } from '@/components/ui/tooltip' +import { groupSkillFreshness } from './skill-freshness-grouping' +import { SkillFreshnessGroup } from './skill-freshness-group' +import { + consumeSkillFreshnessUpdateDialogRequest, + getSkillFreshnessUpdateDialogRequest, + subscribeSkillFreshnessUpdateDialog +} from './skill-freshness-update-dialog' + +type FreshnessSummaryKind = 'loading' | 'empty' | 'eligible' | 'current' | 'attention' + +function summarizeInventory( + inventory: SkillFreshnessInventory | null, + hasBlockedGroup: boolean +): FreshnessSummaryKind { + if (!inventory) { + return 'loading' + } + if (inventory.installations.length === 0) { + return 'empty' + } + if (inventory.eligibleUpdateNames.length > 0) { + return 'eligible' + } + // Why: with nothing eligible, the modal is either genuinely all-clear or has + // out-of-date skills it can't safely update; the group filter already dropped + // the up-to-date and unrecognized-only noise, so a blocked group is the signal. + return hasBlockedGroup ? 'attention' : 'current' +} + +function SummaryHeadline({ + kind, + eligibleCount +}: { + kind: FreshnessSummaryKind + eligibleCount: number +}): React.JSX.Element { + if (kind === 'loading') { + return ( +
+ + {translate( + 'auto.components.skills.SkillFreshnessUpdateDialog.checking', + 'Checking installed Orca skills…' + )} +
+ ) + } + if (kind === 'empty') { + return ( +

+ {translate( + 'auto.components.skills.SkillFreshnessUpdateDialog.none', + 'No installed Orca skills found.' + )} +

+ ) + } + if (kind === 'current') { + return ( +
+ + {translate( + 'auto.components.skills.SkillFreshnessUpdateDialog.success', + 'All installed Orca skills are up to date.' + )} +
+ ) + } + if (kind === 'attention') { + return ( +
+
+ + {translate( + 'auto.components.skills.SkillFreshnessUpdateDialog.attention', + 'Some installed Orca skills were left out of the update.' + )} +
+

+ {translate( + 'auto.components.skills.SkillFreshnessUpdateDialog.attentionDescription', + 'Open Update details to see why each one was skipped.' + )} +

+
+ ) + } + return ( +
+

+ {eligibleCount === 1 + ? translate( + 'auto.components.skills.SkillFreshnessUpdateDialog.updateOne', + '1 skill can be updated safely' + ) + : translate( + 'auto.components.skills.SkillFreshnessUpdateDialog.updateMany', + '{{value0}} skills can be updated safely', + { value0: eligibleCount } + )} +

+
+ ) +} + +export function SkillFreshnessUpdateDialog(): React.JSX.Element { + const state = useSkillFreshness() + const open = useSyncExternalStore( + subscribeSkillFreshnessUpdateDialog, + getSkillFreshnessUpdateDialogRequest, + getSkillFreshnessUpdateDialogRequest + ) + const [terminalCommand, setTerminalCommand] = useState(null) + const [awaitingExitRefresh, setAwaitingExitRefresh] = useState(false) + const terminalSubmittedRef = useRef(false) + const inventoryAtTerminalExitRef = useRef(null) + const inventory = state.inventory + const eligibleNames = useMemo(() => inventory?.eligibleUpdateNames ?? [], [inventory]) + const groups = useMemo( + () => + inventory ? groupSkillFreshness(inventory.installations, inventory.eligibleUpdateNames) : [], + [inventory] + ) + const hasBlockedGroup = groups.some((group) => group.status === 'cannot-update') + const updateCommand = buildTargetedSkillUpdateCommand(eligibleNames) + const summaryKind = summarizeInventory(inventory, hasBlockedGroup) + + useEffect(() => { + if (!open) { + return + } + if (state.loading || state.error || !inventory) { + // Why: a scan invalidates the authorization behind an unsubmitted draft. + // A running command keeps its PTY until exit, but stale drafts fail closed. + if (!terminalSubmittedRef.current && terminalCommand !== null) { + setTerminalCommand(null) + } + return + } + if (awaitingExitRefresh) { + if (inventory === inventoryAtTerminalExitRef.current) { + return + } + inventoryAtTerminalExitRef.current = null + setAwaitingExitRefresh(false) + return + } + if (terminalSubmittedRef.current || terminalCommand === updateCommand) { + return + } + // Why: changing the shared onboarding terminal's command pastes it again. + // Replace only an unsubmitted draft; a submitted command owns its PTY until exit. + setTerminalCommand(updateCommand) + }, [ + awaitingExitRefresh, + inventory, + open, + state.error, + state.loading, + terminalCommand, + updateCommand + ]) + + const handleOpenChange = (next: boolean): void => { + // Why: closing is the natural point to re-observe bytes so a completed update + // clears the state and the lingering nudge does not fire again. + if (!next) { + consumeSkillFreshnessUpdateDialogRequest() + terminalSubmittedRef.current = false + inventoryAtTerminalExitRef.current = null + setAwaitingExitRefresh(false) + setTerminalCommand(null) + notifyInstalledAgentSkillsChanged() + } + } + + const handleTerminalInteraction = ( + method: 'keyboard' | 'pointer', + event?: KeyboardEvent + ): void => { + if (method === 'keyboard' && event?.key === 'Enter') { + terminalSubmittedRef.current = true + } + } + + const handleTerminalExit = (): void => { + terminalSubmittedRef.current = false + inventoryAtTerminalExitRef.current = inventory + setAwaitingExitRefresh(true) + setTerminalCommand(null) + notifyInstalledAgentSkillsChanged() + } + + const hasVisibleGroups = groups.length > 0 + + return ( + + + + + {translate('auto.components.skills.SkillFreshnessUpdateDialog.title', 'Update skills')} + + + + {state.error ? ( +

{state.error}

+ ) : ( + + )} + + {terminalCommand ? ( + + ) : null} + + {/* Why: Radix reads defaultOpen only on mount. Remount when a scan + becomes blocked so the required diagnostic details actually open. */} + {hasVisibleGroups ? ( + + + + + + + {groups.map((group) => ( + + ))} + + + + ) : null} + + + + + +
+
+ ) +} diff --git a/src/renderer/src/components/skills/skill-freshness-group.tsx b/src/renderer/src/components/skills/skill-freshness-group.tsx new file mode 100644 index 000000000000..c6dac96479bc --- /dev/null +++ b/src/renderer/src/components/skills/skill-freshness-group.tsx @@ -0,0 +1,203 @@ +import type { + SkillFreshnessGroupModel, + SkillLocationChip, + SkillLocationRow +} from './skill-freshness-grouping' +import { translate } from '@/i18n/i18n' +import { Badge } from '@/components/ui/badge' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' + +function chipLabel(chip: SkillLocationChip): string { + switch (chip) { + case 'current': + return translate('auto.components.skills.SkillFreshnessRow.chipCurrent', 'Current') + case 'unrecognized': + return translate('auto.components.skills.SkillFreshnessRow.chipUnrecognized', 'Unrecognized') + case 'inaccessible': + return translate('auto.components.skills.SkillFreshnessRow.chipInaccessible', 'Inaccessible') + case 'duplicate': + return translate('auto.components.skills.SkillFreshnessRow.chipDuplicate', 'Duplicate') + case 'external-link': + return translate('auto.components.skills.SkillFreshnessRow.chipExternalLink', 'External link') + case 'broken-link': + return translate('auto.components.skills.SkillFreshnessRow.chipBrokenLink', 'Broken link') + case 'read-only': + return translate('auto.components.skills.SkillFreshnessRow.chipReadOnly', 'Read only') + case 'in-a-repo': + return translate('auto.components.skills.SkillFreshnessRow.chipInRepo', 'In a repo') + case 'plugin-cache': + return translate('auto.components.skills.SkillFreshnessRow.chipPluginCache', 'Plugin cache') + } +} + +// Why: chips describe only what a location *is*; the effect on the update +// command lives in the per-skill sentence, so the two never say it twice. +function chipTooltip(chip: SkillLocationChip): string { + switch (chip) { + case 'current': + return translate( + 'auto.components.skills.SkillFreshnessRow.tipCurrent', + 'This copy matches the current official version.' + ) + case 'unrecognized': + return translate( + 'auto.components.skills.SkillFreshnessRow.tipUnrecognized', + 'This copy doesn’t match any official version — it may be modified, or a different skill with the same name.' + ) + case 'inaccessible': + return translate( + 'auto.components.skills.SkillFreshnessRow.tipInaccessible', + 'Orca couldn’t read this copy (a permissions or file error).' + ) + case 'duplicate': + return translate( + 'auto.components.skills.SkillFreshnessRow.tipDuplicate', + 'A separate copy of this skill, installed apart from the main one.' + ) + case 'external-link': + return translate( + 'auto.components.skills.SkillFreshnessRow.tipExternalLink', + 'A shortcut pointing outside Orca’s skill folders.' + ) + case 'broken-link': + return translate( + 'auto.components.skills.SkillFreshnessRow.tipBrokenLink', + 'A shortcut to something that no longer exists.' + ) + case 'read-only': + return translate( + 'auto.components.skills.SkillFreshnessRow.tipReadOnly', + 'This copy is in a read-only location.' + ) + case 'in-a-repo': + return translate( + 'auto.components.skills.SkillFreshnessRow.tipInRepo', + 'This copy lives inside a project, not your global skills.' + ) + case 'plugin-cache': + return translate( + 'auto.components.skills.SkillFreshnessRow.tipPluginCache', + 'This copy is managed by a plugin.' + ) + } +} + +// Why: a skill is skipped for one concrete reason; lead with the highest-priority +// blocking placement so the sentence explains the real cause (an edited copy is +// more useful to surface than a downstream symptom). +const SKIPPED_REASON_PRIORITY: SkillLocationChip[] = [ + 'unrecognized', + 'read-only', + 'inaccessible', + 'in-a-repo', + 'plugin-cache', + 'external-link', + 'broken-link' +] + +function skippedReason(locations: readonly SkillLocationRow[]): string { + const present = new Set(locations.map((location) => location.chip)) + const chip = SKIPPED_REASON_PRIORITY.find((candidate) => present.has(candidate)) + switch (chip) { + case 'unrecognized': + return translate( + 'auto.components.skills.SkillFreshnessRow.skippedReasonUnrecognized', + 'The copy here doesn’t match the official version — it may be modified, or a different skill with the same name. Orca left it out of the update so it won’t overwrite it. Remove it if you want Orca to update this skill.' + ) + case 'read-only': + return translate( + 'auto.components.skills.SkillFreshnessRow.skippedReasonReadOnly', + 'This copy is in a read-only location, so Orca left it out of the update. Change its permissions to let Orca update it.' + ) + case 'inaccessible': + return translate( + 'auto.components.skills.SkillFreshnessRow.skippedReasonInaccessible', + 'Orca couldn’t read this copy, so it left the skill out of the update.' + ) + case 'in-a-repo': + return translate( + 'auto.components.skills.SkillFreshnessRow.skippedReasonInRepo', + 'This is a project skill, not a global one — Orca only updates your global skills, so it left this out of the update.' + ) + case 'plugin-cache': + return translate( + 'auto.components.skills.SkillFreshnessRow.skippedReasonPluginCache', + 'A plugin manages this skill, so Orca left it out of the update — update the plugin instead.' + ) + case 'external-link': + return translate( + 'auto.components.skills.SkillFreshnessRow.skippedReasonExternalLink', + 'This copy is a shortcut pointing outside Orca’s skill folders, so Orca left it out of the update.' + ) + case 'broken-link': + return translate( + 'auto.components.skills.SkillFreshnessRow.skippedReasonBrokenLink', + 'This copy is a shortcut to something that no longer exists, so Orca left it out — you can safely delete it.' + ) + default: + return translate( + 'auto.components.skills.SkillFreshnessRow.cantUpdateReason', + 'Orca left this skill out of the update command.' + ) + } +} + +export function SkillFreshnessGroup({ + group +}: { + group: SkillFreshnessGroupModel +}): React.JSX.Element { + const isBlocked = group.status === 'cannot-update' + return ( +
+
+ {group.name} + {isBlocked ? ( + + {translate('auto.components.skills.SkillFreshnessRow.statusCantUpdate', 'Skipped')} + + ) : ( + + {translate( + 'auto.components.skills.SkillFreshnessRow.statusUpdateAvailable', + 'Update available' + )} + + )} +
+ {isBlocked ? ( +

{skippedReason(group.locations)}

+ ) : null} +
+ {group.locations.map((location) => ( +
+ + {location.path} + + {location.chip ? ( + + + + {chipLabel(location.chip)} + + + + {chipTooltip(location.chip)} + + + ) : null} +
+ ))} +
+
+ ) +} diff --git a/src/renderer/src/components/skills/skill-freshness-grouping.test.ts b/src/renderer/src/components/skills/skill-freshness-grouping.test.ts new file mode 100644 index 000000000000..03368ec07bf1 --- /dev/null +++ b/src/renderer/src/components/skills/skill-freshness-grouping.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest' +import type { SkillFreshnessInstallation } from '../../../../shared/skill-freshness' +import { groupSkillFreshness } from './skill-freshness-grouping' + +function placement( + name: string, + overrides: Partial = {} +): SkillFreshnessInstallation { + return { + id: `${name}-${overrides.rootId ?? 'home-agents'}-${overrides.unresolvedPath ?? 'a'}`, + name, + rootId: 'home-agents', + providers: ['agent-skills'], + sourceKind: 'home', + sourceLabel: 'Agent skills home', + unresolvedPath: `/home/.agents/skills/${name}`, + resolvedPath: `/home/.agents/skills/${name}`, + physicalIdentity: `physical-${name}`, + topology: 'canonical-copy', + status: 'outdated', + installedReleaseRevision: 1, + installedAppVersion: '1.0.0', + currentReleaseRevision: 2, + currentPackageDigest: 'current', + currentAppVersion: '2.0.0', + observedPackageDigest: 'old', + errorCategory: null, + ...overrides + } +} + +describe('groupSkillFreshness', () => { + it('marks an eligible outdated skill as update-available with one location', () => { + const groups = groupSkillFreshness([placement('orca-cli')], ['orca-cli']) + expect(groups).toHaveLength(1) + expect(groups[0]).toMatchObject({ name: 'orca-cli', status: 'update-available' }) + expect(groups[0]?.locations).toEqual([ + { id: expect.any(String), path: '/home/.agents/skills/orca-cli', chip: null } + ]) + }) + + it('hides skills with nothing out of date (current, unrecognized-only, unreadable-only)', () => { + const groups = groupSkillFreshness( + [ + placement('orca-cli', { status: 'current' }), + placement('dataviz', { status: 'unrecognized', topology: 'independent-copy' }), + placement('linear-tickets', { status: 'inaccessible' }) + ], + [] + ) + expect(groups).toEqual([]) + }) + + it('groups a blocked skill and flags the culprit location, not the main copy', () => { + const groups = groupSkillFreshness( + [ + placement('orchestration'), + placement('orchestration', { + rootId: 'home-claude', + unresolvedPath: '/home/.claude/skills/orchestration', + status: 'unrecognized', + topology: 'independent-copy' + }) + ], + [] + ) + expect(groups).toHaveLength(1) + expect(groups[0]?.status).toBe('cannot-update') + // Why: the out-of-date main copy is bare; only the poisoning copy carries a chip. + expect(groups[0]?.locations).toEqual([ + { id: expect.any(String), path: '/home/.agents/skills/orchestration', chip: null }, + { id: expect.any(String), path: '/home/.claude/skills/orchestration', chip: 'unrecognized' } + ]) + }) + + it('prefers a location status over its topology and maps every topology to a chip', () => { + const chipFor = (overrides: Partial): string | null => + groupSkillFreshness( + [placement('s', { status: 'outdated' }), placement('s', overrides)], + ['s'] + )[0]?.locations.find((location) => location.path.includes('culprit'))?.chip ?? null + const at = (path: string, rest: Partial) => ({ + unresolvedPath: `/culprit/${path}`, + ...rest + }) + expect(chipFor(at('a', { status: 'unrecognized', topology: 'independent-copy' }))).toBe( + 'unrecognized' + ) + expect(chipFor(at('b', { status: 'inaccessible', topology: 'read-only' }))).toBe('inaccessible') + expect(chipFor(at('c', { topology: 'independent-copy' }))).toBe('duplicate') + expect(chipFor(at('d', { topology: 'external-link' }))).toBe('external-link') + expect(chipFor(at('e', { topology: 'broken-link' }))).toBe('broken-link') + expect(chipFor(at('f', { topology: 'read-only' }))).toBe('read-only') + expect(chipFor(at('g', { topology: 'repo-scope' }))).toBe('in-a-repo') + expect(chipFor(at('h', { topology: 'plugin-cache' }))).toBe('plugin-cache') + expect(chipFor(at('i', { status: 'current', topology: 'provider-alias' }))).toBe('current') + }) +}) diff --git a/src/renderer/src/components/skills/skill-freshness-grouping.ts b/src/renderer/src/components/skills/skill-freshness-grouping.ts new file mode 100644 index 000000000000..db76800fdfb2 --- /dev/null +++ b/src/renderer/src/components/skills/skill-freshness-grouping.ts @@ -0,0 +1,90 @@ +import type { SkillFreshnessInstallation } from '../../../../shared/skill-freshness' + +export type SkillGroupStatus = 'update-available' | 'cannot-update' + +export type SkillLocationChip = + | 'current' + | 'unrecognized' + | 'inaccessible' + | 'duplicate' + | 'external-link' + | 'broken-link' + | 'read-only' + | 'in-a-repo' + | 'plugin-cache' + +export type SkillLocationRow = { + id: string + path: string + chip: SkillLocationChip | null +} + +export type SkillFreshnessGroupModel = { + name: string + status: SkillGroupStatus + locations: SkillLocationRow[] +} + +function locationChip(installation: SkillFreshnessInstallation): SkillLocationChip | null { + // Why: a location's own status wins over its topology — "the contents don't + // match" is more useful to the user than "it's a duplicate". + if (installation.status === 'unrecognized') { + return 'unrecognized' + } + if (installation.status === 'inaccessible') { + return 'inaccessible' + } + switch (installation.topology) { + case 'independent-copy': + return 'duplicate' + case 'external-link': + return 'external-link' + case 'broken-link': + return 'broken-link' + case 'read-only': + return 'read-only' + case 'repo-scope': + return 'in-a-repo' + case 'plugin-cache': + return 'plugin-cache' + case 'canonical-copy': + case 'provider-alias': + // Why: a supported location only needs a chip when it's already up to date, + // to explain why the update won't touch it; the out-of-date main copy is bare. + return installation.status === 'current' ? 'current' : null + } +} + +/** + * Groups installations by skill for the update modal and derives each skill's + * update disposition. Only skills with an out-of-date official copy are returned — + * up-to-date, unrecognized-only, and unreadable-only skills have nothing to change + * here, so they are omitted entirely. + */ +export function groupSkillFreshness( + installations: readonly SkillFreshnessInstallation[], + eligibleUpdateNames: readonly string[] +): SkillFreshnessGroupModel[] { + const eligible = new Set(eligibleUpdateNames) + const byName = new Map() + for (const installation of installations) { + const entries = byName.get(installation.name) ?? [] + entries.push(installation) + byName.set(installation.name, entries) + } + const groups: SkillFreshnessGroupModel[] = [] + for (const [name, entries] of byName) { + if (!entries.some((entry) => entry.status === 'outdated')) { + continue + } + const locations = entries + .map((entry) => ({ id: entry.id, path: entry.unresolvedPath, chip: locationChip(entry) })) + .sort((left, right) => left.path.localeCompare(right.path, 'en')) + groups.push({ + name, + status: eligible.has(name) ? 'update-available' : 'cannot-update', + locations + }) + } + return groups.sort((left, right) => left.name.localeCompare(right.name, 'en')) +} diff --git a/src/renderer/src/components/skills/skill-freshness-update-dialog.ts b/src/renderer/src/components/skills/skill-freshness-update-dialog.ts new file mode 100644 index 000000000000..14a84fa4ac83 --- /dev/null +++ b/src/renderer/src/components/skills/skill-freshness-update-dialog.ts @@ -0,0 +1,31 @@ +let pendingOpen = false +const listeners = new Set<() => void>() + +// Why: the nudge action can fire before the dialog subscribes. Keeping the +// request as an external snapshot prevents mount ordering from losing it. +export function requestSkillFreshnessUpdateDialog(): void { + pendingOpen = true + for (const listener of listeners) { + listener() + } +} + +export function consumeSkillFreshnessUpdateDialogRequest(): boolean { + const requested = pendingOpen + pendingOpen = false + if (requested) { + for (const listener of listeners) { + listener() + } + } + return requested +} + +export function getSkillFreshnessUpdateDialogRequest(): boolean { + return pendingOpen +} + +export function subscribeSkillFreshnessUpdateDialog(listener: () => void): () => void { + listeners.add(listener) + return () => listeners.delete(listener) +} diff --git a/src/renderer/src/hooks/installed-agent-skills-change-event.ts b/src/renderer/src/hooks/installed-agent-skills-change-event.ts new file mode 100644 index 000000000000..2e11a17ff03e --- /dev/null +++ b/src/renderer/src/hooks/installed-agent-skills-change-event.ts @@ -0,0 +1 @@ +export const INSTALLED_AGENT_SKILLS_CHANGED_EVENT = 'orca:installed-agent-skills-changed' diff --git a/src/renderer/src/hooks/useInstalledAgentSkills.ts b/src/renderer/src/hooks/useInstalledAgentSkills.ts index e06a8a2a2eef..e19bac655b14 100644 --- a/src/renderer/src/hooks/useInstalledAgentSkills.ts +++ b/src/renderer/src/hooks/useInstalledAgentSkills.ts @@ -7,9 +7,9 @@ import type { } from '../../../shared/skills' import { ORCHESTRATION_SKILL_NAME } from '@/lib/agent-feature-install-commands' import { markOrchestrationSetupComplete } from '@/lib/orchestration-setup-state' +import { INSTALLED_AGENT_SKILLS_CHANGED_EVENT } from './installed-agent-skills-change-event' import { useMountedRef } from './useMountedRef' -const INSTALLED_AGENT_SKILLS_CHANGED_EVENT = 'orca:installed-agent-skills-changed' export const GLOBAL_AGENT_SKILL_SOURCE_KINDS = [ 'home' ] as const satisfies readonly SkillSourceKind[] diff --git a/src/renderer/src/hooks/useSkillFreshness.test.tsx b/src/renderer/src/hooks/useSkillFreshness.test.tsx new file mode 100644 index 000000000000..812ad72ffe86 --- /dev/null +++ b/src/renderer/src/hooks/useSkillFreshness.test.tsx @@ -0,0 +1,238 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { SkillFreshnessInventory } from '../../../shared/skill-freshness' +import { + _skillFreshnessCacheForTests, + type SkillFreshnessState, + useSkillFreshness +} from './useSkillFreshness' + +function deferred(): { + promise: Promise + resolve: (value: T) => void + reject: (cause: unknown) => void +} { + let resolve!: (value: T) => void + let reject!: (cause: unknown) => void + const promise = new Promise((complete, fail) => { + resolve = complete + reject = fail + }) + return { promise, resolve, reject } +} + +function inventory(scannedAt: number, eligibleUpdateNames: string[] = []): SkillFreshnessInventory { + return { schemaVersion: 1, installations: [], eligibleUpdateNames, scannedAt } +} + +let root: Root | null = null +let container: HTMLDivElement | null = null +let state: SkillFreshnessState | null = null +const states = new Map() + +function Probe({ id = 'default' }: { id?: string }): null { + state = useSkillFreshness() + states.set(id, state) + return null +} + +describe('useSkillFreshness', () => { + beforeEach(() => { + _skillFreshnessCacheForTests.reset() + state = null + states.clear() + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(async () => { + vi.useRealTimers() + if (root) { + await act(async () => root?.unmount()) + } + root = null + container?.remove() + container = null + }) + + it('runs a follow-up scan when invalidated during an in-flight request', async () => { + const first = deferred() + const second = deferred() + const freshnessInventory = vi + .fn() + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + window.api = { skills: { freshnessInventory } } as never + + await act(async () => root?.render()) + expect(freshnessInventory).toHaveBeenCalledTimes(1) + + await act(async () => window.dispatchEvent(new Event('focus'))) + await act(async () => first.resolve(inventory(1))) + expect(freshnessInventory).toHaveBeenCalledTimes(2) + + await act(async () => second.resolve(inventory(2))) + expect(state?.inventory?.scannedAt).toBe(2) + }) + + it('skips focus rescans inside the cooldown but honors install-change events', async () => { + const first = deferred() + const second = deferred() + const freshnessInventory = vi + .fn() + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + window.api = { skills: { freshnessInventory } } as never + + await act(async () => root?.render()) + await act(async () => first.resolve(inventory(1))) + expect(freshnessInventory).toHaveBeenCalledTimes(1) + + await act(async () => window.dispatchEvent(new Event('focus'))) + expect(freshnessInventory).toHaveBeenCalledTimes(1) + + await act(async () => window.dispatchEvent(new Event('orca:installed-agent-skills-changed'))) + await act(async () => second.resolve(inventory(2))) + expect(freshnessInventory).toHaveBeenCalledTimes(2) + expect(state?.inventory?.scannedAt).toBe(2) + }) + + it('retracts stale update authority during the cooldown and runs one trailing focus scan', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-07-15T00:00:00Z')) + const second = deferred() + const freshnessInventory = vi + .fn() + .mockResolvedValueOnce(inventory(1, ['orca-cli'])) + .mockReturnValueOnce(second.promise) + window.api = { skills: { freshnessInventory } } as never + + await act(async () => root?.render()) + expect(state?.inventory?.eligibleUpdateNames).toEqual(['orca-cli']) + + await act(async () => window.dispatchEvent(new Event('focus'))) + expect(freshnessInventory).toHaveBeenCalledTimes(1) + expect(state?.inventory).toBeNull() + expect(state?.loading).toBe(true) + + await act(async () => vi.advanceTimersByTimeAsync(15_000)) + expect(freshnessInventory).toHaveBeenCalledTimes(2) + await act(async () => second.resolve(inventory(2))) + expect(state?.inventory?.scannedAt).toBe(2) + }) + + it('coalesces multiple consumers into one rescan per invalidation event', async () => { + const first = deferred() + const second = deferred() + const freshnessInventory = vi + .fn() + .mockReturnValueOnce(first.promise) + .mockReturnValue(second.promise) + window.api = { skills: { freshnessInventory } } as never + + await act(async () => + root?.render( + <> + + + + ) + ) + await act(async () => first.resolve(inventory(1))) + expect(freshnessInventory).toHaveBeenCalledTimes(1) + + await act(async () => window.dispatchEvent(new Event('orca:installed-agent-skills-changed'))) + await act(async () => second.resolve(inventory(2))) + expect(freshnessInventory).toHaveBeenCalledTimes(2) + }) + + it('publishes a manual refresh to every consumer', async () => { + const second = deferred() + const freshnessInventory = vi + .fn() + .mockResolvedValueOnce(inventory(1)) + .mockReturnValueOnce(second.promise) + window.api = { skills: { freshnessInventory } } as never + + await act(async () => + root?.render( + <> + + + + ) + ) + expect(states.get('one')?.inventory?.scannedAt).toBe(1) + expect(states.get('two')?.inventory?.scannedAt).toBe(1) + + let refresh: Promise | undefined + await act(async () => { + refresh = states.get('one')?.refresh() + await Promise.resolve() + }) + expect(states.get('one')?.inventory).toBeNull() + expect(states.get('two')?.inventory).toBeNull() + + await act(async () => second.resolve(inventory(2))) + await refresh + expect(states.get('one')?.inventory?.scannedAt).toBe(2) + expect(states.get('two')?.inventory?.scannedAt).toBe(2) + expect(freshnessInventory).toHaveBeenCalledTimes(2) + }) + + it('fails closed when an invalidation scan rejects', async () => { + const second = deferred() + const freshnessInventory = vi + .fn() + .mockResolvedValueOnce(inventory(1)) + .mockReturnValueOnce(second.promise) + window.api = { skills: { freshnessInventory } } as never + + await act(async () => root?.render()) + expect(state?.inventory?.scannedAt).toBe(1) + + await act(async () => window.dispatchEvent(new Event('orca:installed-agent-skills-changed'))) + expect(state?.inventory).toBeNull() + expect(state?.loading).toBe(true) + + await act(async () => second.reject(new Error('scan failed'))) + expect(state?.inventory).toBeNull() + expect(state?.loading).toBe(false) + expect(state?.error).toBe('scan failed') + }) + + it('installs one event-listener pair for multiple consumers and cleans it up', async () => { + const addEventListener = vi.spyOn(window, 'addEventListener') + const removeEventListener = vi.spyOn(window, 'removeEventListener') + window.api = { + skills: { freshnessInventory: vi.fn().mockResolvedValue(inventory(1)) } + } as never + + await act(async () => + root?.render( + <> + + + + ) + ) + + expect(addEventListener.mock.calls.filter(([name]) => name === 'focus')).toHaveLength(1) + expect( + addEventListener.mock.calls.filter(([name]) => name === 'orca:installed-agent-skills-changed') + ).toHaveLength(1) + + await act(async () => root?.unmount()) + root = null + expect(removeEventListener.mock.calls.filter(([name]) => name === 'focus')).toHaveLength(1) + expect( + removeEventListener.mock.calls.filter( + ([name]) => name === 'orca:installed-agent-skills-changed' + ) + ).toHaveLength(1) + }) +}) diff --git a/src/renderer/src/hooks/useSkillFreshness.ts b/src/renderer/src/hooks/useSkillFreshness.ts new file mode 100644 index 000000000000..91ea0c4bdc20 --- /dev/null +++ b/src/renderer/src/hooks/useSkillFreshness.ts @@ -0,0 +1,178 @@ +import { useEffect, useSyncExternalStore } from 'react' +import type { SkillFreshnessInventory } from '../../../shared/skill-freshness' +import { INSTALLED_AGENT_SKILLS_CHANGED_EVENT } from './installed-agent-skills-change-event' + +// Why: window focus fires on every alt-tab, and each scan re-reads and re-hashes +// every installed package; a just-completed scan stays authoritative briefly. +const FOCUS_RESCAN_COOLDOWN_MS = 15_000 +let cachedInventory: SkillFreshnessInventory | null = null +let pendingInventory: Promise | null = null +let invalidationRevision = 0 +let completedRevision = -1 +let lastCompletedScanAt = 0 +let refreshSequence = 0 +let scheduledFocusRescan: number | null = null + +type SkillFreshnessSnapshot = { + inventory: SkillFreshnessInventory | null + loading: boolean + error: string | null +} + +let snapshot: SkillFreshnessSnapshot = { + inventory: null, + loading: false, + error: null +} +const subscribers = new Set<() => void>() + +function publishSnapshot(next: SkillFreshnessSnapshot): void { + if ( + snapshot.inventory === next.inventory && + snapshot.loading === next.loading && + snapshot.error === next.error + ) { + return + } + snapshot = next + for (const subscriber of subscribers) { + subscriber() + } +} + +async function loadInventory(force: boolean): Promise { + if (force) { + invalidationRevision += 1 + } + const targetRevision = invalidationRevision + for (;;) { + if (cachedInventory && completedRevision >= targetRevision) { + return cachedInventory + } + if (!pendingInventory) { + const requestRevision = invalidationRevision + const request = window.api.skills + .freshnessInventory() + .then((inventory) => { + cachedInventory = inventory + completedRevision = Math.max(completedRevision, requestRevision) + lastCompletedScanAt = Date.now() + return inventory + }) + .finally(() => { + if (pendingInventory === request) { + pendingInventory = null + } + }) + pendingInventory = request + } + await pendingInventory + } +} + +async function refreshSkillFreshness(force = true): Promise { + if (scheduledFocusRescan !== null) { + window.clearTimeout(scheduledFocusRescan) + scheduledFocusRescan = null + } + const sequence = ++refreshSequence + // Why: eligibility is write authority for the draft command. Once invalidated, + // stale bytes must stop authorizing UI even if the replacement scan fails. + publishSnapshot({ inventory: null, loading: true, error: null }) + try { + const inventory = await loadInventory(force) + if (sequence === refreshSequence) { + publishSnapshot({ inventory, loading: false, error: null }) + } + } catch (cause) { + if (sequence === refreshSequence) { + publishSnapshot({ + inventory: null, + loading: false, + error: cause instanceof Error ? cause.message : 'Could not inspect Orca skills.' + }) + } + } +} + +function onWindowFocus(): void { + const cooldownRemaining = FOCUS_RESCAN_COOLDOWN_MS - (Date.now() - lastCompletedScanAt) + if (cooldownRemaining <= 0) { + void refreshSkillFreshness(true) + return + } + if (!snapshot.inventory?.eligibleUpdateNames.length || scheduledFocusRescan !== null) { + return + } + // Why: a focus event can follow an external edit. Retract stale update + // authority immediately, but keep rapid alt-tabs to one trailing disk scan. + publishSnapshot({ inventory: null, loading: true, error: null }) + scheduledFocusRescan = window.setTimeout( + () => { + scheduledFocusRescan = null + void refreshSkillFreshness(true) + }, + Math.min(cooldownRemaining, FOCUS_RESCAN_COOLDOWN_MS) + ) +} + +function onInstalledSkillsChanged(): void { + void refreshSkillFreshness(true) +} + +function subscribe(subscriber: () => void): () => void { + subscribers.add(subscriber) + if (subscribers.size === 1) { + // Why: every consumer reads one external snapshot, so focus/install events + // install one listener and trigger one shared IPC scan regardless of UI count. + window.addEventListener('focus', onWindowFocus) + window.addEventListener(INSTALLED_AGENT_SKILLS_CHANGED_EVENT, onInstalledSkillsChanged) + } + return () => { + subscribers.delete(subscriber) + if (subscribers.size === 0) { + window.removeEventListener('focus', onWindowFocus) + window.removeEventListener(INSTALLED_AGENT_SKILLS_CHANGED_EVENT, onInstalledSkillsChanged) + } + } +} + +function getSnapshot(): SkillFreshnessSnapshot { + return snapshot +} + +function ensureInventoryLoaded(): void { + if (!snapshot.inventory && !snapshot.loading) { + void refreshSkillFreshness(false) + } +} + +export type SkillFreshnessState = SkillFreshnessSnapshot & { + refresh: () => Promise +} + +export function useSkillFreshness(): SkillFreshnessState { + const current = useSyncExternalStore(subscribe, getSnapshot, getSnapshot) + + useEffect(() => { + ensureInventoryLoaded() + }, []) + + return { ...current, refresh: refreshSkillFreshness } +} + +export const _skillFreshnessCacheForTests = { + reset(): void { + cachedInventory = null + pendingInventory = null + invalidationRevision = 0 + completedRevision = -1 + lastCompletedScanAt = 0 + refreshSequence = 0 + if (scheduledFocusRescan !== null) { + window.clearTimeout(scheduledFocusRescan) + scheduledFocusRescan = null + } + snapshot = { inventory: null, loading: false, error: null } + } +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 6596b371b7d7..f275b3923b6f 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3605,6 +3605,64 @@ "cd7893fbc1": "Scanning skills", "35b9a724a0": "Available", "0c74e7ff34": "Local" + }, + "SkillFreshnessNudge": { + "titleOne": "An installed Orca skill is out of date", + "titleMany": "{{value0}} installed Orca skills are out of date", + "description": "Update {{value0}} so agents follow the current instructions for this version of Orca.", + "updateOne": "Update skill", + "updateMany": "Update skills" + }, + "SkillFreshnessRow": { + "statusUpdateAvailable": "Update available", + "statusCantUpdate": "Skipped", + "cantUpdateReason": "Orca left this skill out of the update command.", + "skippedReasonUnrecognized": "The copy here doesn’t match the official version — it may be modified, or a different skill with the same name. Orca left it out of the update so it won’t overwrite it. Remove it if you want Orca to update this skill.", + "skippedReasonReadOnly": "This copy is in a read-only location, so Orca left it out of the update. Change its permissions to let Orca update it.", + "skippedReasonInaccessible": "Orca couldn’t read this copy, so it left the skill out of the update.", + "skippedReasonInRepo": "This is a project skill, not a global one — Orca only updates your global skills, so it left this out of the update.", + "skippedReasonPluginCache": "A plugin manages this skill, so Orca left it out of the update — update the plugin instead.", + "skippedReasonExternalLink": "This copy is a shortcut pointing outside Orca’s skill folders, so Orca left it out of the update.", + "skippedReasonBrokenLink": "This copy is a shortcut to something that no longer exists, so Orca left it out — you can safely delete it.", + "chipCurrent": "Current", + "chipUnrecognized": "Unrecognized", + "chipInaccessible": "Inaccessible", + "chipDuplicate": "Duplicate", + "chipExternalLink": "External link", + "chipBrokenLink": "Broken link", + "chipReadOnly": "Read only", + "chipInRepo": "In a repo", + "chipPluginCache": "Plugin cache", + "tipCurrent": "This copy matches the current official version.", + "tipUnrecognized": "This copy doesn’t match any official version — it may be modified, or a different skill with the same name.", + "tipInaccessible": "Orca couldn’t read this copy (a permissions or file error).", + "tipDuplicate": "A separate copy of this skill, installed apart from the main one.", + "tipExternalLink": "A shortcut pointing outside Orca’s skill folders.", + "tipBrokenLink": "A shortcut to something that no longer exists.", + "tipReadOnly": "This copy is in a read-only location.", + "tipInRepo": "This copy lives inside a project, not your global skills.", + "tipPluginCache": "This copy is managed by a plugin." + }, + "SkillFreshnessUpdateDialog": { + "title": "Update skills", + "checking": "Checking installed Orca skills…", + "none": "No installed Orca skills found.", + "updateOne": "1 skill can be updated safely", + "updateMany": "{{value0}} skills can be updated safely", + "success": "All installed Orca skills are up to date.", + "attention": "Some installed Orca skills were left out of the update.", + "attentionDescription": "Open Update details to see why each one was skipped.", + "details": "Update details", + "terminalTitle": "Update Orca skills", + "terminalDescription": "Review the pre-filled command, then press Enter to run it.", + "terminalAria": "Orca skill update terminal", + "checkNow": "Re-check", + "close": "Close" + }, + "SkillFreshnessStatusPill": { + "updateAvailable": "Update available", + "upToDate": "Up to date", + "installed": "Installed" } }, "sidebar": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 2b568c764bd0..9261f9971d07 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -3605,6 +3605,64 @@ "cd7893fbc1": "Escaneando skills", "35b9a724a0": "Disponible", "0c74e7ff34": "Local" + }, + "SkillFreshnessNudge": { + "titleOne": "Una skill de Orca instalada está desactualizada", + "titleMany": "{{value0}} skills de Orca instaladas están desactualizadas", + "updateOne": "Actualizar skill", + "updateMany": "Actualizar skills", + "description": "Actualiza {{value0}} para que los Agents sigan las instrucciones actuales de esta versión de Orca." + }, + "SkillFreshnessRow": { + "statusUpdateAvailable": "Actualización disponible", + "statusCantUpdate": "No se puede actualizar", + "cantUpdateReason": "Esta skill está instalada en un lugar que Orca no puede actualizar de forma segura, así que el comando npx skills update la deja sin cambios.", + "chipCurrent": "Actual", + "chipUnrecognized": "No reconocida", + "chipInaccessible": "Inaccesible", + "chipDuplicate": "Duplicada", + "chipExternalLink": "Enlace externo", + "chipBrokenLink": "Enlace roto", + "chipReadOnly": "Solo lectura", + "chipInRepo": "En un repo", + "chipPluginCache": "Caché de plugin", + "tipCurrent": "La skill aquí ya está actualizada; la actualización no la cambiará.", + "tipUnrecognized": "El contenido de la skill aquí no coincide con ninguna versión oficial, así que Orca no puede actualizarla de forma segura. Elimina o reemplaza lo que hay aquí para permitir las actualizaciones.", + "tipInaccessible": "Orca no pudo leer la skill aquí (un error de permisos o de archivo), así que no puede comprobarla ni actualizarla.", + "tipDuplicate": "La skill también está instalada aquí, aparte de la principal, así que el comando npx skills update no puede alcanzarla. Elimínala para permitir las actualizaciones.", + "tipExternalLink": "Es un acceso directo que apunta fuera de las carpetas de skills de Orca; la actualización no lo seguirá.", + "tipBrokenLink": "Es un acceso directo a algo que ya no existe; puedes eliminarlo sin problema.", + "tipReadOnly": "La skill aquí está en una ubicación de solo lectura, así que no se puede actualizar hasta que cambies sus permisos.", + "tipInRepo": "La skill aquí vive dentro de un proyecto, no en tus skills globales; Orca solo actualiza las globales.", + "tipPluginCache": "La skill aquí la gestiona un plugin; actualiza el plugin en su lugar.", + "skippedReasonUnrecognized": "The copy here doesn’t match the official version — it may be modified, or a different skill with the same name. Orca left it out of the update so it won’t overwrite it. Remove it if you want Orca to update this skill.", + "skippedReasonReadOnly": "This copy is in a read-only location, so Orca left it out of the update. Change its permissions to let Orca update it.", + "skippedReasonInaccessible": "Orca couldn’t read this copy, so it left the skill out of the update.", + "skippedReasonInRepo": "This is a project skill, not a global one — Orca only updates your global skills, so it left this out of the update.", + "skippedReasonPluginCache": "A plugin manages this skill, so Orca left it out of the update — update the plugin instead.", + "skippedReasonExternalLink": "This copy is a shortcut pointing outside Orca’s skill folders, so Orca left it out of the update.", + "skippedReasonBrokenLink": "This copy is a shortcut to something that no longer exists, so Orca left it out — you can safely delete it." + }, + "SkillFreshnessUpdateDialog": { + "title": "Actualizar skills", + "checking": "Comprobando las skills de Orca instaladas…", + "none": "No se encontraron skills de Orca instaladas.", + "updateOne": "Se puede actualizar 1 skill de forma segura", + "updateMany": "Se pueden actualizar {{value0}} skills de forma segura", + "success": "Todas las skills de Orca instaladas están actualizadas.", + "attention": "Algunas skills de Orca instaladas no se pueden actualizar automáticamente.", + "attentionDescription": "Abre Detalles de la actualización para ver por qué no se puede actualizar cada una.", + "details": "Detalles de la actualización", + "terminalTitle": "Actualizar skills de Orca", + "terminalDescription": "Revisa el comando pre-rellenado y pulsa Intro para ejecutarlo.", + "terminalAria": "Terminal de actualización de skills de Orca", + "checkNow": "Comprobar ahora", + "close": "Cerrar" + }, + "SkillFreshnessStatusPill": { + "updateAvailable": "Actualización disponible", + "upToDate": "Actualizado", + "installed": "Instalado" } }, "sidebar": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 70aaea12442f..e1cf3313cdc0 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -3605,6 +3605,64 @@ "cd7893fbc1": "スキャンスキル", "35b9a724a0": "利用可能", "0c74e7ff34": "ローカル" + }, + "SkillFreshnessNudge": { + "titleOne": "インストール済みの Orca スキルが古くなっています", + "titleMany": "インストール済みの Orca スキル {{value0}} 件が古くなっています", + "updateOne": "スキルを更新", + "updateMany": "スキルを更新", + "description": "Agents がこのバージョンの Orca の最新の手順に従うように、{{value0}} を更新してください。" + }, + "SkillFreshnessRow": { + "statusUpdateAvailable": "更新あり", + "statusCantUpdate": "更新できません", + "cantUpdateReason": "このスキルは Orca が安全に更新できない場所にインストールされているため、npx skills update コマンドは変更しません。", + "chipCurrent": "最新", + "chipUnrecognized": "未認識", + "chipInaccessible": "アクセス不可", + "chipDuplicate": "重複", + "chipExternalLink": "外部リンク", + "chipBrokenLink": "リンク切れ", + "chipReadOnly": "読み取り専用", + "chipInRepo": "リポジトリ内", + "chipPluginCache": "プラグインキャッシュ", + "tipCurrent": "ここのスキルはすでに最新です。更新しても変更されません。", + "tipUnrecognized": "ここのスキルの内容が公式バージョンのいずれとも一致しないため、Orca は安全に更新できません。更新を許可するには、ここにあるものを削除または置き換えてください。", + "tipInaccessible": "Orca はここのスキルを読み取れなかったため(権限またはファイルのエラー)、確認も更新もできません。", + "tipDuplicate": "このスキルはメインとは別に、ここにもインストールされています。そのため npx skills update コマンドは到達できません。更新を許可するには削除してください。", + "tipExternalLink": "これは Orca のスキルフォルダーの外を指すショートカットです。更新では追跡されません。", + "tipBrokenLink": "これは存在しないものを指すショートカットです。削除しても問題ありません。", + "tipReadOnly": "ここのスキルは読み取り専用の場所にあるため、権限を変更するまで更新できません。", + "tipInRepo": "ここのスキルはグローバルスキルではなくプロジェクト内にあります。Orca はグローバルなものだけを更新します。", + "tipPluginCache": "ここのスキルはプラグインによって管理されています。代わりにプラグインを更新してください。", + "skippedReasonUnrecognized": "The copy here doesn’t match the official version — it may be modified, or a different skill with the same name. Orca left it out of the update so it won’t overwrite it. Remove it if you want Orca to update this skill.", + "skippedReasonReadOnly": "This copy is in a read-only location, so Orca left it out of the update. Change its permissions to let Orca update it.", + "skippedReasonInaccessible": "Orca couldn’t read this copy, so it left the skill out of the update.", + "skippedReasonInRepo": "This is a project skill, not a global one — Orca only updates your global skills, so it left this out of the update.", + "skippedReasonPluginCache": "A plugin manages this skill, so Orca left it out of the update — update the plugin instead.", + "skippedReasonExternalLink": "This copy is a shortcut pointing outside Orca’s skill folders, so Orca left it out of the update.", + "skippedReasonBrokenLink": "This copy is a shortcut to something that no longer exists, so Orca left it out — you can safely delete it." + }, + "SkillFreshnessUpdateDialog": { + "title": "スキルを更新", + "checking": "インストール済みの Orca スキルを確認中…", + "none": "インストール済みの Orca スキルは見つかりませんでした。", + "updateOne": "1 件のスキルを安全に更新できます", + "updateMany": "{{value0}} 件のスキルを安全に更新できます", + "success": "インストール済みの Orca スキルはすべて最新です。", + "attention": "一部のインストール済み Orca スキルは自動的に更新できません。", + "attentionDescription": "更新できない理由は、更新の詳細を開いて確認してください。", + "details": "更新の詳細", + "terminalTitle": "Orca スキルを更新", + "terminalDescription": "入力済みのコマンドを確認し、Enter キーを押して実行してください。", + "terminalAria": "Orca スキル更新ターミナル", + "checkNow": "今すぐ確認", + "close": "閉じる" + }, + "SkillFreshnessStatusPill": { + "updateAvailable": "更新があります", + "upToDate": "最新です", + "installed": "インストール済み" } }, "sidebar": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 98311b01de7f..d22ad9a5db8e 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -3605,6 +3605,64 @@ "cd7893fbc1": "스킬 스캔 중", "35b9a724a0": "사용 가능", "0c74e7ff34": "로컬" + }, + "SkillFreshnessNudge": { + "titleOne": "설치된 Orca 스킬이 오래되었습니다", + "titleMany": "설치된 Orca 스킬 {{value0}}개가 오래되었습니다", + "updateOne": "스킬 업데이트", + "updateMany": "스킬 업데이트", + "description": "Agents가 이 버전의 Orca에 대한 최신 지침을 따르도록 {{value0}}을(를) 업데이트하세요." + }, + "SkillFreshnessRow": { + "statusUpdateAvailable": "업데이트 있음", + "statusCantUpdate": "업데이트할 수 없음", + "cantUpdateReason": "이 스킬은 Orca가 안전하게 업데이트할 수 없는 위치에 설치되어 있어, npx skills update 명령이 변경하지 않습니다.", + "chipCurrent": "최신", + "chipUnrecognized": "인식 안 됨", + "chipInaccessible": "액세스 불가", + "chipDuplicate": "중복", + "chipExternalLink": "외부 링크", + "chipBrokenLink": "깨진 링크", + "chipReadOnly": "읽기 전용", + "chipInRepo": "저장소 내", + "chipPluginCache": "플러그인 캐시", + "tipCurrent": "여기 있는 스킬은 이미 최신이므로 업데이트해도 변경되지 않습니다.", + "tipUnrecognized": "여기 있는 스킬의 콘텐츠가 어떤 공식 버전과도 일치하지 않아 Orca가 안전하게 업데이트할 수 없습니다. 업데이트를 허용하려면 여기 있는 항목을 제거하거나 교체하세요.", + "tipInaccessible": "Orca가 여기 있는 스킬을 읽지 못해(권한 또는 파일 오류) 확인하거나 업데이트할 수 없습니다.", + "tipDuplicate": "이 스킬은 기본 위치와 별개로 여기에도 설치되어 있어 npx skills update 명령이 접근할 수 없습니다. 업데이트를 허용하려면 제거하세요.", + "tipExternalLink": "Orca의 스킬 폴더 바깥을 가리키는 바로 가기입니다. 업데이트가 이를 따라가지 않습니다.", + "tipBrokenLink": "더 이상 존재하지 않는 대상을 가리키는 바로 가기입니다. 삭제해도 됩니다.", + "tipReadOnly": "여기 있는 스킬은 읽기 전용 위치에 있어 권한을 변경해야 업데이트할 수 있습니다.", + "tipInRepo": "여기 있는 스킬은 전역 스킬이 아니라 프로젝트 안에 있습니다. Orca는 전역 스킬만 업데이트합니다.", + "tipPluginCache": "여기 있는 스킬은 플러그인이 관리합니다. 대신 플러그인을 업데이트하세요.", + "skippedReasonUnrecognized": "The copy here doesn’t match the official version — it may be modified, or a different skill with the same name. Orca left it out of the update so it won’t overwrite it. Remove it if you want Orca to update this skill.", + "skippedReasonReadOnly": "This copy is in a read-only location, so Orca left it out of the update. Change its permissions to let Orca update it.", + "skippedReasonInaccessible": "Orca couldn’t read this copy, so it left the skill out of the update.", + "skippedReasonInRepo": "This is a project skill, not a global one — Orca only updates your global skills, so it left this out of the update.", + "skippedReasonPluginCache": "A plugin manages this skill, so Orca left it out of the update — update the plugin instead.", + "skippedReasonExternalLink": "This copy is a shortcut pointing outside Orca’s skill folders, so Orca left it out of the update.", + "skippedReasonBrokenLink": "This copy is a shortcut to something that no longer exists, so Orca left it out — you can safely delete it." + }, + "SkillFreshnessUpdateDialog": { + "title": "스킬 업데이트", + "checking": "설치된 Orca 스킬을 확인하는 중…", + "none": "설치된 Orca 스킬을 찾을 수 없습니다.", + "updateOne": "스킬 1개를 안전하게 업데이트할 수 있습니다", + "updateMany": "스킬 {{value0}}개를 안전하게 업데이트할 수 있습니다", + "success": "설치된 Orca 스킬이 모두 최신 상태입니다.", + "attention": "일부 설치된 Orca 스킬은 자동으로 업데이트할 수 없습니다.", + "attentionDescription": "각 항목을 업데이트할 수 없는 이유는 업데이트 세부 정보를 열어 확인하세요.", + "details": "업데이트 세부 정보", + "terminalTitle": "Orca 스킬 업데이트", + "terminalDescription": "미리 입력된 명령을 검토한 후 Enter 키를 눌러 실행하세요.", + "terminalAria": "Orca 스킬 업데이트 터미널", + "checkNow": "지금 확인", + "close": "닫기" + }, + "SkillFreshnessStatusPill": { + "updateAvailable": "업데이트 가능", + "upToDate": "최신 상태", + "installed": "설치됨" } }, "sidebar": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 7a439d1ef378..b9fe5b678d65 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -3605,6 +3605,64 @@ "cd7893fbc1": "正在扫描技能", "35b9a724a0": "可用的", "0c74e7ff34": "当地的" + }, + "SkillFreshnessNudge": { + "titleOne": "已安装的 Orca 技能已过期", + "titleMany": "{{value0}} 个已安装的 Orca 技能已过期", + "updateOne": "更新技能", + "updateMany": "更新技能", + "description": "更新 {{value0}},让 Agents 遵循此版本 Orca 的最新说明。" + }, + "SkillFreshnessRow": { + "statusUpdateAvailable": "有更新", + "statusCantUpdate": "无法更新", + "cantUpdateReason": "此技能安装在 Orca 无法安全更新的位置,因此 npx skills update 命令不会更改它。", + "chipCurrent": "最新", + "chipUnrecognized": "无法识别", + "chipInaccessible": "无法访问", + "chipDuplicate": "重复", + "chipExternalLink": "外部链接", + "chipBrokenLink": "失效链接", + "chipReadOnly": "只读", + "chipInRepo": "在仓库中", + "chipPluginCache": "插件缓存", + "tipCurrent": "这里的技能已是最新,更新不会更改它。", + "tipUnrecognized": "这里的技能内容与任何官方版本都不匹配,因此 Orca 无法安全更新它。请移除或替换此处的内容以允许更新。", + "tipInaccessible": "Orca 无法读取这里的技能(权限或文件错误),因此无法检查或更新它。", + "tipDuplicate": "此技能除主副本外还安装在这里,因此 npx skills update 命令无法访问它。请移除它以允许更新。", + "tipExternalLink": "这是一个指向 Orca 技能文件夹之外的快捷方式;更新不会跟随它。", + "tipBrokenLink": "这是一个指向已不存在内容的快捷方式,可以安全删除。", + "tipReadOnly": "这里的技能位于只读位置,需更改其权限后才能更新。", + "tipInRepo": "这里的技能位于某个项目内,而非全局技能;Orca 只更新全局技能。", + "tipPluginCache": "这里的技能由插件管理;请改为更新插件。", + "skippedReasonUnrecognized": "The copy here doesn’t match the official version — it may be modified, or a different skill with the same name. Orca left it out of the update so it won’t overwrite it. Remove it if you want Orca to update this skill.", + "skippedReasonReadOnly": "This copy is in a read-only location, so Orca left it out of the update. Change its permissions to let Orca update it.", + "skippedReasonInaccessible": "Orca couldn’t read this copy, so it left the skill out of the update.", + "skippedReasonInRepo": "This is a project skill, not a global one — Orca only updates your global skills, so it left this out of the update.", + "skippedReasonPluginCache": "A plugin manages this skill, so Orca left it out of the update — update the plugin instead.", + "skippedReasonExternalLink": "This copy is a shortcut pointing outside Orca’s skill folders, so Orca left it out of the update.", + "skippedReasonBrokenLink": "This copy is a shortcut to something that no longer exists, so Orca left it out — you can safely delete it." + }, + "SkillFreshnessUpdateDialog": { + "title": "更新技能", + "checking": "正在检查已安装的 Orca 技能…", + "none": "未找到已安装的 Orca 技能。", + "updateOne": "可以安全更新 1 个技能", + "updateMany": "可以安全更新 {{value0}} 个技能", + "success": "所有已安装的 Orca 技能均为最新。", + "attention": "部分已安装的 Orca 技能无法自动更新。", + "attentionDescription": "打开“更新详情”查看为何每一项都无法更新。", + "details": "更新详情", + "terminalTitle": "更新 Orca 技能", + "terminalDescription": "检查预填的命令,然后按 Enter 键运行。", + "terminalAria": "Orca 技能更新终端", + "checkNow": "立即检查", + "close": "关闭" + }, + "SkillFreshnessStatusPill": { + "updateAvailable": "有可用更新", + "upToDate": "已是最新", + "installed": "已安装" } }, "sidebar": { diff --git a/src/renderer/src/lib/settings-navigation-types.ts b/src/renderer/src/lib/settings-navigation-types.ts index 765940ffe9e0..3541c0e3f2af 100644 --- a/src/renderer/src/lib/settings-navigation-types.ts +++ b/src/renderer/src/lib/settings-navigation-types.ts @@ -3,7 +3,7 @@ import type { LucideProps } from 'lucide-react' import type { SettingsSearchEntry } from '@/components/settings/settings-search' export type SettingsNavIcon = ComponentType -export type SettingsNavInstallStatus = 'install' | 'installed' | 'checking' +export type SettingsNavInstallStatus = 'install' | 'installed' | 'update-available' | 'checking' export type SettingsNavTarget = | 'general' diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index a64f4abe263e..d6f6d6e5830b 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -35,6 +35,7 @@ import type { WorkspaceSessionState } from '../../../shared/types' import type { SkillDiscoveryResult } from '../../../shared/skills' +import type { SkillFreshnessInventory } from '../../../shared/skill-freshness' import type { SshConnectionState, SshTarget } from '../../../shared/ssh-types' import { getDefaultOnboardingState, @@ -2743,7 +2744,16 @@ function createSkillsApi(): NonNullable['skills']> { skills: [], sources: [], scannedAt: Date.now() - })) + })), + // Why: browser clients have no local skill homes, and remote-host + // freshness stays disabled until its update rail has equivalent coverage. + freshnessInventory: (): Promise => + Promise.resolve({ + schemaVersion: 1, + installations: [], + eligibleUpdateNames: [], + scannedAt: Date.now() + }) } } diff --git a/src/shared/skill-freshness.ts b/src/shared/skill-freshness.ts new file mode 100644 index 000000000000..08383d5e5f58 --- /dev/null +++ b/src/shared/skill-freshness.ts @@ -0,0 +1,102 @@ +import type { SkillProvider, SkillSourceKind } from './skills' + +export type SkillBundleFileIdentity = { + path: string + size: number + executable: boolean + classification: 'text' | 'binary' + exactSha256: string + textNormalizedSha256: string | null + identitySha256: string +} + +export type SkillKnownSnapshot = { + releaseRevision: number + packageDigest: string + gitTreeSha: string + files: SkillBundleFileIdentity[] +} + +export type SkillCurrentBundleEntry = SkillKnownSnapshot & { + name: string + sourcePath: string + appVersion: string +} + +export type SkillBundleManifest = { + schemaVersion: 1 + appVersion: string + skills: SkillCurrentBundleEntry[] +} + +export type SkillSnapshotRegistry = { + schemaVersion: 1 + skills: Record +} + +export type SkillReleaseMapping = { + schemaVersion: 1 + releases: { appVersion: string; skills: Record }[] +} + +export type SkillFreshnessStatus = + | 'current' + | 'outdated' + | 'newer-known' + | 'unrecognized' + | 'inaccessible' + +export type SkillInstallationTopology = + | 'canonical-copy' + | 'provider-alias' + | 'independent-copy' + | 'external-link' + | 'broken-link' + | 'read-only' + | 'repo-scope' + | 'plugin-cache' + +// Why: eligibility and the explanation copy must agree on which placements the +// validated npx rail can converge; a drifted copy would blame a phantom sibling. +export const SUPPORTED_GLOBAL_SKILL_TOPOLOGIES: ReadonlySet = new Set([ + 'canonical-copy', + 'provider-alias' +]) + +export type SkillFreshnessInstallation = { + id: string + name: string + rootId: string + providers: SkillProvider[] + sourceKind: SkillSourceKind + sourceLabel: string + unresolvedPath: string + resolvedPath: string | null + physicalIdentity: string | null + topology: SkillInstallationTopology + status: SkillFreshnessStatus + installedReleaseRevision: number | null + installedAppVersion: string | null + currentReleaseRevision: number + currentPackageDigest: string + currentAppVersion: string + observedPackageDigest: string | null + errorCategory: string | null +} + +export type SkillFreshnessInventory = { + schemaVersion: 1 + installations: SkillFreshnessInstallation[] + eligibleUpdateNames: string[] + scannedAt: number +} + +export function buildTargetedSkillUpdateCommand(names: readonly string[]): string | null { + const canonicalNames = [...new Set(names)].sort((left, right) => left.localeCompare(right, 'en')) + // Why: names become editable shell input. Official manifests use this + // restricted package-name grammar so no entry can introduce shell syntax. + if (canonicalNames.some((name) => !/^[a-z0-9][a-z0-9._-]*$/.test(name))) { + return null + } + return canonicalNames.length > 0 ? `npx skills update ${canonicalNames.join(' ')} --global` : null +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 91b7785f0263..3de8e943d16f 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2875,6 +2875,9 @@ export type GlobalSettings = { /** Why: disabling must persist so startup does not reinstall global agent * hook entries right after the user removes them from Settings or CLI. */ agentStatusHooksEnabled: boolean + /** Dismissed freshness tuples grant no write authority; they only keep the + * same exact official placement/revision from nudging more than once. */ + dismissedSkillFreshnessNudges?: string[] /** Why: generated tab titles are semantic but subjective, so they stay opt-in * and manual renames remain the stronger user intent. */ tabAutoGenerateTitle: boolean From 09173d02d21de61a10b07cbc200ca746de1edf16 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:48:06 -0700 Subject: [PATCH 088/351] fix(terminal): restore clickable links after returning to a worktree (#9061) * fix(terminal): restore clickable links after returning to a worktree Clicking a terminal link (file path, URL, term_* handle, OSC-8) does nothing after switching to another worktree and back, until the terminal is scrolled a little. xterm's linkifier only re-runs link providers on mousemove when the hovered buffer cell changes vs its cached `_lastBufferCell`. Hiding the terminal surface fires `mouseleave`, which clears the current link but leaves that cache. On reveal the pointer returns to the same cell, so the mousemove handler short-circuits and the link is never re-established; a scroll shifts the buffer position and re-linkifies, which is the "scroll to fix" symptom. Reset the linkifier hover-cell cache when a terminal surface is revealed (resumeTerminalVisibility) so the next mousemove re-evaluates providers. Covers all link types, including file-path links whose geometry click fallback does not compensate after reveal. Verified in real Electron via a new e2e that fails without the reset and passes with it, for both a file-path link and a URL. * test(terminal): prove restored links activate after reveal --- .../terminal-visibility-resume.test.ts | 17 + .../terminal-visibility-resume.ts | 7 + .../terminal-linkifier-hover-reset.test.ts | 33 ++ .../terminal-linkifier-hover-reset.ts | 46 +++ ...l-link-hover-after-worktree-return.spec.ts | 332 ++++++++++++++++++ 5 files changed, 435 insertions(+) create mode 100644 src/renderer/src/lib/pane-manager/terminal-linkifier-hover-reset.test.ts create mode 100644 src/renderer/src/lib/pane-manager/terminal-linkifier-hover-reset.ts create mode 100644 tests/e2e/terminal-link-hover-after-worktree-return.spec.ts diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts index 21716851899b..29d9f27d2876 100644 --- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts @@ -27,6 +27,11 @@ vi.mock('./terminal-webgl-atlas-recovery', () => ({ // the terminal-output debounce (which a background stream could otherwise defer). scheduleTabRevealWebglAtlasRecovery: () => scheduleTabRevealWebglAtlasRecovery() })) +const resetTerminalLinkifierHoverState = vi.fn() +vi.mock('@/lib/pane-manager/terminal-linkifier-hover-reset', () => ({ + resetTerminalLinkifierHoverState: (terminal: unknown) => + resetTerminalLinkifierHoverState(terminal) +})) type FakeManager = { getPanes: ReturnType @@ -90,6 +95,18 @@ describe('resumeTerminalVisibility reveal repaint', () => { ) }) + it('resets each pane linkifier hover cache on reveal so links recover without a scroll', () => { + const first = { name: 'pane-a' } + const second = { name: 'pane-b' } + const manager = createManager() + manager.getPanes.mockReturnValue([{ terminal: first }, { terminal: second }]) + + resumeTerminalVisibility(resumeArgs(manager, false)) + + expect(resetTerminalLinkifierHoverState).toHaveBeenCalledWith(first) + expect(resetTerminalLinkifierHoverState).toHaveBeenCalledWith(second) + }) + it('schedules the repaint after rendering resumes on a heavy reveal', () => { const order: string[] = [] const manager = createManager(order) diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts index 5892dead9d7a..c330efb37e4a 100644 --- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts @@ -9,6 +9,7 @@ import { enforceTerminalCurrentScrollIntent, syncTerminalScrollIntentFromViewport } from '@/lib/pane-manager/terminal-scroll-intent' +import { resetTerminalLinkifierHoverState } from '@/lib/pane-manager/terminal-linkifier-hover-reset' import { fitAndFocusPanes, fitPanes, focusActivePane } from './pane-helpers' import { scheduleTabRevealWebglAtlasRecovery } from './terminal-webgl-atlas-recovery' @@ -54,6 +55,12 @@ export function resumeTerminalVisibility({ captureViewportPositions, withSuppressedScrollTracking }: ResumeTerminalVisibilityArgs): void { + // Why: hiding the surface fired mouseleave, which cleared xterm's current + // link but left its hover cell cache; without this reset a link stays dead + // until a scroll when the pointer returns to the same cell on reveal. + for (const pane of manager.getPanes()) { + resetTerminalLinkifierHoverState(pane.terminal) + } syncTerminalViewportIntents(manager) // Why: WebGL resume can disturb xterm's viewport bookkeeping before the // post-resume fit runs. Capture numeric viewport positions first; the diff --git a/src/renderer/src/lib/pane-manager/terminal-linkifier-hover-reset.test.ts b/src/renderer/src/lib/pane-manager/terminal-linkifier-hover-reset.test.ts new file mode 100644 index 000000000000..aca9b7034d50 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-linkifier-hover-reset.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import type { Terminal } from '@xterm/xterm' +import { resetTerminalLinkifierHoverState } from './terminal-linkifier-hover-reset' + +type FakeLinkifier = { _lastBufferCell?: unknown; _activeLine?: number } + +function createTerminal(linkifier: FakeLinkifier | null | undefined, hasCore = true): Terminal { + const core = hasCore ? { linkifier: linkifier ?? undefined } : undefined + return { _core: core } as unknown as Terminal +} + +describe('resetTerminalLinkifierHoverState', () => { + it('clears the hover cell cache and active line so the next mousemove re-linkifies', () => { + const linkifier: FakeLinkifier = { _lastBufferCell: { x: 5, y: 5 }, _activeLine: 5 } + resetTerminalLinkifierHoverState(createTerminal(linkifier)) + + expect(linkifier._lastBufferCell).toBeUndefined() + expect(linkifier._activeLine).toBe(-1) + }) + + it('is a no-op when the linkifier or core internals are unavailable', () => { + expect(() => resetTerminalLinkifierHoverState(createTerminal(null))).not.toThrow() + expect(() => resetTerminalLinkifierHoverState(createTerminal(undefined, false))).not.toThrow() + }) + + it('only touches fields that exist so a renamed xterm internal degrades safely', () => { + const linkifier: FakeLinkifier = {} + resetTerminalLinkifierHoverState(createTerminal(linkifier)) + + expect('_lastBufferCell' in linkifier).toBe(false) + expect('_activeLine' in linkifier).toBe(false) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/terminal-linkifier-hover-reset.ts b/src/renderer/src/lib/pane-manager/terminal-linkifier-hover-reset.ts new file mode 100644 index 000000000000..2c93f354ac0d --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-linkifier-hover-reset.ts @@ -0,0 +1,46 @@ +import type { Terminal } from '@xterm/xterm' + +type LinkifierHoverCache = { + _lastBufferCell?: unknown + _activeLine?: number +} + +type TerminalCoreWithLinkifier = { + _core?: { + linkifier?: LinkifierHoverCache + } +} + +/** + * Force xterm's linkifier to re-run link providers on the next mousemove. + * + * Why: when a terminal is hidden (worktree/tab switch) the browser fires + * `mouseleave`, which clears the linkifier's current link but leaves its + * `_lastBufferCell` cache set. On reveal the pointer usually returns to the + * same cell, so xterm's mousemove handler short-circuits (position unchanged) + * and never re-linkifies — the link (file path, URL, term_* handle, OSC-8) + * stays dead until a scroll shifts the buffer position. Clearing the cell/line + * cache makes the next mousemove re-evaluate providers so the link and its + * hover underline recover without a scroll. + * + * Reaches into xterm internals (`@xterm/xterm` 6.1.0-beta.287 `Linkifier`) + * because there is no public API to invalidate the hover cache. Guarded so a + * future xterm build that renames these fields degrades to the pre-fix + * behavior (link recovers on the next genuine cell change) instead of throwing. + */ +export function resetTerminalLinkifierHoverState(terminal: Terminal): void { + try { + const linkifier = (terminal as unknown as TerminalCoreWithLinkifier)._core?.linkifier + if (!linkifier) { + return + } + if ('_lastBufferCell' in linkifier) { + linkifier._lastBufferCell = undefined + } + if ('_activeLine' in linkifier) { + linkifier._activeLine = -1 + } + } catch { + /* linkifier internals unavailable — link recovers on the next cell change */ + } +} diff --git a/tests/e2e/terminal-link-hover-after-worktree-return.spec.ts b/tests/e2e/terminal-link-hover-after-worktree-return.spec.ts new file mode 100644 index 000000000000..b0bcd1bc4b3b --- /dev/null +++ b/tests/e2e/terminal-link-hover-after-worktree-return.spec.ts @@ -0,0 +1,332 @@ +import { randomUUID } from 'node:crypto' +import { rmSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import type { Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { + ensureTerminalVisible, + getAllWorktreeIds, + switchToWorktree, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { + getTerminalContent, + sendToTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' +import { waitForPtyShellEcho } from './terminal-pty-readiness' + +/** + * Repro for: a clickable terminal link (file path, URL, …) becomes dead after + * switching to another worktree and returning, until the user scrolls the + * terminal a little. + * + * xterm's Linkifier only re-runs link providers on mousemove when the hovered + * buffer cell changes vs its `_lastBufferCell` cache. Hiding the surface fires + * mouseleave (clearing the current link) but leaves that cache, so returning + * the pointer to the same cell short-circuits and the link is never + * re-established — `currentLink` stays null. File-path links are the worst case + * because their geometry click fallback does not compensate after reveal. + */ +type HoverProbe = { col: number; row: number; tabId: string } + +async function locateHoverProbe(page: Page, needle: string): Promise { + return page.evaluate((needle) => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId ?? null + const tabId = + state?.activeTabType === 'terminal' + ? (state?.activeTabId ?? null) + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!tabId || !pane) { + throw new Error('active terminal pane unavailable') + } + const terminal = pane.terminal + const buffer = terminal.buffer.active + let hit: { row: number; col: number } | null = null + for (let row = 0; row < terminal.rows; row += 1) { + const line = buffer.getLine(buffer.viewportY + row) + if (!line) { + continue + } + const idx = line.translateToString(true).indexOf(needle) + if (idx >= 0) { + hit = { row, col: idx } + break + } + } + if (!hit) { + throw new Error('link text not visible in terminal viewport') + } + const screen = terminal.element?.querySelector('.xterm-screen') + if (!screen) { + throw new Error('xterm-screen element unavailable') + } + // Aim at the middle of the link text so the pointer lands squarely inside + // the link range regardless of rounding. + return { + col: hit.col + Math.floor(needle.length / 2), + row: hit.row, + tabId + } + }, needle) +} + +/** + * Dispatch a hover mousemove at the probe coordinates and return the text of + * the link the linkifier considers active (or null). Callers poll this because + * Orca's file-path provider resolves link candidates asynchronously. + */ +async function hoverAndReadActiveLinkText(page: Page, probe: HoverProbe): Promise { + await page.evaluate(({ col, row, tabId }) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + const screen = pane?.terminal.element?.querySelector('.xterm-screen') + if (!pane || !screen) { + throw new Error('xterm-screen element unavailable') + } + const rect = screen.getBoundingClientRect() + const clientX = rect.left + (col + 0.5) * (rect.width / pane.terminal.cols) + const clientY = rect.top + (row + 0.5) * (rect.height / pane.terminal.rows) + screen.dispatchEvent( + new MouseEvent('mousemove', { bubbles: true, cancelable: true, clientX, clientY }) + ) + }, probe) + return page.evaluate(({ tabId }) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + const core = pane?.terminal as unknown as + | { _core?: { linkifier?: { currentLink?: { link?: { text?: string } } } } } + | undefined + return core?._core?.linkifier?.currentLink?.link?.text ?? null + }, probe) +} + +async function readTerminalCursor(page: Page, tabId: string): Promise { + return page.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + const screen = pane?.terminal.element?.querySelector('.xterm-screen') + return screen ? getComputedStyle(screen).cursor : null + }, tabId) +} + +async function isTerminalSurfaceVisible(page: Page, tabId: string): Promise { + return page.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + return Boolean(pane?.container.isConnected && pane.container.getClientRects().length > 0) + }, tabId) +} + +async function activateHoveredLink(page: Page, probe: HoverProbe): Promise { + await page.evaluate(({ col, row, tabId }) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + const screen = pane?.terminal.element?.querySelector('.xterm-screen') + if (!pane || !screen) { + throw new Error('xterm-screen element unavailable') + } + const rect = screen.getBoundingClientRect() + const clientX = rect.left + (col + 0.5) * (rect.width / pane.terminal.cols) + const clientY = rect.top + (row + 0.5) * (rect.height / pane.terminal.rows) + const isMac = navigator.userAgent.includes('Mac') + const modifier = { metaKey: isMac, ctrlKey: !isMac } + screen.dispatchEvent( + new MouseEvent('mousedown', { + bubbles: true, + cancelable: true, + button: 0, + buttons: 1, + clientX, + clientY, + ...modifier + }) + ) + screen.dispatchEvent( + new MouseEvent('mouseup', { + bubbles: true, + cancelable: true, + button: 0, + clientX, + clientY, + ...modifier + }) + ) + }, probe) +} + +async function dispatchScreenMouseLeave(page: Page, tabId: string): Promise { + await page.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + const screen = pane?.terminal.element?.querySelector('.xterm-screen') + // Mimics the pointer leaving as the surface hides on a worktree switch: + // clears the linkifier's current link but keeps its cell cache. + screen?.dispatchEvent(new MouseEvent('mouseleave', { bubbles: false, cancelable: true })) + }, tabId) +} + +async function activeWorktreePath(page: Page): Promise { + return page.evaluate(() => { + const state = window.__store?.getState() + const id = state?.activeWorktreeId + return ( + Object.values(state?.worktreesByRepo ?? {}) + .flat() + .find((w) => w.id === id)?.path ?? '' + ) + }) +} + +/** + * Drive the full repro and assert the link re-establishes on hover after + * returning to the worktree. `contains` accommodates the file provider's + * display text differing slightly from the raw echoed token. + */ +async function assertLinkRecoversAfterReturn( + page: Page, + args: { + firstWorktreeId: string + secondWorktreeId: string + needle: string + expectContains: string + } +): Promise { + const probe = await locateHoverProbe(page, args.needle) + + // Baseline: hovering establishes the link before any switch. + await expect + .poll(() => hoverAndReadActiveLinkText(page, probe), { + timeout: 5_000, + message: 'baseline hover never established the link' + }) + .toContain(args.expectContains) + + await dispatchScreenMouseLeave(page, probe.tabId) + await switchToWorktree(page, args.secondWorktreeId) + await waitForActiveTerminalManager(page, 30_000) + // Wait for React to commit the intermediate hidden surface; switching back + // before that commit would batch away the lifecycle transition under test. + await expect.poll(() => isTerminalSurfaceVisible(page, probe.tabId)).toBe(false) + + await switchToWorktree(page, args.firstWorktreeId) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + await expect.poll(() => isTerminalSurfaceVisible(page, probe.tabId)).toBe(true) + + // Hover the SAME cell without scrolling. Pre-fix this never re-establishes + // the link (dead until a scroll); post-fix the reveal reset re-linkifies. + await expect + .poll(() => hoverAndReadActiveLinkText(page, probe), { + timeout: 5_000, + message: 'link did not re-establish on hover after returning to the worktree' + }) + .toContain(args.expectContains) + + // The pointer cursor is the user-visible hover affordance; currentLink is + // also checked above because it is the backing state xterm requires to click. + await expect.poll(() => readTerminalCursor(page, probe.tabId)).toBe('pointer') + return probe +} + +test.describe('Terminal link hover after worktree return', () => { + test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + }) + + test('re-establishes a file-path link on hover after switching worktrees and back', async ({ + orcaPage + }) => { + const firstWorktreeId = await waitForActiveWorktree(orcaPage) + const secondWorktreeId = (await getAllWorktreeIds(orcaPage)).find( + (id) => id !== firstWorktreeId + ) + test.skip(!secondWorktreeId, 'link-hover repro needs the seeded secondary worktree') + if (!secondWorktreeId) { + return + } + + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const ptyId = await waitForActivePanePtyId(orcaPage) + await waitForPtyShellEcho(orcaPage, ptyId, 15_000) + + const worktreePath = await activeWorktreePath(orcaPage) + const fileName = `orca-linkfile-${randomUUID().slice(0, 8)}.txt` + const filePath = path.join(worktreePath, fileName) + writeFileSync(filePath, 'orca file link target\n') + const needle = `./${fileName}` + + try { + await sendToTerminal(orcaPage, ptyId, `echo ${needle}\r`) + await expect + .poll(() => getTerminalContent(orcaPage, 4000), { + timeout: 10_000, + message: 'file-link fixture did not reach the terminal buffer' + }) + .toContain(fileName) + + const probe = await assertLinkRecoversAfterReturn(orcaPage, { + firstWorktreeId, + secondWorktreeId, + needle, + expectContains: fileName + }) + await activateHoveredLink(orcaPage, probe) + // The editor header is the user-visible result of a successful terminal + // link activation; store state alone could pass with a blank editor. + await expect(orcaPage.locator('.editor-header-path').first()).toContainText(fileName, { + timeout: 20_000 + }) + } finally { + await orcaPage.evaluate((filePath) => { + const state = window.__store?.getState() + if (state?.openFiles.some((file) => file.filePath === filePath)) { + state.closeFile(filePath) + } + }, filePath) + rmSync(filePath, { force: true }) + } + }) + + test('re-establishes a URL link on hover after switching worktrees and back', async ({ + orcaPage + }) => { + const firstWorktreeId = await waitForActiveWorktree(orcaPage) + const secondWorktreeId = (await getAllWorktreeIds(orcaPage)).find( + (id) => id !== firstWorktreeId + ) + test.skip(!secondWorktreeId, 'link-hover repro needs the seeded secondary worktree') + if (!secondWorktreeId) { + return + } + + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const ptyId = await waitForActivePanePtyId(orcaPage) + await waitForPtyShellEcho(orcaPage, ptyId, 15_000) + + const url = `https://example.com/orca-link-${randomUUID()}` + await sendToTerminal(orcaPage, ptyId, `echo ${url}\r`) + await expect + .poll(() => getTerminalContent(orcaPage, 4000), { + timeout: 10_000, + message: 'URL fixture did not reach the terminal buffer' + }) + .toContain(url) + + await assertLinkRecoversAfterReturn(orcaPage, { + firstWorktreeId, + secondWorktreeId, + needle: url, + expectContains: url + }) + }) +}) From 0702e3dd4c915010d07280fdf3ec8380f369f6e1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:00:09 -0700 Subject: [PATCH 089/351] Fix paired-host manual project header ordering (#8919) * Fix paired-host project header reordering * fix(sidebar): keep unchanged paired project drops inert --- .../src/components/sidebar/WorktreeList.tsx | 9 ++--- .../project-header-drag-commit.test.ts | 36 +++++++++++++++++++ .../sidebar/project-header-drag-commit.ts | 13 ++++--- .../sidebar/project-header-drop.test.ts | 24 +++++++++++++ .../components/sidebar/project-header-drop.ts | 32 +++++++++++++---- .../repos-host-identity-routing.test.ts | 36 +++++++++++++++++++ 6 files changed, 135 insertions(+), 15 deletions(-) diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 6c3dcad28312..81671c206166 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -142,7 +142,10 @@ import { type ScrollToCurrentWorkspaceRevealRequestDetail } from '@/lib/scroll-to-current-workspace-status' import { isRepoHeaderActionTarget, useRepoHeaderDrag } from './project-header-drag' -import { getSidebarOrderedRepoHeaderIdsByBucket } from './project-header-drop' +import { + getLogicalRepoOrderRankById, + getSidebarOrderedRepoHeaderIdsByBucket +} from './project-header-drop' import { useProjectGroupHeaderDrag } from './project-group-header-drag' import { getSidebarOrderedProjectGroupHeaderIdsByBucket } from './project-group-header-drop' import { @@ -5670,9 +5673,7 @@ const WorktreeList = React.memo(function WorktreeList({ }) }, [defaultHostId, folderWorkspaces, projectGroups, visibleHostIdSet]) const repoOrder = useMemo(() => { - const map = new Map() - repos.forEach((r, i) => map.set(r.id, i)) - return map + return getLogicalRepoOrderRankById(repos.map((repo) => repo.id)) }, [repos]) const [importedWorktreeCardActionState, setImportedWorktreeCardActionState] = useState< Map diff --git a/src/renderer/src/components/sidebar/project-header-drag-commit.test.ts b/src/renderer/src/components/sidebar/project-header-drag-commit.test.ts index 70a40d6d250c..7b5a72def55a 100644 --- a/src/renderer/src/components/sidebar/project-header-drag-commit.test.ts +++ b/src/renderer/src/components/sidebar/project-header-drag-commit.test.ts @@ -53,6 +53,42 @@ describe('commitProjectHeaderDragDrop', () => { expect(onCommitRepoOrder).toHaveBeenCalledWith(['c', 'a', 'b']) }) + it('moves a merged paired-host header upward as one stable block', () => { + const onCommitRepoOrder = vi.fn() + const repos = [makeRepo('b'), makeRepo('same'), makeRepo('c')] + const repoById = new Map(repos.map((repo) => [repo.id, repo])) + + commitProjectHeaderDragDrop({ + session: makeSession('same', ['b', 'same', 'c']), + sidebarDropIndex: 0, + orderedRepoIds: ['b', 'same', 'c', 'same'], + repoById, + usesProjectGroupOrdering: false, + onCommitRepoOrder, + onCommitProjectGroupOrder: vi.fn() + }) + + expect(onCommitRepoOrder).toHaveBeenCalledWith(['same', 'same', 'b', 'c']) + }) + + it('does not reorder host occurrences when a merged header stays in place', () => { + const onCommitRepoOrder = vi.fn() + const repos = [makeRepo('b'), makeRepo('same'), makeRepo('c')] + const repoById = new Map(repos.map((repo) => [repo.id, repo])) + + commitProjectHeaderDragDrop({ + session: makeSession('same', ['b', 'same', 'c']), + sidebarDropIndex: 2, + orderedRepoIds: ['b', 'same', 'c', 'same'], + repoById, + usesProjectGroupOrdering: false, + onCommitRepoOrder, + onCommitProjectGroupOrder: vi.fn() + }) + + expect(onCommitRepoOrder).not.toHaveBeenCalled() + }) + it('commits projectGroupOrder when project groups are present', () => { const onCommitProjectGroupOrder = vi.fn() const repos = [ diff --git a/src/renderer/src/components/sidebar/project-header-drag-commit.ts b/src/renderer/src/components/sidebar/project-header-drag-commit.ts index 43c513905a68..2ab63663d713 100644 --- a/src/renderer/src/components/sidebar/project-header-drag-commit.ts +++ b/src/renderer/src/components/sidebar/project-header-drag-commit.ts @@ -1,5 +1,6 @@ import { applyAllRepoInsertAt, + getLogicalRepoOrderRankById, getProjectGroupOrderForSidebarDrop, mapSidebarProjectHeaderDropIndexToSiblingInsertIndex, mapSidebarRepoDropIndexToAllRepoInsertAt @@ -23,7 +24,13 @@ export function commitProjectHeaderDragDrop(args: { const sidebarRepoHeaderIds = args.session.sidebarRepoHeaderIds const sourceIndex = sidebarRepoHeaderIds.indexOf(args.session.repoId) - if (args.sidebarDropIndex === sourceIndex) { + // Why: both slots bordering the dragged header are visual no-ops. In + // particular, do not compact paired-host occurrences on an unchanged drop. + if ( + sourceIndex === -1 || + args.sidebarDropIndex === sourceIndex || + args.sidebarDropIndex === sourceIndex + 1 + ) { return } @@ -46,9 +53,7 @@ export function commitProjectHeaderDragDrop(args: { if (siblingDropIndex === sourceIndexInSiblings) { return } - const repoOrderRankById = new Map( - args.orderedRepoIds.map((repoId, index) => [repoId, index] as const) - ) + const repoOrderRankById = getLogicalRepoOrderRankById(args.orderedRepoIds) const order = getProjectGroupOrderForSidebarDrop({ siblings, dropIndex: siblingDropIndex, diff --git a/src/renderer/src/components/sidebar/project-header-drop.test.ts b/src/renderer/src/components/sidebar/project-header-drop.test.ts index dee5572e02fb..c0382a5f162a 100644 --- a/src/renderer/src/components/sidebar/project-header-drop.test.ts +++ b/src/renderer/src/components/sidebar/project-header-drop.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest' import { applyAllRepoInsertAt, computeProjectHeaderDropPreview, + getLogicalRepoOrderRankById, getProjectGroupOrderForSidebarDrop, getProjectHeaderDragBucketKey, getSidebarOrderedRepoHeaderIdsByBucket, @@ -53,6 +54,20 @@ describe('getSidebarOrderedRepoHeaderIdsByBucket', () => { }) }) +describe('getLogicalRepoOrderRankById', () => { + it('anchors a merged paired-host header to its first persisted occurrence', () => { + const rankById = getLogicalRepoOrderRankById(['b', 'same', 'c', 'same']) + + expect(rankById).toEqual( + new Map([ + ['b', 0], + ['same', 1], + ['c', 2] + ]) + ) + }) +}) + describe('mapSidebarRepoDropIndexToAllRepoInsertAt', () => { const sidebar = ['a', 'b', 'c'] @@ -320,6 +335,15 @@ describe('applyAllRepoInsertAt', () => { ]) }) + it('moves duplicate host occurrences as one stable logical-project block', () => { + expect(applyAllRepoInsertAt(['b', 'same', 'c', 'same'], 'same', 0)).toEqual([ + 'same', + 'same', + 'b', + 'c' + ]) + }) + it('returns null for no-op reorders', () => { expect(applyAllRepoInsertAt(['a', 'b', 'c'], 'b', 2)).toBeNull() }) diff --git a/src/renderer/src/components/sidebar/project-header-drop.ts b/src/renderer/src/components/sidebar/project-header-drop.ts index 00b7169b97e6..be012eb72cef 100644 --- a/src/renderer/src/components/sidebar/project-header-drop.ts +++ b/src/renderer/src/components/sidebar/project-header-drop.ts @@ -54,6 +54,20 @@ export function getSidebarOrderedRepoHeaderIdsByBucket( return buckets } +export function getLogicalRepoOrderRankById( + orderedRepoIds: readonly string[] +): Map { + const rankById = new Map() + orderedRepoIds.forEach((repoId, index) => { + // Why: paired hosts can contribute the same logical project ID; its merged + // header must anchor to the first occurrence instead of whichever host loaded last. + if (!rankById.has(repoId)) { + rankById.set(repoId, index) + } + }) + return rankById +} + export function getProjectGroupOrderForSidebarDrop(args: { siblings: readonly Repo[] dropIndex: number @@ -203,16 +217,20 @@ export function applyAllRepoInsertAt( draggedRepoId: string, insertAt: number ): string[] | null { - const fromIndex = allRepoIds.indexOf(draggedRepoId) - if (fromIndex === -1 || insertAt < 0 || insertAt > allRepoIds.length) { + if (!allRepoIds.includes(draggedRepoId) || insertAt < 0 || insertAt > allRepoIds.length) { return null } - const next = allRepoIds.slice() - next.splice(fromIndex, 1) - const adjustedInsertAt = insertAt > fromIndex ? insertAt - 1 : insertAt - if (adjustedInsertAt === fromIndex) { + // Why: one merged header controls every host-qualified occurrence; moving + // them together prevents a drag from persisting a cross-host split project. + const draggedBlock = allRepoIds.filter((repoId) => repoId === draggedRepoId) + const removedBeforeInsert = allRepoIds + .slice(0, insertAt) + .filter((repoId) => repoId === draggedRepoId).length + const adjustedInsertAt = insertAt - removedBeforeInsert + const next = allRepoIds.filter((repoId) => repoId !== draggedRepoId) + next.splice(adjustedInsertAt, 0, ...draggedBlock) + if (next.every((repoId, index) => repoId === allRepoIds[index])) { return null } - next.splice(adjustedInsertAt, 0, draggedRepoId) return next } diff --git a/src/renderer/src/store/slices/repos-host-identity-routing.test.ts b/src/renderer/src/store/slices/repos-host-identity-routing.test.ts index 449290d81f42..23325aa73e91 100644 --- a/src/renderer/src/store/slices/repos-host-identity-routing.test.ts +++ b/src/renderer/src/store/slices/repos-host-identity-routing.test.ts @@ -496,6 +496,42 @@ describe('repo slice host identity routing', () => { }) }) + it('persists a moved paired-host project block without reversing its host occurrences', async () => { + reposReorderForHost.mockResolvedValue({ status: 'applied' }) + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-paired-project-reorder', + ok: true, + result: { status: 'applied' }, + _meta: { runtimeId: 'runtime-remote' } + }) + const bravo = { ...localDuplicate, id: 'bravo' } + const charlie = { ...remoteDuplicate, id: 'charlie' } + const store = createTestStore() + store.setState({ repos: [bravo, localDuplicate, charlie, remoteDuplicate] }) + + await store.getState().reorderRepos(['same-repo', 'same-repo', 'bravo', 'charlie']) + + expect(store.getState().repos).toEqual([localDuplicate, remoteDuplicate, bravo, charlie]) + expect(uiSet).toHaveBeenCalledWith({ + manualRepoOrder: [ + { hostId: 'local', repoId: 'same-repo' }, + { hostId: 'runtime:env-1', repoId: 'same-repo' }, + { hostId: 'local', repoId: 'bravo' }, + { hostId: 'runtime:env-1', repoId: 'charlie' } + ] + }) + expect(reposReorderForHost).toHaveBeenCalledWith({ + hostId: 'local', + orderedIds: ['same-repo', 'bravo'] + }) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'repo.reorder', + params: { orderedIds: ['same-repo', 'charlie'] }, + timeoutMs: 15_000 + }) + }) + it('persists a complete cross-host overlay alongside host-local permutations', async () => { reposReorderForHost.mockResolvedValue({ status: 'applied' }) runtimeEnvironmentCall.mockResolvedValue({ From de9e9f8f1b3e2c3215181b4df6ec94d1013d4b1e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 22:01:28 +0000 Subject: [PATCH 090/351] release: v1.4.144-rc.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c7e41d8ce0bd..292873687f18 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.144-rc.1", + "version": "1.4.144-rc.2", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From dd0f4c39c8fdf9c32afa2fbda61ab363c414886b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:18:58 -0700 Subject: [PATCH 091/351] fix(pi): stop OMP tab icon flashing to Pi while working (#9077) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OMP runs as a `shell → omp → pi` process tree and Orca recognizes both `omp` and `pi` as distinct agents, so the foreground-process reader alternates between reporting the two across command boundaries. In `resolveTabAgentFromSignals` every identity signal is re-owned onto the pane's durable owner within its title-identity group (pi and omp share one) — except the process signal, which was consumed raw. Because it ranks above launchAgent, each `pi` read repainted an OMP-owned tab's icon to the Pi glyph and the next read flipped it back. Re-own the process signal the same way the hook and title signals already are, so a same-group read collapses onto the owner while a genuine cross-group process (e.g. Codex) still stands and can reclaim a reused pane. The tab title text was unaffected — its owner resolution already excludes the process signal — so this is icon-only. Adds regression coverage for the oscillation, the mirrored/restored pane (launchAgent gone, durable hook record), and the cross-group scope guard. --- .../src/lib/use-tab-agent-pi-identity.test.ts | 58 +++++++++++++++++++ src/renderer/src/lib/use-tab-agent.ts | 11 +++- 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/lib/use-tab-agent-pi-identity.test.ts b/src/renderer/src/lib/use-tab-agent-pi-identity.test.ts index abe494ee174d..2dea897814f1 100644 --- a/src/renderer/src/lib/use-tab-agent-pi-identity.test.ts +++ b/src/renderer/src/lib/use-tab-agent-pi-identity.test.ts @@ -132,6 +132,64 @@ describe('resolveTabAgentFromSignals — Pi/OMP identity', () => { expect(afterHookCleared).toBe('omp') }) + it('does not flap between OMP and Pi as the foreground process oscillates', () => { + // The reported flicker: OMP wraps Pi (`shell → omp → pi`), so the foreground + // reader alternates between reporting `omp` and `pi` at command boundaries. + // The process signal outranks launchAgent, so without owner-normalization the + // OMP-owned tab's icon flips to Pi on every `pi` read. Both reads must land on + // the launched owner. + const readsPi = resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: false, + title: '⠋ OMP', + hookAgent: null, + processAgent: 'pi', + launchAgent: 'omp' + }) + const readsOmp = resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: false, + title: '⠋ OMP', + hookAgent: null, + processAgent: 'omp', + launchAgent: 'omp' + }) + expect(readsPi).toBe('omp') + expect(readsOmp).toBe('omp') + }) + + it('re-owns a Pi foreground read to a durable OMP identity when launchAgent is gone', () => { + // A mirrored/restored OMP pane keeps only its completed-hook identity; a `pi` + // foreground read (OMP's nested child) must not repaint it to Pi. + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: false, + title: '⠋ OMP', + hookAgent: null, + focusedCompletedHookAgent: 'omp', + processAgent: 'pi', + launchAgent: undefined + }) + ).toBe('omp') + }) + + it('still lets a genuine cross-group foreground process reclaim a reused OMP pane', () => { + // Scope guard: a different-group process (Codex is not Pi-compatible) is + // real-time proof the pane was reused, so it overrides the OMP launch owner + // instead of collapsing onto it. + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: false, + title: 'zsh', + hookAgent: null, + processAgent: 'codex', + launchAgent: 'omp' + }) + ).toBe('codex') + }) + it('keeps a launchAgent-less Pi pane on Pi and rejects a stale OMP session record', () => { // The fallback must not over-reach: a genuine Pi pane (recent Pi hook) stays // Pi even if a stale hibernated OMP record is present. diff --git a/src/renderer/src/lib/use-tab-agent.ts b/src/renderer/src/lib/use-tab-agent.ts index 93051d41d4fd..b75273befbeb 100644 --- a/src/renderer/src/lib/use-tab-agent.ts +++ b/src/renderer/src/lib/use-tab-agent.ts @@ -168,7 +168,13 @@ export function resolveTabAgentFromSignals(args: { processShellForeground: args.processShellForeground }) const activeLaunchAgent = launchedAgentExited ? null : launchAgent - const processAgent = args.processAgent ?? null + // Why: the foreground process, like the hook and title signals, must be + // re-owned within its title-identity group. OMP wraps Pi as `shell → omp → pi`, + // so the foreground reader oscillates between reporting `omp` and `pi` across + // command boundaries; taken raw it outranks launchAgent and flips an OMP-owned + // tab's icon between the two glyphs. Re-owning collapses the same-group read + // onto the durable owner while a genuine cross-group process still stands. + const processAgent = resolveSignalAgentForLaunchOwner(args.processAgent, owner) const sleepingSessionAgent = args.sleepingSessionAgent ?? null // Identity-first precedence. The live focused hook is ground truth while the // agent works; process identity covers agents with neither hook nor title; the @@ -199,7 +205,8 @@ export function resolveTabAgentFromSignals(args: { * 2. Process identity — the recognized foreground process, read at OSC 133 * command boundaries (local panes only); covers agents that emit neither * hooks nor titles, and its shell-foreground mark is title-independent exit - * evidence. + * evidence. Re-owned within its title-identity group, so OMP's nested `pi` + * child (the `shell → omp → pi` tree) cannot flip the icon back to Pi. * 3. Title — only as a reuse override (it names a DIFFERENT-group agent than the * pane's known identity, proving reuse) or as a legacy standalone identity * when the pane has no hook. Within the same title-identity group it carries From 8461df213916c8fbfc68a893cd8f0ebb783e948a Mon Sep 17 00:00:00 2001 From: BingZ Date: Fri, 17 Jul 2026 07:38:20 +0800 Subject: [PATCH 092/351] Fix Ctrl+Alt terminal input on Windows by repairing xterm's AltGr misclassification (#8810) * fix(terminal): forward Windows Ctrl Alt chords * fix(terminal): route rescued Windows Ctrl+Alt chords through xterm's own key encoders Replace the hand-rolled Alt-prefix encoder in the custom key handler with a narrow repair of xterm's third-level-shift classification. xterm's keyboard service already computes the correct bytes for every input protocol (legacy ESC-prefixed, kitty CSI-u, win32-input-mode) before _isThirdLevelShift discards them on Windows Ctrl+Alt; rescuing only provably-genuine chords (Chromium's layout-wide AltGraph simulation, crbug 762557) lets those encoders deliver protocol-correct, layout-aware bytes with no duplicated encoding knowledge in Orca. Fixes vs the previous approach: kitty-mode TUIs now receive CSI-u instead of legacy bytes, digits/punctuation no longer alias to plain Alt chords, letters follow the logical layout (Dvorak/Colemak), Ctrl+Alt+Shift and Ctrl+Alt+F-keys gain Linux parity, and handled keys get xterm's stock preventDefault/stopPropagation. Firefox web clients keep stock behavior; a real-Terminal contract test fails loudly if an xterm upgrade removes the seam, degrading at runtime to the historical dropped-chord behavior. Co-authored-by: Orca * Refactor Windows Ctrl+Alt chord test helpers and clarify AltGraph commen - Extract a shared getCore() helper in the test file to dedupe repeated `_core` casts across third-level-shift and keyboard-service lookups. - Correct the AltGraph comment: Chromium simulates AltGraph per composing keypress, not for the whole Ctrl+Alt press duration. - Warn via console when xterm no longer exposes `_core._isThirdLevelShift`, so a silent classification-repair failure is diagnosable in the wild. * Clarify comment explaining why Windows Ctrl+Alt chords bypass AltGraph c The comment previously implied Chromium always sets AltGraph=true for composable chords; the revised wording states the actual mechanism (Alt+Ctrl modifiers get replaced by AltGraph) so the inverse case is unambiguous. --------- Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-authored-by: Orca --- .../src/lib/pane-manager/pane-dom-creation.ts | 2 + ...dows-ctrl-alt-chord-classification.test.ts | 185 ++++++++++++++++++ ...l-windows-ctrl-alt-chord-classification.ts | 76 +++++++ tests/e2e/terminal-shortcuts.spec.ts | 72 ++----- 4 files changed, 281 insertions(+), 54 deletions(-) create mode 100644 src/renderer/src/lib/pane-manager/terminal-windows-ctrl-alt-chord-classification.test.ts create mode 100644 src/renderer/src/lib/pane-manager/terminal-windows-ctrl-alt-chord-classification.ts 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 365bfd80178f..9d1038c45808 100644 --- a/src/renderer/src/lib/pane-manager/pane-dom-creation.ts +++ b/src/renderer/src/lib/pane-manager/pane-dom-creation.ts @@ -13,6 +13,7 @@ import { buildDefaultTerminalOptions } from './pane-terminal-options' import { shouldFocusTerminalFromPanePointerDown } from './pane-pointer-focus' import { ENABLE_WEBGL_RENDERER } from './pane-webgl-renderer' import { installGuardedLinkProviderRegistration } from './terminal-link-provider-guard' +import { installWindowsCtrlAltChordRepair } from './terminal-windows-ctrl-alt-chord-classification' function getTerminalUrlOpenHint(): string { return navigator.userAgent.includes('Mac') @@ -55,6 +56,7 @@ export function createPaneDOM( // line) escapes to window.onerror and gets the renderer killed. Guard every // provider registered after this point — addon-internal and Orca's own. installGuardedLinkProviderRegistration(terminal) + installWindowsCtrlAltChordRepair(terminal) const fitAddon = new FitAddon() const searchAddon = new SearchAddon() const unicode11Addon = new Unicode11Addon() diff --git a/src/renderer/src/lib/pane-manager/terminal-windows-ctrl-alt-chord-classification.test.ts b/src/renderer/src/lib/pane-manager/terminal-windows-ctrl-alt-chord-classification.test.ts new file mode 100644 index 000000000000..d0d9f92ef4ac --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-windows-ctrl-alt-chord-classification.test.ts @@ -0,0 +1,185 @@ +// @vitest-environment happy-dom + +import { describe, expect, it } from 'vitest' +import { Terminal } from '@xterm/xterm' +import { + installWindowsCtrlAltChordRepair, + isGenuineWindowsCtrlAltChord, + shouldRepairWindowsCtrlAltChords +} from './terminal-windows-ctrl-alt-chord-classification' + +const WINDOWS_ELECTRON_UA = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) ' + + 'orca/1.0.0 Chrome/126.0.0.0 Electron/31.0.0 Safari/537.36' +const WINDOWS_FIREFOX_UA = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:127.0) Gecko/20100101 Firefox/127.0' +const MAC_ELECTRON_UA = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) ' + + 'orca/1.0.0 Chrome/126.0.0.0 Electron/31.0.0 Safari/537.36' + +type ClassificationEvent = { + type: string + keyCode: number + ctrlKey: boolean + altKey: boolean + metaKey: boolean + shiftKey?: boolean + getModifierState?: (keyArg: string) => boolean +} + +function chord(overrides: Partial = {}): ClassificationEvent { + return { + type: 'keydown', + keyCode: 85, + ctrlKey: true, + altKey: true, + metaKey: false, + getModifierState: () => false, + ...overrides + } +} + +type CoreThirdLevelShift = ( + browser: { isMac?: boolean; isWindows?: boolean }, + event: ClassificationEvent +) => boolean + +type XtermCoreInternals = { + _isThirdLevelShift?: CoreThirdLevelShift + _keyboardService?: { evaluateKeyDown?: (event: unknown) => { key?: string } | undefined } + coreService?: { kittyKeyboard?: { flags: number } } +} + +function getCore(terminal: Terminal): XtermCoreInternals { + return (terminal as unknown as { _core?: XtermCoreInternals })._core ?? {} +} + +function getThirdLevelShift(terminal: Terminal): CoreThirdLevelShift { + const core = getCore(terminal) + const classify = core._isThirdLevelShift + if (typeof classify !== 'function') { + throw new Error('xterm no longer exposes _core._isThirdLevelShift') + } + return classify.bind(core) +} + +describe('isGenuineWindowsCtrlAltChord', () => { + it('accepts Ctrl+Alt chords whose AltGraph state is false', () => { + expect(isGenuineWindowsCtrlAltChord(chord())).toBe(true) + expect(isGenuineWindowsCtrlAltChord(chord({ shiftKey: true }))).toBe(true) + // Synthetic events without getModifierState cannot be AltGr composition. + expect(isGenuineWindowsCtrlAltChord(chord({ getModifierState: undefined }))).toBe(true) + }) + + it('rejects AltGr composition and non-Ctrl+Alt chords', () => { + expect( + isGenuineWindowsCtrlAltChord(chord({ getModifierState: (key) => key === 'AltGraph' })) + ).toBe(false) + expect(isGenuineWindowsCtrlAltChord(chord({ metaKey: true }))).toBe(false) + expect(isGenuineWindowsCtrlAltChord(chord({ altKey: false }))).toBe(false) + expect(isGenuineWindowsCtrlAltChord(chord({ ctrlKey: false }))).toBe(false) + }) +}) + +describe('shouldRepairWindowsCtrlAltChords', () => { + it('repairs only Windows Chromium clients', () => { + expect(shouldRepairWindowsCtrlAltChords(WINDOWS_ELECTRON_UA)).toBe(true) + // Why: Firefox does not rewrite composing Ctrl+Alt presses to AltGraph, so + // a false AltGraph state there does not prove the chord is genuine. + expect(shouldRepairWindowsCtrlAltChords(WINDOWS_FIREFOX_UA)).toBe(false) + expect(shouldRepairWindowsCtrlAltChords(MAC_ELECTRON_UA)).toBe(false) + }) +}) + +describe('installWindowsCtrlAltChordRepair', () => { + it('finds the internal classification seam on the real Terminal', () => { + // Upgrade tripwire: if a future xterm rename removes the seam, this fails + // loudly instead of silently reverting to dropped Ctrl+Alt chords. + const terminal = new Terminal() + try { + expect(getThirdLevelShift(terminal)({ isWindows: true }, chord())).toBe(true) + expect(installWindowsCtrlAltChordRepair(terminal, WINDOWS_ELECTRON_UA)).toBe(true) + } finally { + terminal.dispose() + } + }) + + it('reclassifies only genuine Windows Ctrl+Alt chords', () => { + const terminal = new Terminal() + try { + installWindowsCtrlAltChordRepair(terminal, WINDOWS_ELECTRON_UA) + const classify = getThirdLevelShift(terminal) + const windows = { isWindows: true } + expect(classify(windows, chord())).toBe(false) + expect(classify(windows, chord({ shiftKey: true }))).toBe(false) + // AltGr composition keeps xterm's third-level-shift handling. + expect(classify(windows, chord({ getModifierState: (key) => key === 'AltGraph' }))).toBe(true) + // macOS option-as-third-level-shift is untouched. + expect( + classify({ isMac: true }, chord({ ctrlKey: false, getModifierState: () => false })) + ).toBe(true) + } finally { + terminal.dispose() + } + }) + + it('declines on clients without trustworthy AltGraph state', () => { + const terminal = new Terminal() + try { + expect(installWindowsCtrlAltChordRepair(terminal, WINDOWS_FIREFOX_UA)).toBe(false) + expect(getThirdLevelShift(terminal)({ isWindows: true }, chord())).toBe(true) + } finally { + terminal.dispose() + } + }) +}) + +// Why: the repair intentionally adds no encoder — rescued chords must produce +// whatever bytes xterm's own keyboard service computes for the protocol the +// foreground app negotiated. These pin that contract for both protocol tiers. +describe('rescued chords are encoded by xterm, not Orca', () => { + function keyDownChord(overrides: Record): Record { + return { ...chord(), repeat: false, ...overrides } + } + + function getEvaluateKeyDown( + terminal: Terminal + ): (event: unknown) => { key?: string } | undefined { + const service = getCore(terminal)._keyboardService + if (typeof service?.evaluateKeyDown !== 'function') { + throw new Error('xterm no longer exposes _core._keyboardService.evaluateKeyDown') + } + return service.evaluateKeyDown.bind(service) + } + + it('legacy encoder emits Alt-prefixed bytes matching the Windows E2E', () => { + const terminal = new Terminal() + try { + installWindowsCtrlAltChordRepair(terminal, WINDOWS_ELECTRON_UA) + const evaluate = getEvaluateKeyDown(terminal) + expect(evaluate(keyDownChord({ key: 'u', code: 'KeyU', keyCode: 85 }))?.key).toBe('\x1b\x15') + expect(evaluate(keyDownChord({ key: '2', code: 'Digit2', keyCode: 50 }))?.key).toBe('\x1b2') + expect(evaluate(keyDownChord({ key: ';', code: 'Semicolon', keyCode: 186 }))?.key).toBe( + '\x1b;' + ) + } finally { + terminal.dispose() + } + }) + + it('kitty encoder takes over once the app negotiates progressive flags', () => { + const terminal = new Terminal({ vtExtensions: { kittyKeyboard: true } }) + try { + installWindowsCtrlAltChordRepair(terminal, WINDOWS_ELECTRON_UA) + const kitty = getCore(terminal).coreService?.kittyKeyboard + expect(kitty).toBeTruthy() + kitty!.flags = 1 + const evaluate = getEvaluateKeyDown(terminal) + expect(evaluate(keyDownChord({ key: 'u', code: 'KeyU', keyCode: 85 }))?.key).toBe( + '\x1b[117;7u' + ) + } finally { + terminal.dispose() + } + }) +}) diff --git a/src/renderer/src/lib/pane-manager/terminal-windows-ctrl-alt-chord-classification.ts b/src/renderer/src/lib/pane-manager/terminal-windows-ctrl-alt-chord-classification.ts new file mode 100644 index 000000000000..f73da741ad17 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-windows-ctrl-alt-chord-classification.ts @@ -0,0 +1,76 @@ +import type { Terminal } from '@xterm/xterm' + +// Why: xterm misclassifies Windows Ctrl+Alt chords as AltGr and drops the ones +// that never compose a keypress (#8734); repairing the classification lets +// xterm's own protocol-aware key encoders emit the bytes. +type ThirdLevelShiftBrowserInfo = { isWindows?: boolean } + +type ThirdLevelShiftKeyboardEvent = Pick & { + getModifierState?: (keyArg: string) => boolean +} + +type TerminalWithThirdLevelShift = { + _core?: { + _isThirdLevelShift?: ( + browser: ThirdLevelShiftBrowserInfo, + event: ThirdLevelShiftKeyboardEvent + ) => boolean + } +} + +/** + * Returns whether a Windows Ctrl+Alt chord is genuine keyboard input rather + * than AltGr composition, and must therefore reach xterm's key encoders. + * + * When a Ctrl+Alt keydown composes a printable character on the active + * layout, Chromium replaces the Control+Alt modifiers with AltGraph + * (crbug 762557), so a chord still reporting Ctrl+Alt without AltGraph + * cannot compose text. + */ +export function isGenuineWindowsCtrlAltChord(event: ThirdLevelShiftKeyboardEvent): boolean { + return ( + event.ctrlKey && event.altKey && !event.metaKey && event.getModifierState?.('AltGraph') !== true + ) +} + +/** Returns whether this client's AltGraph modifier state is trustworthy. */ +export function shouldRepairWindowsCtrlAltChords(userAgent: string): boolean { + // Why: only Chromium rewrites composing Ctrl+Alt presses to AltGraph. Paired + // web clients on Firefox keep stock classification so Ctrl+Alt-alias AltGr + // typing there is never misread as a chord. + return userAgent.includes('Windows') && userAgent.includes('Chrome/') +} + +/** + * Narrow xterm's Windows third-level-shift classification so genuine + * Ctrl+Alt chords flow into its protocol-aware key encoders instead of + * being dropped. Only ever flips a third-level verdict to false — AltGr, + * macOS option handling, and every non-Windows path are untouched. + * + * Returns false when the internal seam is unavailable (e.g. after an xterm + * upgrade), degrading to the historical drop-the-chord behavior. + */ +export function installWindowsCtrlAltChordRepair( + terminal: Terminal, + userAgent: string = navigator.userAgent +): boolean { + if (!shouldRepairWindowsCtrlAltChords(userAgent)) { + return false + } + const core = (terminal as unknown as TerminalWithThirdLevelShift)._core + const stockClassification = core?._isThirdLevelShift + if (!core || typeof stockClassification !== 'function') { + console.warn( + 'xterm no longer exposes _core._isThirdLevelShift; Windows Ctrl+Alt chords will be dropped' + ) + return false + } + core._isThirdLevelShift = function (browser, event) { + const thirdLevel = stockClassification.call(this, browser, event) + if (!thirdLevel || browser?.isWindows !== true) { + return thirdLevel + } + return !isGenuineWindowsCtrlAltChord(event) + } + return true +} diff --git a/tests/e2e/terminal-shortcuts.spec.ts b/tests/e2e/terminal-shortcuts.spec.ts index f120d53ffe42..5c921376ca54 100644 --- a/tests/e2e/terminal-shortcuts.spec.ts +++ b/tests/e2e/terminal-shortcuts.spec.ts @@ -29,60 +29,11 @@ import { focusActiveTerminalInput } from './helpers/terminal' import { waitForSessionReady, waitForActiveWorktree, ensureTerminalVisible } from './helpers/store' - -// Why: contextBridge freezes window.api so the renderer cannot spy on -// pty.write directly. Intercept in the main process instead — pty:write is an -// ipcMain.on listener, so prepending a listener lets us capture every call -// without disturbing the real handler. -async function installMainProcessPtyWriteSpy(app: ElectronApplication): Promise { - await app.evaluate(({ ipcMain }) => { - const g = globalThis as unknown as { - __ptyWriteLog?: { id: string; data: string }[] - __ptyWriteSpyInstalled?: boolean - __ptyWriteAcceptedSpyInstalled?: boolean - } - if (g.__ptyWriteSpyInstalled) { - return - } - g.__ptyWriteLog = [] - g.__ptyWriteSpyInstalled = true - ipcMain.prependListener('pty:write', (_event: unknown, args: { id: string; data: string }) => { - g.__ptyWriteLog!.push({ id: args.id, data: args.data }) - }) - const invokeHandlers = ( - ipcMain as unknown as { - _invokeHandlers?: Map< - string, - (event: unknown, args: { id: string; data: string }) => unknown - > - } - )._invokeHandlers - const writeAcceptedHandler = invokeHandlers?.get('pty:writeAccepted') - if (writeAcceptedHandler && !g.__ptyWriteAcceptedSpyInstalled) { - g.__ptyWriteAcceptedSpyInstalled = true - invokeHandlers?.set('pty:writeAccepted', (event, args) => { - g.__ptyWriteLog!.push({ id: args.id, data: args.data }) - return writeAcceptedHandler(event, args) - }) - } - }) -} - -async function clearPtyWriteLog(app: ElectronApplication): Promise { - await app.evaluate(() => { - const g = globalThis as unknown as { __ptyWriteLog?: { id: string; data: string }[] } - if (g.__ptyWriteLog) { - g.__ptyWriteLog.length = 0 - } - }) -} - -async function getPtyWrites(app: ElectronApplication): Promise { - return app.evaluate(() => { - const g = globalThis as unknown as { __ptyWriteLog?: { id: string; data: string }[] } - return (g.__ptyWriteLog ?? []).map((e) => e.data) - }) -} +import { + clearTerminalPtyWriteLog as clearPtyWriteLog, + installTerminalPtyWriteSpy as installMainProcessPtyWriteSpy, + readTerminalPtyWrites as getPtyWrites +} from './helpers/terminal-pty-write-spy' async function setActivePaneForegroundAgent( page: Page, @@ -562,6 +513,19 @@ test.describe('Terminal Shortcuts', () => { } }) + test('Windows forwards genuine Ctrl+Alt text chords to the PTY', async ({ + orcaPage, + electronApp + }) => { + test.skip(process.platform !== 'win32', 'Windows xterm AltGr classification regression') + await installMainProcessPtyWriteSpy(electronApp) + await waitForActivePanePtyId(orcaPage) + + await pressAndExpectWrite(orcaPage, electronApp, 'Control+Alt+u', '\x1b\x15') + await pressAndExpectWrite(orcaPage, electronApp, 'Control+Alt+2', '\x1b2') + await pressAndExpectWrite(orcaPage, electronApp, 'Control+Alt+;', '\x1b;') + }) + test('Ctrl+Enter writes the kitty modified-enter chord for terminal TUIs', async ({ orcaPage, electronApp From 88068f55bdad211afaf15f27470f1aa96f0dd697 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:57:08 -0700 Subject: [PATCH 093/351] Preserve native OpenCode session titles (#9080) Co-authored-by: Orca --- .../lib/agent-status-terminal-title.test.ts | 21 +++++++ src/shared/opencode-terminal-title.test.ts | 16 +++++ src/shared/opencode-terminal-title.ts | 4 ++ src/shared/synthetic-agent-title.test.ts | 8 +++ src/shared/synthetic-agent-title.ts | 9 ++- src/shared/tab-title-resolution.test.ts | 60 +++++++++++++++++++ src/shared/tab-title-resolution.ts | 9 ++- 7 files changed, 122 insertions(+), 5 deletions(-) create mode 100644 src/shared/opencode-terminal-title.test.ts create mode 100644 src/shared/opencode-terminal-title.ts diff --git a/src/renderer/src/lib/agent-status-terminal-title.test.ts b/src/renderer/src/lib/agent-status-terminal-title.test.ts index 7cba70a463b0..756dd79b9e37 100644 --- a/src/renderer/src/lib/agent-status-terminal-title.test.ts +++ b/src/renderer/src/lib/agent-status-terminal-title.test.ts @@ -58,4 +58,25 @@ describe('resolveAgentStatusTerminalTitle', () => { resolveAgentStatusTerminalTitle({ agentType: 'devin', state: 'waiting' }, '\u280b Devin') ).toBe('Devin - action required') }) + + it('preserves native OpenCode titles through hook status transitions', () => { + expect( + resolveAgentStatusTerminalTitle( + { agentType: 'opencode', state: 'done' }, + 'OC | Native Stable Session' + ) + ).toBe('OC | Native Stable Session') + expect( + resolveAgentStatusTerminalTitle( + { agentType: 'opencode', state: 'waiting' }, + 'OC | Native Stable Session' + ) + ).toBe('OC | Native Stable Session') + }) + + it('does not invent an OpenCode title when no native title exists', () => { + expect( + resolveAgentStatusTerminalTitle({ agentType: 'opencode', state: 'done' }, undefined) + ).toBeUndefined() + }) }) diff --git a/src/shared/opencode-terminal-title.test.ts b/src/shared/opencode-terminal-title.test.ts new file mode 100644 index 000000000000..fc1b7806e35a --- /dev/null +++ b/src/shared/opencode-terminal-title.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { isMeaningfulOpenCodeTerminalTitle } from './opencode-terminal-title' + +describe('OpenCode terminal titles', () => { + it('recognizes native session titles', () => { + expect(isMeaningfulOpenCodeTerminalTitle('OC | Native Stable Session')).toBe(true) + expect(isMeaningfulOpenCodeTerminalTitle(' OC|Session ')).toBe(true) + }) + + it('rejects generic or incomplete OpenCode titles', () => { + expect(isMeaningfulOpenCodeTerminalTitle('OpenCode')).toBe(false) + expect(isMeaningfulOpenCodeTerminalTitle('OpenCode ready')).toBe(false) + expect(isMeaningfulOpenCodeTerminalTitle('OC |')).toBe(false) + expect(isMeaningfulOpenCodeTerminalTitle(undefined)).toBe(false) + }) +}) diff --git a/src/shared/opencode-terminal-title.ts b/src/shared/opencode-terminal-title.ts new file mode 100644 index 000000000000..64312b94be27 --- /dev/null +++ b/src/shared/opencode-terminal-title.ts @@ -0,0 +1,4 @@ +export function isMeaningfulOpenCodeTerminalTitle(title: string | null | undefined): boolean { + // Why: bare OpenCode labels are status/identity; `OC | …` carries native session identity. + return /^OC\s*\|\s*\S/u.test(title?.trim() ?? '') +} diff --git a/src/shared/synthetic-agent-title.test.ts b/src/shared/synthetic-agent-title.test.ts index dc51838ed43b..8b7b518fa3da 100644 --- a/src/shared/synthetic-agent-title.test.ts +++ b/src/shared/synthetic-agent-title.test.ts @@ -15,6 +15,14 @@ describe('synthetic agent titles', () => { expect(shouldDriveSyntheticAgentTitleFromHook('codex', 'done')).toBe(true) }) + it('does not synthesize OpenCode titles over native session titles', () => { + expect(getSyntheticAgentTerminalTitle('opencode', 'done')).toBeNull() + expect(getSyntheticAgentTerminalTitle('opencode', 'waiting')).toBeNull() + expect(shouldDriveSyntheticAgentTitleFromHook('opencode', 'working')).toBe(false) + expect(shouldDriveSyntheticAgentTitleFromHook('opencode', 'done')).toBe(false) + expect(shouldDriveSyntheticAgentTitleFromHook('opencode', 'waiting')).toBe(false) + }) + it('provides Devin titles for hook-driven status updates', () => { expect(getSyntheticAgentTerminalTitle('devin', 'done')).toBe('Devin ready') expect(getSyntheticAgentTerminalTitle('devin', 'waiting')).toBe('Devin - action required') diff --git a/src/shared/synthetic-agent-title.ts b/src/shared/synthetic-agent-title.ts index ecac929fe94c..61229cea850e 100644 --- a/src/shared/synthetic-agent-title.ts +++ b/src/shared/synthetic-agent-title.ts @@ -5,6 +5,7 @@ export type SyntheticAgentTitleProfile = { permissionLabel: string idleLabel: string titleIdentityGroup?: string + synthesizeTerminalTitle?: boolean synthesizeWorkingTitle?: boolean } @@ -25,7 +26,9 @@ export const SYNTHETIC_AGENT_TITLE_PROFILES: Record { ).toBe('Payments') }) + it('uses meaningful native OpenCode session titles before generated titles', () => { + expect( + resolveTerminalTabTitle( + { + customTitle: null, + generatedTitle: 'Refactor auth', + title: 'OC | Native Stable Session' + }, + true + ) + ).toBe('OC | Native Stable Session') + }) + + it('keeps generated titles ahead of generic OpenCode titles', () => { + expect( + resolveTerminalTabTitle( + { customTitle: null, generatedTitle: 'Refactor auth', title: 'OpenCode' }, + true + ) + ).toBe('Refactor auth') + }) + it('places quick command labels between manual and generated titles', () => { expect( resolveTerminalTabTitle( @@ -73,4 +95,42 @@ describe('tab title resolution', () => { ) ).toBe('Run build') }) + + it('uses meaningful native OpenCode labels before generated unified labels', () => { + expect( + resolveUnifiedTabLabel( + { + customLabel: null, + generatedLabel: 'Fix flaky tests', + label: 'OC | Native Stable Session' + }, + true + ) + ).toBe('OC | Native Stable Session') + }) + + it('keeps manual and quick command labels ahead of native OpenCode labels', () => { + expect( + resolveUnifiedTabLabel( + { + customLabel: 'Manual label', + quickCommandLabel: 'Run build', + generatedLabel: 'Fix flaky tests', + label: 'OC | Native Stable Session' + }, + true + ) + ).toBe('Manual label') + expect( + resolveUnifiedTabLabel( + { + customLabel: null, + quickCommandLabel: 'Run build', + generatedLabel: 'Fix flaky tests', + label: 'OC | Native Stable Session' + }, + true + ) + ).toBe('Run build') + }) }) diff --git a/src/shared/tab-title-resolution.ts b/src/shared/tab-title-resolution.ts index d3ac33b95add..6f1d51042cee 100644 --- a/src/shared/tab-title-resolution.ts +++ b/src/shared/tab-title-resolution.ts @@ -1,15 +1,18 @@ import type { Tab, TerminalTab } from './types' +import { isMeaningfulOpenCodeTerminalTitle } from './opencode-terminal-title' export function resolveTerminalTabTitle( tab: Pick, generatedTitlesEnabled: boolean, fallback = '' ): string { + const liveTitle = tab.title?.trim() ?? '' return ( tab.customTitle?.trim() || tab.quickCommandLabel?.trim() || + (isMeaningfulOpenCodeTerminalTitle(liveTitle) ? liveTitle : '') || (generatedTitlesEnabled ? tab.generatedTitle?.trim() : '') || - tab.title?.trim() || + liveTitle || fallback ) } @@ -19,11 +22,13 @@ export function resolveUnifiedTabLabel( generatedTitlesEnabled: boolean, fallback = '' ): string { + const liveLabel = tab?.label?.trim() ?? '' return ( tab?.customLabel?.trim() || tab?.quickCommandLabel?.trim() || + (isMeaningfulOpenCodeTerminalTitle(liveLabel) ? liveLabel : '') || (generatedTitlesEnabled ? tab?.generatedLabel?.trim() : '') || - tab?.label?.trim() || + liveLabel || fallback ) } From 5b75a87dd305924057b797ef131935e469efa4d8 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:12:44 -0700 Subject: [PATCH 094/351] feat(native-chat): add per-model session option pickers with verified Claude switch (#9085) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add per-model session-option pickers (model/effort/fast-mode) to native Introduces a shared agent session-option catalog (Claude/Codex/Gemini/Cursor) with model-scoped options, launch-command composition, mid-session dispatch via slash commands, and per-model persisted defaults. Wires the new NativeChatSessionOptionPickers UI into the composer, threads sessionOptions through every startup-plan builder (worktree creation, onboarding, source control actions, folder workspace, direct work-item launches), and adds localized strings and tests across the affected surfaces. * Add per-model session-option pickers with a verified Claude model switch Native chat's model/effort picker now dispatches option commands through a body-then-verified-Enter write path and, for Claude, arms a PTY observer that classifies the cached-history confirmation prompt as applied/rejected/needs interaction before the picker returns — falling back to the terminal only when Claude genuinely requires manual input, and clearing stale truth otherwise. Also reorders the composer's model/effort pills, disambiguates their tooltip labels, and switches disabled-reason strings to a closed enum so producer and localized copy can't drift. * Add live model/effort detection from Claude's TUI header - Reads the mounted xterm's main-buffer snapshot (falling back to the rendered screen when the alternate screen owns the buffer) to parse Claude's header for the currently active model and effort, so the native chat picker reflects reality instead of only dispatched state - Extracts command-apply recording and reported-value application into dedicated modules, and factors file-link click handling into a hook, to keep native-chat-pty-session-options.ts focused --- src/main/persistence.test.ts | 25 ++ .../FloatingTerminalWindowControls.tsx | 7 + .../native-chat/NativeChatComposer.test.tsx | 285 ++++++++++++++- .../native-chat/NativeChatComposer.tsx | 101 +++--- .../NativeChatComposerActions.test.tsx | 62 ++++ .../native-chat/NativeChatComposerActions.tsx | 56 ++- .../native-chat/NativeChatComposerField.tsx | 12 +- .../NativeChatSessionOptionPickers.test.tsx | 230 ++++++++++++ .../NativeChatSessionOptionPickers.tsx | 276 ++++++++++++++ .../components/native-chat/NativeChatView.tsx | 44 +-- .../claude-model-switch-confirmation.test.ts | 176 +++++++++ .../claude-model-switch-confirmation.ts | 199 ++++++++++ .../claude-terminal-session-options.test.ts | 53 +++ .../claude-terminal-session-options.ts | 54 +++ .../native-chat-pty-session-options.test.ts | 340 ++++++++++++++++++ .../native-chat-pty-session-options.ts | 276 ++++++++++++++ .../native-chat-runtime-send.test.ts | 51 ++- .../native-chat/native-chat-runtime-send.ts | 45 ++- .../native-chat-session-option-cache.ts | 84 +++++ ...ive-chat-session-option-command-builder.ts | 40 +++ ...ve-chat-session-option-command-dispatch.ts | 17 + ...ve-chat-session-option-command-matching.ts | 31 ++ ...e-chat-session-option-command-recording.ts | 105 ++++++ .../native-chat-session-option-discovery.ts | 57 +++ ...ive-chat-session-option-enrichment.test.ts | 61 ++++ .../native-chat-session-option-enrichment.ts | 87 +++++ .../native-chat-session-option-labels.ts | 135 +++++++ .../native-chat-session-option-reporting.ts | 28 ++ .../native-chat-session-option-snapshot.ts | 178 +++++++++ .../use-native-chat-dictation-actions.ts | 28 ++ ...use-native-chat-file-attachment-actions.ts | 27 ++ .../use-native-chat-file-link-click.ts | 27 ++ .../use-native-chat-session-option-command.ts | 114 ++++++ .../use-native-chat-session-options.ts | 155 ++++++++ .../onboarding-folder-agent-startup.test.ts | 8 +- .../buildSourceControlAgentDeliveryPlan.ts | 7 + .../folder-workspace-composer-submit.ts | 10 + .../components/terminal-pane/TerminalPane.tsx | 8 + src/renderer/src/hooks/useComposerState.ts | 30 ++ src/renderer/src/i18n/locales/en.json | 25 +- src/renderer/src/i18n/locales/es.json | 25 +- src/renderer/src/i18n/locales/ja.json | 25 +- src/renderer/src/i18n/locales/ko.json | 25 +- src/renderer/src/i18n/locales/zh.json | 25 +- .../src/i18n/native-chat-locales.test.ts | 20 ++ .../src/lib/launch-agent-in-new-tab.test.ts | 25 +- .../src/lib/launch-agent-in-new-tab.ts | 12 +- .../src/lib/launch-work-item-direct-agent.ts | 16 +- .../src/lib/launch-work-item-direct.test.ts | 3 + .../lib/onboarding-folder-agent-startup.ts | 8 + .../lib/source-control-agent-action-plan.ts | 12 +- .../worktree-activation-created-agent.test.ts | 4 +- src/renderer/src/lib/worktree-activation.ts | 24 ++ .../src/lib/worktree-creation-flow.ts | 11 + .../runtime/runtime-terminal-stream.test.ts | 54 +++ .../src/runtime/runtime-terminal-stream.ts | 53 ++- .../repos-onboarding-folder-startup.test.ts | 4 +- ...ent-session-option-catalog-claude-codex.ts | 153 ++++++++ ...nt-session-option-catalog-gemini-cursor.ts | 112 ++++++ .../agent-session-option-catalog-types.ts | 63 ++++ .../agent-session-option-catalog.test.ts | 112 ++++++ src/shared/agent-session-option-catalog.ts | 80 +++++ src/shared/agent-session-option-launch.ts | 64 ++++ src/shared/constants.ts | 1 + .../native-chat-session-option-defaults.ts | 72 ++++ src/shared/native-chat-session-options.ts | 57 +++ src/shared/tui-agent-launch-command.ts | 62 ++++ .../tui-agent-startup-session-options.test.ts | 100 ++++++ src/shared/tui-agent-startup.ts | 61 ++-- src/shared/types.ts | 4 + 70 files changed, 4674 insertions(+), 167 deletions(-) create mode 100644 src/renderer/src/components/native-chat/NativeChatComposerActions.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx create mode 100644 src/renderer/src/components/native-chat/claude-model-switch-confirmation.test.ts create mode 100644 src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts create mode 100644 src/renderer/src/components/native-chat/claude-terminal-session-options.test.ts create mode 100644 src/renderer/src/components/native-chat/claude-terminal-session-options.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-pty-session-options.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-session-option-cache.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-session-option-command-builder.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-session-option-command-dispatch.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-session-option-command-matching.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-session-option-command-recording.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-session-option-enrichment.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-session-option-labels.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-session-option-reporting.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-session-option-snapshot.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-dictation-actions.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-file-attachment-actions.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-file-link-click.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-session-option-command.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-session-options.ts create mode 100644 src/shared/agent-session-option-catalog-claude-codex.ts create mode 100644 src/shared/agent-session-option-catalog-gemini-cursor.ts create mode 100644 src/shared/agent-session-option-catalog-types.ts create mode 100644 src/shared/agent-session-option-catalog.test.ts create mode 100644 src/shared/agent-session-option-catalog.ts create mode 100644 src/shared/agent-session-option-launch.ts create mode 100644 src/shared/native-chat-session-option-defaults.ts create mode 100644 src/shared/native-chat-session-options.ts create mode 100644 src/shared/tui-agent-launch-command.ts create mode 100644 src/shared/tui-agent-startup-session-options.test.ts diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index acde5dd02c39..2cd0c36ef4cd 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -690,6 +690,31 @@ describe('Store', () => { expect(store.getSettings().minimizeToTrayOnClose).toBe(false) }) + it('persists native chat session options with per-model isolation', async () => { + const store = await createStore() + store.updateSettings({ + nativeChatSessionOptions: { + claude: { + model: 'opus', + valuesByModel: { + opus: { effort: 'xhigh', fastMode: true }, + sonnet: { effort: 'medium' } + } + } + } + }) + store.flush() + + const reloaded = await createStore() + expect(reloaded.getSettings().nativeChatSessionOptions?.claude).toEqual({ + model: 'opus', + valuesByModel: { + opus: { effort: 'xhigh', fastMode: true }, + sonnet: { effort: 'medium' } + } + }) + }) + it('coerces non-boolean minimizeToTrayOnClose payloads to a strict boolean', async () => { const store = await createStore() // Why: a renderer-supplied non-bool must never persist as a truthy non-bool diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx index 03213e9820e7..a0699332738e 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx @@ -17,6 +17,8 @@ import { } from '../../../../shared/tui-agent-launch-defaults' import { translate } from '@/i18n/i18n' import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' +import { resolveNativeChatSessionOptionDefaults } from '../../../../shared/native-chat-session-option-defaults' +import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache' type FloatingTerminalWindowControlsProps = { maximized: boolean @@ -73,6 +75,10 @@ export function FloatingTerminalWindowControls({ cmdOverrides: state.settings?.agentCmdOverrides ?? {}, agentArgs: resolveTuiAgentLaunchArgs(defaultAgent, state.settings?.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(defaultAgent, state.settings?.agentDefaultEnv), + sessionOptions: resolveNativeChatSessionOptionDefaults( + state.settings?.nativeChatSessionOptions, + defaultAgent + ), platform: CLIENT_PLATFORM, allowEmptyPromptLaunch: true }) @@ -87,6 +93,7 @@ export function FloatingTerminalWindowControls({ return } const tab = createTab(FLOATING_TERMINAL_WORKTREE_ID, undefined, undefined, { activate: false }) + seedNativeChatAppliedSessionOptions(tab.id, defaultAgent, startupPlan.sessionOptions) state.queueTabStartupCommand(tab.id, { command: startupPlan.launchCommand, ...(startupPlan.env ? { env: startupPlan.env } : {}), diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx index c8be699f8f64..f1f1794cc3b8 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx @@ -1,21 +1,48 @@ // @vitest-environment happy-dom -import { act, cleanup, render } from '@testing-library/react' +import { act, cleanup, render, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + SessionOptionDescriptor, + SessionOptionsSurface +} from '../../../../shared/native-chat-session-options' +import { clearNativeChatSessionOptionCacheForTests } from './native-chat-session-option-cache' const mocks = vi.hoisted(() => ({ cancelPendingSends: vi.fn(), - fieldProps: null as { onSend?: () => void; onStop?: () => void } | null, + fieldProps: null as { + onSend?: () => void + onStop?: () => void + sessionOptionsSurface?: SessionOptionsSurface | null + sessionOptionsSnapshot?: SessionOptionDescriptor[] + } | null, + modelSwitchOutcome: 'applied' as 'applied' | 'rejected' | 'interaction-required' | 'unknown', + confirmationObserver: null as { + ready: Promise + result: Promise<'applied' | 'rejected' | 'interaction-required' | 'unknown'> + arm: ReturnType + startDetection: ReturnType + dispose: ReturnType + } | null, + createClaudeModelSwitchConfirmationObserver: vi.fn(), + getMainBufferSnapshot: vi.fn(), sendHandle: { cancel: vi.fn(), settleAfterMs: 500 }, sendNativeChatMessage: vi.fn(), + sendNativeChatMessageVerified: vi.fn(), trackPendingSend: vi.fn(), setDraft: vi.fn() })) -vi.mock('../../store', () => ({ - useAppStore: (selector: (state: unknown) => unknown) => - selector({ dictationState: 'idle', settings: { voice: { enabled: false } } }) -})) +vi.mock('../../store', () => { + const state = { + dictationState: 'idle', + settings: { voice: { enabled: false }, nativeChatSessionOptions: {} }, + updateSettings: vi.fn() + } + const useAppStore = (selector: (value: typeof state) => unknown) => selector(state) + useAppStore.getState = () => state + return { useAppStore } +}) vi.mock('@/runtime/runtime-terminal-inspection', () => ({ isRemoteRuntimePtyId: () => false, @@ -26,15 +53,27 @@ vi.mock('@/lib/agent-paste-draft', () => ({ })) vi.mock('./native-chat-runtime-send', () => ({ sendNativeChatMessage: (...args: unknown[]) => mocks.sendNativeChatMessage(...args), + sendNativeChatMessageVerified: (...args: unknown[]) => + mocks.sendNativeChatMessageVerified(...args), sendNativeChatMessageWithImageAttachments: vi.fn(), submitNativeChatPrompt: vi.fn() })) -vi.mock('./native-chat-agent-commands', () => ({ getAgentSlashCommands: () => [] })) -vi.mock('@/lib/native-chat-telemetry', () => ({ emitNativeChatMessageSent: vi.fn() })) +vi.mock('./claude-model-switch-confirmation', () => ({ + createClaudeModelSwitchConfirmationObserver: (...args: unknown[]) => + mocks.createClaudeModelSwitchConfirmationObserver(...args) +})) +vi.mock('./native-chat-agent-commands', () => ({ + getAgentSlashCommands: () => [] +})) +vi.mock('@/lib/native-chat-telemetry', () => ({ + emitNativeChatMessageSent: vi.fn() +})) vi.mock('./use-native-chat-draft', () => ({ useNativeChatDraft: () => ({ draft: 'hello', setDraft: mocks.setDraft }) })) -vi.mock('./native-chat-draft-cache', () => ({ readNativeChatDraftCache: () => '' })) +vi.mock('./native-chat-draft-cache', () => ({ + readNativeChatDraftCache: () => '' +})) vi.mock('./NativeChatComposerField', () => ({ NativeChatComposerField: (props: { onSend?: () => void; onStop?: () => void }) => { mocks.fieldProps = props @@ -51,7 +90,10 @@ vi.mock('./use-native-chat-composer-attachments', () => ({ }) })) vi.mock('./use-native-chat-composer-paste', () => ({ - useNativeChatComposerPaste: () => ({ handlePaste: vi.fn(), pasteFromClipboard: vi.fn() }) + useNativeChatComposerPaste: () => ({ + handlePaste: vi.fn(), + pasteFromClipboard: vi.fn() + }) })) vi.mock('./use-native-chat-external-attachments', () => ({ useNativeChatExternalAttachments: () => ({ @@ -59,7 +101,9 @@ vi.mock('./use-native-chat-external-attachments', () => ({ resolveAttachmentOwner: vi.fn() }) })) -vi.mock('../dictation/dictation-control-events', () => ({ dispatchDictationControl: vi.fn() })) +vi.mock('../dictation/dictation-control-events', () => ({ + dispatchDictationControl: vi.fn() +})) vi.mock('./use-native-chat-composer-keydown', () => ({ useNativeChatComposerKeyDown: () => vi.fn() })) @@ -75,11 +119,31 @@ import { NativeChatComposer } from './NativeChatComposer' describe('NativeChatComposer', () => { beforeEach(() => { vi.clearAllMocks() + clearNativeChatSessionOptionCacheForTests() mocks.fieldProps = null + mocks.modelSwitchOutcome = 'applied' + mocks.confirmationObserver = null + mocks.createClaudeModelSwitchConfirmationObserver.mockImplementation(() => { + const observer = { + ready: Promise.resolve(), + result: Promise.resolve(mocks.modelSwitchOutcome), + arm: vi.fn(), + startDetection: vi.fn(), + dispose: vi.fn() + } + mocks.confirmationObserver = observer + return observer + }) + mocks.getMainBufferSnapshot.mockResolvedValue(null) mocks.sendNativeChatMessage.mockReturnValue(mocks.sendHandle) + mocks.sendNativeChatMessageVerified.mockResolvedValue(true) + mocks.sendHandle.settleAfterMs = 500 Object.defineProperty(window, 'api', { configurable: true, - value: { ui: { onFileDrop: () => vi.fn() } } + value: { + pty: { getMainBufferSnapshot: mocks.getMainBufferSnapshot }, + ui: { onFileDrop: () => vi.fn() } + } }) }) @@ -122,4 +186,201 @@ describe('NativeChatComposer', () => { expect(onOptimisticSend).toHaveBeenCalledWith('hello', []) expect(mocks.trackPendingSend).toHaveBeenCalledWith(mocks.sendHandle, 'pending-1') }) + + it('shows the model already selected in the Claude TUI when chat opens', async () => { + mocks.getMainBufferSnapshot.mockResolvedValue({ + data: 'Claude Code v2.1.211\r\nOpus 4.8 with medium effort · API Usage Billing', + cols: 120, + rows: 40 + }) + render( + null} + /> + ) + + await waitFor(() => + expect(mocks.fieldProps?.sessionOptionsSnapshot).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'model', + valueSource: 'reported', + kind: expect.objectContaining({ currentValue: 'opus' }) + }), + expect.objectContaining({ + id: 'effort', + valueSource: 'reported', + kind: expect.objectContaining({ currentValue: 'medium' }) + }) + ]) + ) + ) + expect(mocks.getMainBufferSnapshot).toHaveBeenCalledWith('pty-1', { scrollbackRows: 0 }) + }) + + it('reads Claude state from mounted xterm while its alternate screen is active', async () => { + mocks.getMainBufferSnapshot.mockResolvedValue({ + data: 'Claude Code v2.1.211\r\nOpus 4.8 with high effort · stale main buffer', + cols: 120, + rows: 40, + alternateScreen: true + }) + render( + + '\u001b[?1049h\u001b[HClaude Codev2.1.211\r\n' + + 'Sonnet 5 with medium effort · API Usage Billing' + } + /> + ) + + await waitFor(() => + expect(mocks.fieldProps?.sessionOptionsSnapshot).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'model', + valueSource: 'reported', + kind: expect.objectContaining({ currentValue: 'sonnet' }) + }), + expect.objectContaining({ + id: 'effort', + valueSource: 'reported', + kind: expect.objectContaining({ currentValue: 'medium' }) + }) + ]) + ) + ) + }) + + it('observes a fresh Claude model choice and stays native on success', async () => { + mocks.sendHandle.settleAfterMs = 0 + const onSlashCommand = vi.fn() + const onOptimisticSend = vi.fn() + const onSwitchToTerminal = vi.fn() + render( + + ) + + await act(async () => { + await mocks.fieldProps?.sessionOptionsSurface?.setOption('model', 'opus') + }) + + expect(mocks.sendNativeChatMessageVerified).toHaveBeenCalledWith( + {}, + 'pty-1', + '/model opus', + expect.any(AbortSignal) + ) + expect(onSlashCommand).toHaveBeenCalledWith('/model opus') + expect(onOptimisticSend).not.toHaveBeenCalled() + expect(mocks.createClaudeModelSwitchConfirmationObserver).toHaveBeenCalledWith({ + ptyId: 'pty-1', + settings: {}, + expectedModelLabel: 'Opus 4.8' + }) + expect(onSwitchToTerminal).not.toHaveBeenCalled() + }) + + it('keeps a successful Claude model change after a conversation in native chat', async () => { + mocks.sendHandle.settleAfterMs = 0 + const onSwitchToTerminal = vi.fn() + render( + + ) + + await act(async () => { + await mocks.fieldProps?.sessionOptionsSurface?.setOption('model', 'fable') + }) + + expect(mocks.sendNativeChatMessageVerified).toHaveBeenCalledWith( + {}, + 'pty-1', + '/model fable', + expect.any(AbortSignal) + ) + expect(mocks.createClaudeModelSwitchConfirmationObserver).toHaveBeenCalledWith({ + ptyId: 'pty-1', + settings: {}, + expectedModelLabel: 'Fable 5' + }) + expect(mocks.confirmationObserver?.arm).toHaveBeenCalledOnce() + expect(mocks.confirmationObserver?.arm.mock.invocationCallOrder[0]).toBeLessThan( + mocks.sendNativeChatMessageVerified.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY + ) + expect(mocks.confirmationObserver?.startDetection).toHaveBeenCalledOnce() + expect(mocks.confirmationObserver?.startDetection.mock.invocationCallOrder[0]).toBeGreaterThan( + mocks.sendNativeChatMessageVerified.mock.invocationCallOrder[0] ?? Number.NEGATIVE_INFINITY + ) + expect(mocks.confirmationObserver?.dispose).toHaveBeenCalledOnce() + expect(onSwitchToTerminal).not.toHaveBeenCalled() + }) + + it('reveals Claude interaction only when the model switch needs user input', async () => { + mocks.sendHandle.settleAfterMs = 0 + mocks.modelSwitchOutcome = 'interaction-required' + const onSwitchToTerminal = vi.fn() + render( + + ) + + await act(async () => { + await mocks.fieldProps?.sessionOptionsSurface?.setOption('model', 'fable') + }) + + expect(mocks.sendNativeChatMessageVerified).toHaveBeenCalledWith( + {}, + 'pty-1', + '/model fable', + expect.any(AbortSignal) + ) + expect(onSwitchToTerminal).toHaveBeenCalledOnce() + }) + + it('waits for the Codex picker command before switching to the terminal', async () => { + mocks.sendHandle.settleAfterMs = 0 + const onSwitchToTerminal = vi.fn() + render( + + ) + + await act(async () => { + await mocks.fieldProps?.sessionOptionsSurface?.setOption('model', '') + }) + + expect(mocks.sendNativeChatMessageVerified).toHaveBeenCalledWith( + {}, + 'pty-1', + '/model', + expect.any(AbortSignal) + ) + expect(onSwitchToTerminal).toHaveBeenCalledOnce() + }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index 41b254639dc3..dd624ba06ed4 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -1,15 +1,6 @@ -import { - forwardRef, - useCallback, - useEffect, - useImperativeHandle, - useMemo, - useRef, - useState -} from 'react' +import { forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState } from 'react' import { useAppStore } from '../../store' import type { AgentType } from '../../../../shared/agent-status-types' -import { NATIVE_FILE_DROP_TARGET } from '../../../../shared/native-file-drop' import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection' import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' import { @@ -43,9 +34,12 @@ import { useNativeChatSkills } from './use-native-chat-skills' import { useNativeChatComposerAttachments } from './use-native-chat-composer-attachments' import { useNativeChatComposerPaste } from './use-native-chat-composer-paste' import { useNativeChatExternalAttachments } from './use-native-chat-external-attachments' -import { dispatchDictationControl } from '../dictation/dictation-control-events' import { useNativeChatComposerKeyDown } from './use-native-chat-composer-keydown' import { useNativeChatSendLifecycle } from './use-native-chat-send-lifecycle' +import { useNativeChatSessionOptions } from './use-native-chat-session-options' +import { useNativeChatFileAttachmentActions } from './use-native-chat-file-attachment-actions' +import { useNativeChatDictationActions } from './use-native-chat-dictation-actions' +import { useNativeChatSessionOptionCommand } from './use-native-chat-session-option-command' // Why: a plain ESC byte is what the agent TUIs read as the interrupt key over a // PTY (matching how xterm forwards Escape). The richer interrupt-intent @@ -79,6 +73,10 @@ export type NativeChatComposerProps = { * a small "Ran /clear" system line — slash commands aren't chat turns and * otherwise leave no visible trace that anything happened. */ onSlashCommand?: (command: string) => void + /** Picker-only agent commands continue in the hosted TUI after dispatch. */ + onSwitchToTerminal?: () => void + /** Reads the hosted TUI's current rendered screen when chat is entered. */ + readTerminalScreen?: () => string | null } export type NativeChatComposerHandle = { @@ -116,7 +114,9 @@ export const NativeChatComposer = forwardRef { - return window.api.ui.onFileDrop((payload) => { - if (payload.target !== NATIVE_FILE_DROP_TARGET.composer) { - return - } - attachExternalPaths(payload.paths) + const { pickAttachment } = useNativeChatFileAttachmentActions(attachExternalPaths) + const { toggleDictation, startHoldDictation, stopHoldDictation } = + useNativeChatDictationActions({ textareaRef, setDictationPressed }) + const { dispatch: dispatchSessionOptionCommand, isDispatching: isDispatchingSessionOption } = + useNativeChatSessionOptionCommand({ + agent, + disabled, + onSlashCommand, + resolveTarget, + setHistory }) - }, [attachExternalPaths]) - - const pickAttachment = useCallback(() => { - void (async () => { - const filePath = await window.api.shell.pickAttachment() - if (!filePath) { - return - } - attachExternalPaths([filePath]) - })() - }, [attachExternalPaths]) - - const focusForDictation = useCallback(() => { - textareaRef.current?.focus() - }, []) - const toggleDictation = useCallback(() => { - focusForDictation() - dispatchDictationControl('toggle') - }, [focusForDictation]) - - const startHoldDictation = useCallback(() => { - setDictationPressed(true) - focusForDictation() - dispatchDictationControl('start') - }, [focusForDictation]) - - const stopHoldDictation = useCallback(() => { - setDictationPressed(false) - dispatchDictationControl('stop') - }, []) + const { surface: sessionOptionsSurface, snapshot: sessionOptionsSnapshot } = + useNativeChatSessionOptions({ + agent, + terminalTabId, + targetPtyId, + dispatchCommand: dispatchSessionOptionCommand, + onAgentPicker: onSwitchToTerminal, + readTerminalScreen + }) const send = useCallback(() => { const text = draft @@ -293,6 +276,12 @@ export const NativeChatComposer = forwardRef ) } diff --git a/src/renderer/src/components/native-chat/NativeChatComposerActions.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposerActions.test.tsx new file mode 100644 index 000000000000..2e1b02ac2494 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatComposerActions.test.tsx @@ -0,0 +1,62 @@ +// @vitest-environment happy-dom + +import { cleanup, render, screen } from '@testing-library/react' +import type { ReactNode } from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +vi.mock('@/components/ui/button', () => ({ + Button: ({ + children, + variant: _variant, + size: _size, + ...props + }: { + children: ReactNode + variant?: string + size?: string + } & React.ButtonHTMLAttributes) => +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: ReactNode }) =>
{children}
+})) + +vi.mock('./NativeChatSessionOptionPickers', () => ({ + NativeChatSessionOptionPickers: () =>
+})) + +import { NativeChatComposerActions } from './NativeChatComposerActions' + +afterEach(() => cleanup()) + +describe('NativeChatComposerActions', () => { + it('places session option pickers immediately beside dictation', () => { + render( + + ) + + const pickers = screen.getByTestId('session-option-pickers') + const dictation = screen.getByRole('button', { name: 'Start dictation' }) + expect(pickers.nextElementSibling).toBe(dictation) + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx b/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx index fb67917b3328..aee0f79c3c07 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx @@ -2,6 +2,11 @@ import { ArrowUp, Mic, Plus, Square } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { translate } from '@/i18n/i18n' +import type { + SessionOptionDescriptor, + SessionOptionsSurface +} from '../../../../shared/native-chat-session-options' +import { NativeChatSessionOptionPickers } from './NativeChatSessionOptionPickers' export type NativeChatComposerActionsProps = { attachDisabled: boolean @@ -16,6 +21,8 @@ export type NativeChatComposerActionsProps = { onDictationHoldEnd: () => void onSend: () => void onStop?: () => void + sessionOptionsSurface: SessionOptionsSurface | null + sessionOptionsSnapshot: SessionOptionDescriptor[] } export function NativeChatComposerActions({ @@ -30,32 +37,43 @@ export function NativeChatComposerActions({ onDictationHoldStart, onDictationHoldEnd, onSend, - onStop + onStop, + sessionOptionsSurface, + sessionOptionsSnapshot }: NativeChatComposerActionsProps): React.JSX.Element { const dictationLabel = isDictating ? translate('components.native-chat.composer.stopDictation', 'Stop dictation') : translate('components.native-chat.composer.startDictation', 'Start dictation') return (
- - - - - - {translate('components.native-chat.composer.attach', 'Attach file')} - - +
+ + + + + + {translate('components.native-chat.composer.attach', 'Attach file')} + + +
+ {/* Why: keep session controls beside the actions they affect; the + model trigger is ordered last so it sits directly next to dictation. */} +
diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx new file mode 100644 index 000000000000..8d40a791788a --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx @@ -0,0 +1,230 @@ +// @vitest-environment happy-dom + +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { SessionOptionDescriptor } from '../../../../shared/native-chat-session-options' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, values?: Record) => + fallback.replace('{{value0}}', values?.value0 ?? '') +})) + +vi.mock('@/components/ui/button', () => ({ + Button: ({ children, ...props }: React.ButtonHTMLAttributes) => ( + + ) +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: React.ReactNode }) =>
{children}
+})) + +vi.mock('@/components/ui/dropdown-menu', () => ({ + DropdownMenu: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuTrigger: ({ + children, + disabled + }: { + children: React.ReactNode + disabled?: boolean + }) =>
{children}
, + DropdownMenuContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuLabel: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuSeparator: () =>
, + DropdownMenuItem: ({ children, disabled }: React.ButtonHTMLAttributes) => ( + + ), + DropdownMenuCheckboxItem: ({ + children, + disabled + }: React.ButtonHTMLAttributes) => ( + + ), + DropdownMenuRadioGroup: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuRadioItem: ({ + children, + disabled, + value + }: React.ButtonHTMLAttributes & { value: string }) => ( + + ) +})) + +import { NativeChatSessionOptionPickers } from './NativeChatSessionOptionPickers' + +const surface = { + getSnapshot: vi.fn(() => []), + setOption: vi.fn(), + subscribe: vi.fn(() => vi.fn()) +} + +function model(overrides: Partial = {}): SessionOptionDescriptor { + return { + id: 'model', + label: 'Model', + category: 'model', + kind: { + type: 'select', + currentValue: 'opus', + choices: [ + { value: 'opus', label: 'Opus 4.8' }, + { value: 'sonnet', label: 'Sonnet 5' } + ] + }, + valueSource: 'applied', + settable: true, + ...overrides + } +} + +const effort: SessionOptionDescriptor = { + id: 'effort', + label: 'Effort', + category: 'thought_level', + kind: { + type: 'select', + currentValue: 'high', + choices: [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' } + ] + }, + valueSource: 'applied', + settable: true +} + +const fast: SessionOptionDescriptor = { + id: 'fastMode', + label: 'Fast mode', + category: 'mode', + kind: { type: 'boolean', currentValue: true }, + valueSource: 'applied', + settable: true +} + +afterEach(() => cleanup()) + +describe('NativeChatSessionOptionPickers', () => { + it('renders model and joined option labels, and hides an empty options pill', () => { + const { rerender } = render( + + ) + expect(screen.getByRole('button', { name: 'Model' }).textContent).toContain('Model: Opus 4.8') + expect(screen.getByRole('button', { name: 'Effort' }).textContent).toContain( + 'Effort: High · Fast' + ) + expect( + screen + .getByRole('button', { name: 'Effort' }) + .compareDocumentPosition(screen.getByRole('button', { name: 'Model' })) & + Node.DOCUMENT_POSITION_FOLLOWING + ).not.toBe(0) + + rerender( + + ) + expect(screen.queryByRole('button', { name: 'Effort' })).toBeNull() + }) + + it('names a lone unknown effort control explicitly', () => { + render( + + ) + + expect(screen.getByRole('button', { name: 'Effort' }).textContent).toContain('Effort') + }) + + it('disables both picker triggers while the agent is working', () => { + render( + + ) + expect( + screen.getByRole('button', { name: 'Model' }).parentElement?.getAttribute('data-disabled') + ).toBe('true') + expect( + screen.getByRole('button', { name: 'Effort' }).parentElement?.getAttribute('data-disabled') + ).toBe('true') + }) + + it('does not duplicate titles for unknown values or misname generic controls', () => { + const { rerender } = render( + + ) + expect(screen.getByRole('button', { name: 'Model' }).textContent).toContain('Model') + expect(screen.getByRole('button', { name: 'Model' }).textContent).not.toContain('Model: Model') + expect(screen.getByRole('button', { name: 'Effort' }).textContent).not.toContain( + 'Effort: Effort' + ) + + rerender( + + ) + expect(screen.getByRole('button', { name: 'Session options' }).textContent).toContain('Fast') + expect(screen.queryByRole('button', { name: 'Effort' })).toBeNull() + }) + + it('shows the unconfirmed hint for dispatched values', () => { + render( + + ) + expect(screen.getByText('Sent to the agent — not confirmed')).not.toBeNull() + }) + + it('renders agent-picker routes as one action instead of radio choices', () => { + render( + + ) + expect(screen.getByRole('button', { name: 'Choose in agent picker…' })).not.toBeNull() + expect(screen.queryByText('GPT-5.5')).toBeNull() + expect(screen.queryByText('GPT-5.2 Codex')).toBeNull() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx new file mode 100644 index 000000000000..15852d27db54 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx @@ -0,0 +1,276 @@ +import { memo, useState } from 'react' +import { ChevronDown } from 'lucide-react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { translate } from '@/i18n/i18n' +import type { + SessionOptionDescriptor, + SessionOptionsSurface, + SessionOptionValue +} from '../../../../shared/native-chat-session-options' +import { + nativeChatModelPillLabel, + nativeChatOptionsPillLabel, + nativeChatOptionsPillTitle, + nativeChatSessionChoiceLabel, + nativeChatSessionOptionDisabledReason, + nativeChatSessionOptionLabel +} from './native-chat-session-option-labels' + +export type NativeChatSessionOptionPickersProps = { + surface: SessionOptionsSurface | null + snapshot: SessionOptionDescriptor[] + isWorking: boolean +} + +const CATEGORY_ORDER: Record = { + thought_level: 0, + model_config: 1, + mode: 2 +} + +function sortedOptions(snapshot: readonly SessionOptionDescriptor[]): SessionOptionDescriptor[] { + return snapshot + .filter((descriptor) => descriptor.category !== 'model') + .sort((left, right) => { + const leftOrder = CATEGORY_ORDER[left.category ?? ''] ?? 3 + const rightOrder = CATEGORY_ORDER[right.category ?? ''] ?? 3 + return leftOrder - rightOrder + }) +} + +function PickerTooltipContent(props: { + label: string + disabledReason?: string | null + dispatched: boolean +}): React.JSX.Element { + return ( +
+
{props.disabledReason ?? props.label}
+ {props.dispatched ? ( +
+ {translate( + 'components.native-chat.composer.sentNotConfirmed', + 'Sent to the agent — not confirmed' + )} +
+ ) : null} +
+ ) +} + +function PickerTrigger(props: { + label: string + tooltipLabel: string + disabled: boolean + disabledReason?: string | null + dispatched: boolean +}): React.JSX.Element { + return ( + + + + + + + + + + + ) +} + +function ChoiceBody(props: { label: string; description?: string }): React.JSX.Element { + return ( +
+
{props.label}
+ {props.description ? ( +
{props.description}
+ ) : null} +
+ ) +} + +function actionLabel(descriptor: SessionOptionDescriptor): string { + if (descriptor.action?.type === 'agent-picker') { + return translate( + 'components.native-chat.composer.chooseInAgentPicker', + 'Choose in agent picker…' + ) + } + return translate('components.native-chat.composer.toggleOption', 'Toggle {{value0}}', { + value0: nativeChatSessionOptionLabel(descriptor).toLowerCase() + }) +} + +function DescriptorMenuRows(props: { + descriptor: SessionOptionDescriptor + pending: boolean + setValue: (value: SessionOptionValue) => void +}): React.JSX.Element { + const { descriptor, pending, setValue } = props + if (descriptor.action) { + return ( + + setValue( + descriptor.kind.type === 'boolean' + ? !(descriptor.kind.currentValue ?? false) + : (descriptor.kind.currentValue ?? '') + ) + } + > + {actionLabel(descriptor)} + + ) + } + if (descriptor.kind.type === 'boolean') { + return ( + setValue(checked === true)} + > + {nativeChatSessionOptionLabel(descriptor)} + + ) + } + return ( + setValue(value)} + > + {descriptor.kind.choices.map((choice) => ( + + + + ))} + + ) +} + +function NativeChatSessionOptionPickersInner({ + surface, + snapshot, + isWorking +}: NativeChatSessionOptionPickersProps): React.JSX.Element | null { + const [pendingId, setPendingId] = useState(null) + const model = snapshot.find((descriptor) => descriptor.category === 'model') + const options = sortedOptions(snapshot) + if (!surface || !model) { + return null + } + + const setOption = (descriptor: SessionOptionDescriptor, value: SessionOptionValue): void => { + setPendingId(descriptor.id) + void surface + .setOption(descriptor.id, value) + .catch((error) => { + toast.error( + translate( + 'components.native-chat.composer.optionUpdateFailed', + 'Could not update option' + ), + { description: error instanceof Error ? error.message : String(error) } + ) + }) + .finally(() => setPendingId(null)) + } + + const modelReason = nativeChatSessionOptionDisabledReason(model.disabledReason) + const modelTooltip = translate('components.native-chat.composer.model', 'Model') + const optionsTooltip = nativeChatOptionsPillTitle(options) + const optionsReason = + options.length > 0 && options.every((descriptor) => !descriptor.settable) + ? nativeChatSessionOptionDisabledReason(options[0]?.disabledReason) + : null + + return ( +
+ {options.length > 0 ? ( + + descriptor.valueSource === 'dispatched')} + /> + + {options.map((descriptor, index) => { + const reason = nativeChatSessionOptionDisabledReason(descriptor.disabledReason) + return ( +
+ {index > 0 ? : null} + {nativeChatSessionOptionLabel(descriptor)} + {reason && !descriptor.settable ? ( + {reason} + ) : null} + setOption(descriptor, value)} + /> +
+ ) + })} +
+
+ ) : null} + + + + {modelReason && !model.settable ? ( + {modelReason} + ) : null} + setOption(model, value)} + /> + + +
+ ) +} + +export const NativeChatSessionOptionPickers = memo(NativeChatSessionOptionPickersInner) diff --git a/src/renderer/src/components/native-chat/NativeChatView.tsx b/src/renderer/src/components/native-chat/NativeChatView.tsx index 8caf7d835c89..d2267281ecaa 100644 --- a/src/renderer/src/components/native-chat/NativeChatView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatView.tsx @@ -48,14 +48,10 @@ import { useNativeChatContextMenu } from './use-native-chat-context-menu' import type { NativeChatContextMenuActions } from './use-native-chat-context-menu' -import { - resolveNativeChatFileLink, - resolveNativeChatFileLinkContext -} from './native-chat-file-link' +import { resolveNativeChatFileLinkContext } from './native-chat-file-link' import { selectNativeChatRuntimeEnvironmentId } from './native-chat-runtime-owner' import { useNativeChatPasteBridge } from './use-native-chat-paste-bridge' -import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown' -import { openDetectedFilePath } from '@/components/terminal-pane/terminal-file-open-routing' +import { useNativeChatFileLinkClick } from './use-native-chat-file-link-click' export type NativeChatViewProps = { /** The terminal tab hosting the agent. paneKey is `${tabId}:${leafId}`. */ @@ -70,17 +66,12 @@ export type NativeChatViewProps = { resolvedAgent?: TuiAgent | null /** Return this pane to the hosted terminal surface. */ onSwitchToTerminal?: () => void + /** Current xterm screen reader used to recover agent-reported session state. */ + readTerminalScreen?: () => string | null contextMenuActions?: Omit } -/** - * Native chat surface for an agent terminal. Resolves the pane to its agent + - * session id, streams the assembled conversation via the U4 live-session hook, - * and renders the message list, live status, and all empty/loading/error - * states. When no session id is known yet the hook surfaces live hook state on - * an empty transcript; a true scrollback-scrape fallback (U6) is wired but only - * runs when scrollback is obtainable — it degrades to the empty state otherwise. - */ +/** Resolves an agent terminal into its native conversation and composer UI. */ export default function NativeChatView({ terminalTabId, paneKey: preferredPaneKey, @@ -88,6 +79,7 @@ export default function NativeChatView({ launchAgent, resolvedAgent, onSwitchToTerminal, + readTerminalScreen, contextMenuActions }: NativeChatViewProps): React.JSX.Element { // Select only this tab's status entry (shallow-compared) so an unrelated @@ -120,6 +112,7 @@ export default function NativeChatView({ targetPtyId={targetPtyId} terminalTabId={terminalTabId} onSwitchToTerminal={onSwitchToTerminal} + readTerminalScreen={readTerminalScreen} contextMenuActions={contextMenuActions} /> )} @@ -135,6 +128,7 @@ function NativeChatResolvedView({ targetPtyId, terminalTabId, onSwitchToTerminal, + readTerminalScreen, contextMenuActions }: { paneKey: string @@ -144,6 +138,7 @@ function NativeChatResolvedView({ targetPtyId: string | null terminalTabId: string onSwitchToTerminal?: () => void + readTerminalScreen?: () => string | null contextMenuActions?: Omit }): React.JSX.Element { // Primitive owner selection (no useShallow): routes the pane's read/subscribe to @@ -356,24 +351,7 @@ function NativeChatResolvedView({ setWorkingInterrupted(true) interactiveSend.cancel() }, [interactiveSend]) - const openNativeChatFileLink = useCallback( - (event, href) => { - const target = resolveNativeChatFileLink(href, fileLinkContext) - if (!target || !fileLinkContext) { - return - } - event.preventDefault() - event.stopPropagation() - openDetectedFilePath(target.absolutePath, target.line, target.column, { - worktreeId: fileLinkContext.worktreeId, - worktreePath: fileLinkContext.worktreePath, - runtimeEnvironmentId: fileLinkContext.runtimeEnvironmentId, - openWithSystemDefault: event.shiftKey - }) - }, - [fileLinkContext] - ) - const nativeChatFileLinkClick = fileLinkContext ? openNativeChatFileLink : undefined + const nativeChatFileLinkClick = useNativeChatFileLinkClick(fileLinkContext) // Chat-only font zoom via Cmd/Ctrl +/-/0, gated to the live conversation so // the chord is inert on the loading/empty/error states and elsewhere. @@ -460,6 +438,8 @@ function NativeChatResolvedView({ onOptimisticSend={onOptimisticSend} onOptimisticSendCanceled={onOptimisticSendCanceled} onSlashCommand={onSlashCommand} + onSwitchToTerminal={onSwitchToTerminal} + readTerminalScreen={readTerminalScreen} /> )} {contextMenu.menu} diff --git a/src/renderer/src/components/native-chat/claude-model-switch-confirmation.test.ts b/src/renderer/src/components/native-chat/claude-model-switch-confirmation.test.ts new file mode 100644 index 000000000000..e57f42e2efea --- /dev/null +++ b/src/renderer/src/components/native-chat/claude-model-switch-confirmation.test.ts @@ -0,0 +1,176 @@ +// @vitest-environment happy-dom + +import { describe, expect, it, vi } from 'vitest' +import { + createClaudeModelSwitchConfirmationObserver, + hasClaudeModelSwitchConfirmation +} from './claude-model-switch-confirmation' + +describe('Claude model switch confirmation detection', () => { + it('recognizes the rendered cache warning across ANSI and wrapped whitespace', () => { + expect( + hasClaudeModelSwitchConfirmation( + '\u001b[1mSwitch model?\u001b[0m\r\nThis\u001b[9Gconversation\u001b[22Gis\u001b[25Gcached\r\nfor the current model.' + ) + ).toBe(true) + expect(hasClaudeModelSwitchConfirmation('Set model to Haiku 4.5')).toBe(false) + }) + + it('reports matching success output split across PTY chunks', async () => { + const dataObserver = { current: (_data: string): void => {} } + const unsubscribe = vi.fn(() => {}) + const observer = createClaudeModelSwitchConfirmationObserver({ + ptyId: 'pty-1', + settings: {}, + expectedModelLabel: 'Fable 5', + subscribeToData: (watcher) => { + dataObserver.current = watcher + return unsubscribe + }, + timeoutMs: 100 + }) + + await observer.ready + dataObserver.current('historical Set model to Fable 5') + observer.arm() + dataObserver.current('\u001b[2mSet\u001b[10Gmo') + dataObserver.current('del\u001b[16Gto\u001b[19GFable 5 and saved as your default\u001b[0m') + + await expect(observer.result).resolves.toBe('applied') + expect(unsubscribe).toHaveBeenCalledOnce() + }) + + it('accepts the exact cached-history confirmation once and keeps observing', async () => { + const dataObserver = { current: (_data: string): void => {} } + const submitConfirmation = vi.fn() + const observer = createClaudeModelSwitchConfirmationObserver({ + ptyId: 'pty-1', + settings: {}, + expectedModelLabel: 'Fable 5', + subscribeToData: (watcher) => { + dataObserver.current = watcher + return vi.fn(() => {}) + }, + submitConfirmation, + timeoutMs: 100 + }) + + await observer.ready + observer.arm() + dataObserver.current('Switch model? This\u001b[9Gconversation\u001b[22Gis\u001b[25Gcached ') + dataObserver.current('for\u001b[36Gthe\u001b[40Gcurrent\u001b[48Gmodel.') + dataObserver.current(' redraw of the same Switch model? prompt') + + expect(submitConfirmation).toHaveBeenCalledOnce() + dataObserver.current('Set model to Fable 5 and saved as your default for new sessions') + + await expect(observer.result).resolves.toBe('applied') + }) + + it('reports a canceled model switch without opening an interaction', async () => { + const dataObserver = { current: (_data: string): void => {} } + const observer = createClaudeModelSwitchConfirmationObserver({ + ptyId: 'pty-1', + settings: {}, + expectedModelLabel: 'Haiku', + subscribeToData: (watcher) => { + dataObserver.current = watcher + return vi.fn(() => {}) + }, + timeoutMs: 100 + }) + + await observer.ready + observer.arm() + dataObserver.current('\u001b[2mKept\u001b[9Gmodel\u001b[16Gas ') + dataObserver.current('Fable 5\u001b[0m') + + await expect(observer.result).resolves.toBe('rejected') + }) + + it('requests interaction for Fable one-time usage-credit consent', async () => { + const dataObserver = { current: (_data: string): void => {} } + const observer = createClaudeModelSwitchConfirmationObserver({ + ptyId: 'pty-1', + settings: {}, + expectedModelLabel: 'Fable 5', + subscribeToData: (watcher) => { + dataObserver.current = watcher + return vi.fn(() => {}) + }, + timeoutMs: 100 + }) + + await observer.ready + observer.arm() + dataObserver.current('Fable 5 uses usage credits and needs a one-time consent — ') + dataObserver.current('pick Fable from /model in an interactive session to set it up') + + await expect(observer.result).resolves.toBe('interaction-required') + }) + + it('reports unknown when the PTY observer cannot be established', async () => { + const observer = createClaudeModelSwitchConfirmationObserver({ + ptyId: 'pty-1', + settings: {}, + expectedModelLabel: 'Fable 5', + subscribeToData: () => Promise.reject(new Error('unavailable')), + timeoutMs: 100 + }) + + await observer.ready + observer.arm() + await expect(observer.result).resolves.toBe('unknown') + }) + + it('reports unknown on timeout instead of requesting the terminal', async () => { + vi.useFakeTimers() + try { + const observer = createClaudeModelSwitchConfirmationObserver({ + ptyId: 'pty-1', + settings: {}, + expectedModelLabel: 'Fable 5', + subscribeToData: () => vi.fn(() => {}), + timeoutMs: 100 + }) + + await observer.ready + observer.arm() + observer.startDetection() + await vi.advanceTimersByTimeAsync(100) + await expect(observer.result).resolves.toBe('unknown') + } finally { + vi.useRealTimers() + } + }) + + it('does not start the detection timeout until startDetection() is called', async () => { + vi.useFakeTimers() + try { + let settled = false + const observer = createClaudeModelSwitchConfirmationObserver({ + ptyId: 'pty-1', + settings: {}, + expectedModelLabel: 'Fable 5', + subscribeToData: () => vi.fn(() => {}), + timeoutMs: 100 + }) + void observer.result.then(() => { + settled = true + }) + + await observer.ready + observer.arm() + // Simulates slow SSH send latency between arm() and command delivery: the + // clock must not run yet, or a successful switch would time out to unknown. + await vi.advanceTimersByTimeAsync(500) + expect(settled).toBe(false) + + observer.startDetection() + await vi.advanceTimersByTimeAsync(100) + await expect(observer.result).resolves.toBe('unknown') + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts b/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts new file mode 100644 index 000000000000..648280181814 --- /dev/null +++ b/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts @@ -0,0 +1,199 @@ +import type { GlobalSettings } from '../../../../shared/types' +import { subscribeToPtyData } from '../terminal-pane/pty-data-sidecar-subscriptions' +import { isRemoteRuntimePtyId, sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection' +import { subscribeToRuntimeTerminalData } from '@/runtime/runtime-terminal-stream' +import { NATIVE_CHAT_SUBMIT } from './native-chat-send' +import { stripScrollbackAnsi } from './native-chat-scrape-fallback' + +const DETECTION_TIMEOUT_MS = 5_000 +const MAX_OBSERVED_BYTES = 64 * 1024 + +type SubscribeToData = (watcher: (data: string) => void) => Promise<() => void> | (() => void) + +export type ClaudeModelSwitchOutcome = 'applied' | 'rejected' | 'interaction-required' | 'unknown' + +export type ClaudeModelSwitchConfirmationObserver = { + ready: Promise + result: Promise + arm(): void + startDetection(): void + dispose(): void +} + +export function hasClaudeModelSwitchConfirmation(buffer: string): boolean { + const text = compactTerminalText(buffer) + return ( + text.includes('switchmodel?') && text.includes('thisconversationiscachedforthecurrentmodel') + ) +} + +function compactTerminalText(buffer: string): string { + // Why: Claude positions TUI words with cursor-column escapes instead of + // emitting literal spaces, so matching must not depend on rendered gaps. + return stripScrollbackAnsi(buffer).replace(/\s+/g, '').toLowerCase() +} + +function hasClaudeModelSwitchSuccess(buffer: string, modelLabel: string): boolean { + const text = compactTerminalText(buffer) + const marker = `setmodelto${modelLabel.replace(/\s+/g, '').toLowerCase()}` + return text.includes(marker) +} + +function hasClaudeModelSwitchRejection(buffer: string): boolean { + return compactTerminalText(buffer).includes('keptmodelas') +} + +function hasClaudeModelSwitchInteraction(buffer: string): boolean { + const text = compactTerminalText(buffer) + return ( + text.includes('fable5usesusagecreditsandneedsaone-timeconsent') || + text.includes('pickfablefrom/modelinaninteractivesessiontosetitup') || + (text.includes('switchtofable5?') && text.includes('usagecredits')) + ) +} + +function subscribeToClaudeModelSwitchData(args: { + ptyId: string + settings: Pick | null | undefined + subscribeToData?: SubscribeToData + watcher: (data: string) => void +}): Promise<() => void> | (() => void) { + if (args.subscribeToData) { + return args.subscribeToData(args.watcher) + } + if (isRemoteRuntimePtyId(args.ptyId)) { + return subscribeToRuntimeTerminalData( + args.settings, + args.ptyId, + `desktop:native-chat-model-switch:${args.ptyId}`, + args.watcher, + { startAtLiveTail: true } + ) + } + return subscribeToPtyData(args.ptyId, args.watcher) +} + +export function createClaudeModelSwitchConfirmationObserver(args: { + ptyId: string + settings: Pick | null | undefined + expectedModelLabel: string | null + subscribeToData?: SubscribeToData + submitConfirmation?: () => boolean | void + timeoutMs?: number +}): ClaudeModelSwitchConfirmationObserver { + let armed = false + let settled = false + let confirmationSubmitted = false + let observed = '' + let timeout: ReturnType | null = null + let unsubscribe: (() => void) | null = null + let resolveResult!: (outcome: ClaudeModelSwitchOutcome) => void + let resolveReady!: () => void + const result = new Promise((resolve) => { + resolveResult = resolve + }) + const ready = new Promise((resolve) => { + resolveReady = resolve + }) + + const finish = (outcome: ClaudeModelSwitchOutcome): void => { + if (settled) { + return + } + settled = true + if (timeout !== null) { + clearTimeout(timeout) + timeout = null + } + unsubscribe?.() + unsubscribe = null + resolveResult(outcome) + } + + const scheduleTimeout = (): void => { + if (timeout !== null) { + clearTimeout(timeout) + } + timeout = setTimeout(() => finish('unknown'), args.timeoutMs ?? DETECTION_TIMEOUT_MS) + } + + const observeData = (data: string): void => { + if (!armed || settled) { + return + } + observed = `${observed}${data}`.slice(-MAX_OBSERVED_BYTES) + if (args.expectedModelLabel && hasClaudeModelSwitchSuccess(observed, args.expectedModelLabel)) { + finish('applied') + return + } + if (hasClaudeModelSwitchRejection(observed)) { + finish('rejected') + return + } + if (hasClaudeModelSwitchInteraction(observed)) { + finish('interaction-required') + return + } + if (!confirmationSubmitted && hasClaudeModelSwitchConfirmation(observed)) { + confirmationSubmitted = true + try { + // Why: the picker selection already expresses consent to switch; this + // exact Claude warning defaults to “Yes” and needs only one Enter. + const accepted = args.submitConfirmation + ? args.submitConfirmation() !== false + : sendRuntimePtyInput(args.settings, args.ptyId, NATIVE_CHAT_SUBMIT) + if (!accepted) { + finish('unknown') + return + } + scheduleTimeout() + } catch { + finish('unknown') + } + } + } + + try { + const subscription = subscribeToClaudeModelSwitchData({ + ptyId: args.ptyId, + settings: args.settings, + subscribeToData: args.subscribeToData, + watcher: observeData + }) + void Promise.resolve(subscription) + .then((dispose) => { + if (settled) { + dispose() + } else { + unsubscribe = dispose + } + }) + .catch(() => finish('unknown')) + .finally(resolveReady) + } catch { + finish('unknown') + resolveReady() + } + + return { + ready, + result, + arm: () => { + if (settled || armed) { + return + } + armed = true + }, + startDetection: () => { + // Why: measure the detection window from when the command is actually + // delivered, not from arm(). On SSH/remote the body+Enter round-trips can + // otherwise burn the timeout before the agent has even responded, turning + // a successful switch into a false "could not verify the model change". + if (settled) { + return + } + scheduleTimeout() + }, + dispose: () => finish('unknown') + } +} diff --git a/src/renderer/src/components/native-chat/claude-terminal-session-options.test.ts b/src/renderer/src/components/native-chat/claude-terminal-session-options.test.ts new file mode 100644 index 000000000000..e095adf1132d --- /dev/null +++ b/src/renderer/src/components/native-chat/claude-terminal-session-options.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { readClaudeSessionOptionsFromTerminalScreen } from './claude-terminal-session-options' + +describe('Claude terminal session option detection', () => { + it('reads the current model and effort from Claude header chrome', () => { + const screen = + '\u001b[1mClaude Code\u001b[0m v2.1.211\r\n' + + '\u001b[38;2;102;102;102mOpus 4.8 with high effort · API Usage Billing\r\n' + + '~/Documents/projects/orca' + + expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toEqual({ + model: 'opus', + effort: 'high' + }) + }) + + it('reads a Claude header whose xterm serialization joins the version to the title', () => { + const screen = + '\u001b[?1049h\u001b[H▐▛███▜▌Claude Codev2.1.211\r\n' + + '▝▜█████▛▘Sonnet 5 with medium effort · API Usage Billing' + + expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toEqual({ + model: 'sonnet', + effort: 'medium' + }) + }) + + it('does not mistake old conversation output for the current model', () => { + const screen = + 'Set model to Opus 4.8 and saved as your default\r\n' + + 'Claude Code v2.1.211\r\n' + + 'Sonnet 5 with medium effort · API Usage Billing' + + expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toEqual({ + model: 'sonnet', + effort: 'medium' + }) + }) + + it('reports an option-less Haiku model without inventing effort', () => { + expect( + readClaudeSessionOptionsFromTerminalScreen( + 'Claude Code v2.1.211\r\nHaiku · API Usage Billing\r\n~/repo' + ) + ).toEqual({ model: 'haiku' }) + }) + + it('ignores text without Claude header chrome', () => { + expect( + readClaudeSessionOptionsFromTerminalScreen('I recommend Opus 4.8 for this task.') + ).toBeNull() + }) +}) diff --git a/src/renderer/src/components/native-chat/claude-terminal-session-options.ts b/src/renderer/src/components/native-chat/claude-terminal-session-options.ts new file mode 100644 index 000000000000..7a1e5b8fbd86 --- /dev/null +++ b/src/renderer/src/components/native-chat/claude-terminal-session-options.ts @@ -0,0 +1,54 @@ +import { getAgentSessionOptionCatalog } from '../../../../shared/agent-session-option-catalog' +import type { SessionOptionValue } from '../../../../shared/native-chat-session-options' +import { stripScrollbackAnsi } from './native-chat-scrape-fallback' + +const EFFORT_ID_BY_LABEL: Record = { + low: 'low', + medium: 'medium', + high: 'high', + 'extra high': 'xhigh', + xhigh: 'xhigh', + max: 'max' +} + +function normalizedScreenLines(screen: string): string[] { + return stripScrollbackAnsi(screen) + .split('\n') + .map((line) => line.replace(/\s+/g, ' ').trim()) +} + +export function readClaudeSessionOptionsFromTerminalScreen( + screen: string | null | undefined +): Record | null { + if (!screen) { + return null + } + const lines = normalizedScreenLines(screen) + const headerIndex = lines.findIndex((line) => + // xterm serialization can remove cursor-positioning cells between the + // product name and version, producing `Claude Codev2.1.211`. + /\bClaude Code\s*v?\d+(?:\.\d+){1,2}\b/i.test(line) + ) + if (headerIndex < 0) { + return null + } + // Why: only Claude's fixed header rows describe live state; conversation + // text and old command confirmations elsewhere in the buffer can be stale. + const header = lines.slice(headerIndex, headerIndex + 3).join(' ') + const catalog = getAgentSessionOptionCatalog('claude') + const model = [...(catalog?.models ?? [])] + .sort((left, right) => right.label.length - left.label.length) + .find(({ label }) => header.toLowerCase().includes(label.toLowerCase())) + if (!model) { + return null + } + const result: Record = { model: model.id } + const effortLabel = header.match( + /\bwith\s+(low|medium|high|extra high|xhigh|max)\s+effort\b/i + )?.[1] + const effort = effortLabel ? EFFORT_ID_BY_LABEL[effortLabel.toLowerCase()] : undefined + if (effort && model.options.some((option) => option.id === 'effort')) { + result.effort = effort + } + return result +} diff --git a/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts b/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts new file mode 100644 index 000000000000..771daacd23ee --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts @@ -0,0 +1,340 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + clearNativeChatSessionOptionCacheForTests, + seedNativeChatAppliedSessionOptions +} from './native-chat-session-option-cache' +import { createNativeChatPtySessionOptions } from './native-chat-pty-session-options' + +describe('native chat PTY session options', () => { + beforeEach(() => clearNativeChatSessionOptionCacheForTests()) + + it('starts attached sessions unknown and hides model-scoped options', () => { + const surface = createNativeChatPtySessionOptions({ + agent: 'claude', + scopeKey: 'pty-1', + mode: 'live', + dispatchCommand: vi.fn() + })! + expect(surface.getSnapshot()).toHaveLength(1) + expect(surface.getSnapshot()[0]).toMatchObject({ + id: 'model', + valueSource: 'unknown' + }) + }) + + it('uses model and effort reported by the live Claude terminal', () => { + const surface = createNativeChatPtySessionOptions({ + agent: 'claude', + scopeKey: 'pty-1', + mode: 'live', + reportedValues: { model: 'opus', effort: 'medium' }, + dispatchCommand: vi.fn() + })! + + expect(surface.getSnapshot()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'model', + valueSource: 'reported', + kind: expect.objectContaining({ currentValue: 'opus' }) + }), + expect.objectContaining({ + id: 'effort', + valueSource: 'reported', + kind: expect.objectContaining({ currentValue: 'medium' }) + }) + ]) + ) + }) + + it('restores launch-backed values through the tab-to-PTY cache handoff', () => { + seedNativeChatAppliedSessionOptions('tab-1', 'claude', { + model: 'opus', + effort: 'xhigh' + }) + const surface = createNativeChatPtySessionOptions({ + agent: 'claude', + scopeKey: 'pty-1', + fallbackScopeKey: 'tab-1', + mode: 'live', + dispatchCommand: vi.fn() + })! + expect(surface.getSnapshot()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: 'model', valueSource: 'applied' }), + expect.objectContaining({ id: 'effort', valueSource: 'applied' }), + expect.objectContaining({ id: 'fastMode', valueSource: 'unknown' }) + ]) + ) + }) + + it('dispatches a Claude effort setter and publishes the full snapshot', async () => { + seedNativeChatAppliedSessionOptions('pty-1', 'claude', { + model: 'opus', + effort: 'xhigh' + }) + const dispatch = vi.fn() + const persist = vi.fn() + const listener = vi.fn() + const surface = createNativeChatPtySessionOptions({ + agent: 'claude', + scopeKey: 'pty-1', + mode: 'live', + dispatchCommand: dispatch, + persistSelection: persist + })! + surface.subscribe(listener) + + const effortResult = await surface.setOption('effort', 'high') + expect(dispatch).toHaveBeenCalledWith('/effort high') + expect(effortResult.snapshot.map(({ id }) => id)).toEqual(['model', 'effort', 'fastMode']) + expect(effortResult.snapshot.find(({ id }) => id === 'effort')).toMatchObject({ + valueSource: 'dispatched', + kind: { currentValue: 'high' } + }) + expect(listener).toHaveBeenCalledOnce() + expect(listener.mock.calls.every(([snapshot]) => Array.isArray(snapshot))).toBe(true) + expect(persist).toHaveBeenCalledWith({ + modelId: 'opus', + optionId: 'effort', + value: 'high' + }) + }) + + it('keeps a normal Claude model choice native and dispatches the selected model', async () => { + seedNativeChatAppliedSessionOptions('pty-1', 'claude', { + model: 'sonnet', + effort: 'high' + }) + const dispatch = vi.fn().mockResolvedValue({ outcome: 'applied' }) + const onAgentPicker = vi.fn() + const surface = createNativeChatPtySessionOptions({ + agent: 'claude', + scopeKey: 'pty-1', + mode: 'live', + dispatchCommand: dispatch, + onAgentPicker + })! + expect(surface.getSnapshot()[0]?.action).toBeUndefined() + + const result = await surface.setOption('model', 'fable') + + expect(dispatch).toHaveBeenCalledWith('/model fable', { + detectAgentInteraction: 'claude-model-switch-confirmation', + expectedChoiceLabel: 'Fable 5' + }) + expect(onAgentPicker).not.toHaveBeenCalled() + expect(result.snapshot[0]).toMatchObject({ + valueSource: 'dispatched', + kind: { currentValue: 'fable' } + }) + }) + + it('reveals the terminal only when Claude actually requires model-switch interaction', async () => { + seedNativeChatAppliedSessionOptions('pty-1', 'claude', { model: 'sonnet' }) + const dispatch = vi.fn().mockResolvedValue({ outcome: 'interaction-required' }) + const onAgentPicker = vi.fn() + const surface = createNativeChatPtySessionOptions({ + agent: 'claude', + scopeKey: 'pty-1', + mode: 'live', + dispatchCommand: dispatch, + onAgentPicker + })! + + const result = await surface.setOption('model', 'haiku') + + expect(dispatch).toHaveBeenCalledWith('/model haiku', { + detectAgentInteraction: 'claude-model-switch-confirmation', + expectedChoiceLabel: 'Haiku' + }) + expect(onAgentPicker).toHaveBeenCalledOnce() + expect(result.snapshot[0]).toMatchObject({ valueSource: 'unknown' }) + }) + + it('keeps the prior model and persistence when Claude rejects the switch', async () => { + seedNativeChatAppliedSessionOptions('pty-1', 'claude', { + model: 'fable', + effort: 'high' + }) + const persist = vi.fn() + const onAgentPicker = vi.fn() + const surface = createNativeChatPtySessionOptions({ + agent: 'claude', + scopeKey: 'pty-1', + mode: 'live', + dispatchCommand: vi.fn().mockResolvedValue({ outcome: 'rejected' }), + persistSelection: persist, + onAgentPicker + })! + + await expect(surface.setOption('model', 'haiku')).rejects.toThrow( + 'Claude kept the current model.' + ) + + expect(surface.getSnapshot()[0]).toMatchObject({ + valueSource: 'applied', + kind: { currentValue: 'fable' } + }) + expect(persist).not.toHaveBeenCalled() + expect(onAgentPicker).not.toHaveBeenCalled() + }) + + it('stays native and clears stale truth when the switch cannot be verified', async () => { + seedNativeChatAppliedSessionOptions('pty-1', 'claude', { + model: 'fable', + effort: 'high' + }) + const persist = vi.fn() + const onAgentPicker = vi.fn() + const surface = createNativeChatPtySessionOptions({ + agent: 'claude', + scopeKey: 'pty-1', + mode: 'live', + dispatchCommand: vi.fn().mockResolvedValue({ outcome: 'unknown' }), + persistSelection: persist, + onAgentPicker + })! + + await expect(surface.setOption('model', 'haiku')).rejects.toThrow( + 'Could not verify the model change; open the terminal to check.' + ) + + expect(surface.getSnapshot()).toHaveLength(1) + expect(surface.getSnapshot()[0]).toMatchObject({ valueSource: 'unknown' }) + expect(persist).not.toHaveBeenCalled() + expect(onAgentPicker).not.toHaveBeenCalled() + }) + + it('keeps an unknown toggle unknown after the one-shot action', async () => { + seedNativeChatAppliedSessionOptions('pty-1', 'claude', { + model: 'opus', + effort: 'high' + }) + const dispatch = vi.fn() + const surface = createNativeChatPtySessionOptions({ + agent: 'claude', + scopeKey: 'pty-1', + mode: 'live', + dispatchCommand: dispatch + })! + const fastBefore = surface.getSnapshot().find(({ id }) => id === 'fastMode') + expect(fastBefore?.action?.type).toBe('toggle-command') + + const result = await surface.setOption('fastMode', true) + expect(dispatch).toHaveBeenCalledWith('/fast') + expect(result.snapshot.find(({ id }) => id === 'fastMode')).toMatchObject({ + valueSource: 'unknown', + action: { type: 'toggle-command' } + }) + }) + + it('hands Codex model changes to the TUI picker and drops stale truth', async () => { + seedNativeChatAppliedSessionOptions('pty-1', 'codex', { + model: 'gpt-5.5', + effort: 'high' + }) + const dispatch = vi.fn() + const onAgentPicker = vi.fn() + const surface = createNativeChatPtySessionOptions({ + agent: 'codex', + scopeKey: 'pty-1', + mode: 'live', + dispatchCommand: dispatch, + onAgentPicker + })! + expect(surface.getSnapshot().find(({ id }) => id === 'effort')?.action?.type).toBe( + 'agent-picker' + ) + + const result = await surface.setOption('effort', 'xhigh') + expect(dispatch).toHaveBeenCalledWith('/model') + expect(onAgentPicker).toHaveBeenCalledOnce() + expect(result.snapshot).toHaveLength(1) + expect(result.snapshot[0]).toMatchObject({ valueSource: 'unknown' }) + }) + + it('tracks typed effort commands and downgrades typed toggles', () => { + seedNativeChatAppliedSessionOptions('pty-1', 'claude', { + model: 'opus', + effort: 'xhigh' + }) + const surface = createNativeChatPtySessionOptions({ + agent: 'claude', + scopeKey: 'pty-1', + mode: 'live', + dispatchCommand: vi.fn() + })! + surface.recordOutgoingCommand('/effort high') + expect(surface.getSnapshot().find(({ id }) => id === 'effort')).toMatchObject({ + valueSource: 'dispatched', + kind: { currentValue: 'high' } + }) + surface.recordOutgoingCommand('/fast') + expect(surface.getSnapshot().find(({ id }) => id === 'fastMode')).toMatchObject({ + valueSource: 'unknown' + }) + }) + + it('switches to the terminal and drops stale truth for a typed picker command', () => { + seedNativeChatAppliedSessionOptions('pty-1', 'claude', { + model: 'opus', + effort: 'xhigh' + }) + const onAgentPicker = vi.fn() + const surface = createNativeChatPtySessionOptions({ + agent: 'claude', + scopeKey: 'pty-1', + mode: 'live', + dispatchCommand: vi.fn(), + onAgentPicker + })! + + surface.recordOutgoingCommand('/model') + + expect(onAgentPicker).toHaveBeenCalledOnce() + expect(surface.getSnapshot()).toHaveLength(1) + expect(surface.getSnapshot()[0]).toMatchObject({ valueSource: 'unknown' }) + }) + + it('passes an unknown persisted model through as a literal choice', () => { + seedNativeChatAppliedSessionOptions('pty-1', 'claude', { + model: 'future-model' + }) + const surface = createNativeChatPtySessionOptions({ + agent: 'claude', + scopeKey: 'pty-1', + mode: 'live', + dispatchCommand: vi.fn() + })! + const model = surface.getSnapshot()[0] + expect(model.kind).toMatchObject({ + currentValue: 'future-model', + choices: expect.arrayContaining([{ value: 'future-model', label: 'future-model' }]) + }) + }) + + it('recomposes Cursor model slugs for live option changes', async () => { + seedNativeChatAppliedSessionOptions('pty-1', 'cursor', { + model: 'gpt-5.3-codex', + effort: 'medium', + fastMode: true + }) + const dispatch = vi.fn() + const surface = createNativeChatPtySessionOptions({ + agent: 'cursor', + scopeKey: 'pty-1', + mode: 'live', + dispatchCommand: dispatch + })! + expect(surface.getSnapshot().find(({ id }) => id === 'effort')?.settable).toBe(true) + + await surface.setOption('effort', 'high') + + expect(dispatch).toHaveBeenCalledWith('/model gpt-5.3-codex-high-fast') + expect(surface.getSnapshot().find(({ id }) => id === 'effort')).toMatchObject({ + valueSource: 'dispatched', + kind: { currentValue: 'high' } + }) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-pty-session-options.ts b/src/renderer/src/components/native-chat/native-chat-pty-session-options.ts new file mode 100644 index 000000000000..41ce304011c3 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-pty-session-options.ts @@ -0,0 +1,276 @@ +import { + findCatalogModel, + findCatalogOption, + getAgentSessionOptionCatalog, + type CatalogMidSessionApply, + type CatalogModel, + type CatalogOptionApply +} from '../../../../shared/agent-session-option-catalog' +import type { AgentType } from '../../../../shared/agent-status-types' +import type { + SessionOptionDescriptor, + SessionOptionsSurface, + SessionOptionValue +} from '../../../../shared/native-chat-session-options' +import { + createNativeChatSessionOptionRecord, + readNativeChatSessionOptionCache, + writeNativeChatSessionOptionCache +} from './native-chat-session-option-cache' +import { + buildNativeChatSessionOptionSnapshot, + flattenNativeChatSessionOptionRecord, + type NativeChatSessionOptionMode +} from './native-chat-session-option-snapshot' +import { buildNativeChatSessionOptionCommand } from './native-chat-session-option-command-builder' +import type { + NativeChatSessionOptionDispatchCommand, + NativeChatSessionOptionDispatchResult +} from './native-chat-session-option-command-dispatch' +import { applyNativeChatReportedSessionOptions } from './native-chat-session-option-reporting' +import { recordNativeChatSessionOptionCommand } from './native-chat-session-option-command-recording' + +type PersistSelection = (args: { + modelId: string + optionId: string + value: SessionOptionValue +}) => Promise | void + +export type NativeChatPtySessionOptionsSurface = SessionOptionsSurface & { + recordOutgoingCommand(command: string): void + reportSessionOptions(values: Record): void + replaceModels(models: CatalogModel[]): void +} + +export type CreateNativeChatPtySessionOptionsArgs = { + agent: AgentType + scopeKey: string + fallbackScopeKey?: string + initialModels?: readonly CatalogModel[] + mode: NativeChatSessionOptionMode + reportedValues?: Record | null + dispatchCommand: NativeChatSessionOptionDispatchCommand + onAgentPicker?: () => void + persistSelection?: PersistSelection + onDraftValuesChanged?: (values: Record) => void +} + +export function createNativeChatPtySessionOptions( + args: CreateNativeChatPtySessionOptionsArgs +): NativeChatPtySessionOptionsSurface | null { + const catalog = getAgentSessionOptionCatalog(args.agent) + if (!catalog) { + return null + } + let models = [...(args.initialModels ?? catalog.models)] + let record = + readNativeChatSessionOptionCache(args.scopeKey, args.fallbackScopeKey) ?? + createNativeChatSessionOptionRecord(args.agent) + if (record.agent !== args.agent) { + record = createNativeChatSessionOptionRecord(args.agent) + } + + if (args.reportedValues && applyNativeChatReportedSessionOptions(record, args.reportedValues)) { + writeNativeChatSessionOptionCache(args.scopeKey, record) + } + let snapshot = buildNativeChatSessionOptionSnapshot({ + catalog, + models, + record, + mode: args.mode + }) + const listeners = new Set<(value: SessionOptionDescriptor[]) => void>() + + const publish = (): SessionOptionDescriptor[] => { + writeNativeChatSessionOptionCache(args.scopeKey, record) + snapshot = buildNativeChatSessionOptionSnapshot({ + catalog, + models, + record, + mode: args.mode + }) + for (const listener of listeners) { + listener(snapshot) + } + return snapshot + } + + const clearModelTruth = (): void => { + const modelId = typeof record.model?.value === 'string' ? record.model.value : null + record.model = undefined + if (modelId) { + delete record.valuesByModel[modelId] + } + } + + const setTrackedValue = ( + optionId: string, + value: SessionOptionValue, + source: 'applied' | 'dispatched' + ): string | null => { + if (optionId === 'model') { + record.model = { value, source } + return typeof value === 'string' ? value : null + } + const modelId = typeof record.model?.value === 'string' ? record.model.value : null + if (!modelId) { + return null + } + record.valuesByModel[modelId] = { + ...record.valuesByModel[modelId], + [optionId]: { value, source } + } + return modelId + } + + const persist = (modelId: string | null, optionId: string, value: SessionOptionValue): void => { + if (modelId) { + void args.persistSelection?.({ modelId, optionId, value }) + } + } + + const currentApply = ( + optionId: string + ): { apply: CatalogOptionApply; modelId: string | null } | null => { + const modelId = typeof record.model?.value === 'string' ? record.model.value : null + if (optionId === 'model') { + return { apply: catalog.modelApply, modelId } + } + const model = modelId ? findCatalogModel({ ...catalog, models }, modelId) : undefined + const option = findCatalogOption(model, optionId) + return option ? { apply: option.apply, modelId } : null + } + + const handleAgentPicker = async (midSession: CatalogMidSessionApply): Promise => { + if (midSession.kind !== 'agent-picker') { + return + } + await args.dispatchCommand(midSession.command) + clearModelTruth() + publish() + args.onAgentPicker?.() + } + + const setOption = async (id: string, value: SessionOptionValue) => { + const resolved = currentApply(id) + if (!resolved) { + throw new Error(`Unknown session option: ${id}`) + } + const { apply, modelId: previousModelId } = resolved + if (args.mode === 'live' && apply.midSession?.kind === 'agent-picker') { + await handleAgentPicker(apply.midSession) + return { snapshot } + } + const source = args.mode === 'live' ? 'dispatched' : 'applied' + let dispatchResult: NativeChatSessionOptionDispatchResult | void = undefined + const toggleWasKnown = + apply.midSession?.kind === 'toggle-command' && previousModelId + ? record.valuesByModel[previousModelId]?.[id] !== undefined + : false + if (args.mode === 'live') { + const command = buildNativeChatSessionOptionCommand({ + optionId: id, + value, + apply, + modelId: previousModelId, + catalog, + models, + record + }) + if (!command) { + throw new Error('This option can only be set when the session starts.') + } + const detectAgentInteraction = + apply.midSession?.kind === 'command' + ? apply.midSession.detectAgentInteraction + : apply.composedIntoModel && catalog.modelApply.midSession?.kind === 'command' + ? catalog.modelApply.midSession.detectAgentInteraction + : undefined + const expectedChoiceLabel = + id === 'model' && typeof value === 'string' + ? (findCatalogModel({ ...catalog, models }, value)?.label ?? value) + : undefined + dispatchResult = detectAgentInteraction + ? await args.dispatchCommand(command, { + detectAgentInteraction, + expectedChoiceLabel + }) + : await args.dispatchCommand(command) + } else if (!apply.launchArgs && !apply.composedIntoModel) { + throw new Error('This option is only available after the session starts.') + } + + if (dispatchResult?.outcome === 'rejected') { + throw new Error('Claude kept the current model.') + } + if (dispatchResult?.outcome === 'unknown') { + clearModelTruth() + publish() + throw new Error('Could not verify the model change; open the terminal to check.') + } + if (dispatchResult?.outcome === 'interaction-required') { + clearModelTruth() + publish() + args.onAgentPicker?.() + return { snapshot } + } + + if (apply.midSession?.kind === 'toggle-command' && !toggleWasKnown) { + return { snapshot: publish() } + } + if (id === 'model' && previousModelId !== value) { + record.model = undefined + if (args.mode === 'live' && typeof value === 'string') { + // Why: switching models can reset its effort/toggles. A value cached + // from an earlier visit to that model is no longer live evidence. + delete record.valuesByModel[value] + } + } + const modelId = setTrackedValue(id, value, source) + if (apply.midSession?.kind === 'toggle-command' && previousModelId && source === 'dispatched') { + record.valuesByModel[previousModelId] = { + ...record.valuesByModel[previousModelId], + [id]: { value, source } + } + } + persist(modelId ?? previousModelId, id, value) + const next = publish() + if (args.mode === 'draft' && typeof record.model?.value === 'string') { + args.onDraftValuesChanged?.(flattenNativeChatSessionOptionRecord(record, record.model.value)) + } + return { snapshot: next } + } + + return { + getSnapshot: () => snapshot, + setOption, + subscribe: (listener) => { + listeners.add(listener) + return () => listeners.delete(listener) + }, + recordOutgoingCommand: (command) => { + const result = recordNativeChatSessionOptionCommand({ + catalog, + models, + record, + command, + persist + }) + if (result.changed) { + publish() + } + if (result.opensAgentPicker) { + args.onAgentPicker?.() + } + }, + reportSessionOptions: (values) => { + if (applyNativeChatReportedSessionOptions(record, values)) { + publish() + } + }, + replaceModels: (nextModels) => { + models = [...nextModels] + publish() + } + } +} diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts index 626e126ffa5a..730ad84f1a61 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts @@ -3,12 +3,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' // Mock the IO seam so the test stays pure: we only assert the write order and // the inter-write delay, not the local-vs-remote pty branching. const sendRuntimePtyInput = vi.fn() +const sendRuntimePtyInputVerified = vi.fn() vi.mock('@/runtime/runtime-terminal-inspection', () => ({ - sendRuntimePtyInput: (...args: unknown[]) => sendRuntimePtyInput(...args) + sendRuntimePtyInput: (...args: unknown[]) => sendRuntimePtyInput(...args), + sendRuntimePtyInputVerified: (...args: unknown[]) => sendRuntimePtyInputVerified(...args) })) import { sendNativeChatMessage, + sendNativeChatMessageVerified, sendNativeChatMessageWithImageAttachments, submitNativeChatPrompt, sendNativeChatAskAnswer, @@ -75,6 +78,52 @@ describe('sendNativeChatMessage', () => { }) }) +describe('sendNativeChatMessageVerified', () => { + beforeEach(() => { + vi.useFakeTimers() + sendRuntimePtyInputVerified.mockReset().mockResolvedValue(true) + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('awaits body acceptance before the delayed Enter write', async () => { + const result = sendNativeChatMessageVerified(SETTINGS, PTY, '/model sonnet') + await Promise.resolve() + + expect(sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1) + expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith( + SETTINGS, + PTY, + buildNativeChatPasteBytes('/model sonnet') + ) + + await vi.advanceTimersByTimeAsync(NATIVE_CHAT_SUBMIT_DELAY_MS) + + expect(await result).toBe(true) + expect(sendRuntimePtyInputVerified).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT) + }) + + it('does not send Enter when the body is rejected', async () => { + sendRuntimePtyInputVerified.mockResolvedValueOnce(false) + + await expect(sendNativeChatMessageVerified(SETTINGS, PTY, '/model sonnet')).resolves.toBe(false) + await vi.runAllTimersAsync() + + expect(sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1) + }) + + it('cancels the pending Enter when its composer detaches', async () => { + const controller = new AbortController() + const result = sendNativeChatMessageVerified(SETTINGS, PTY, '/model sonnet', controller.signal) + await Promise.resolve() + controller.abort() + + expect(await result).toBe(false) + expect(sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1) + }) +}) + describe('sendNativeChatMessageWithImageAttachments', () => { beforeEach(() => { vi.useFakeTimers() diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts index 59ff443fd241..f1f97d0b530d 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts @@ -2,7 +2,10 @@ // as a SEPARATE delayed pty write. Kept apart from the pure byte builders in // native-chat-send.ts so those stay IO-free and unit-testable without aliases. -import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection' +import { + sendRuntimePtyInput, + sendRuntimePtyInputVerified +} from '@/runtime/runtime-terminal-inspection' import type { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' import type { AskAnswerKeyGroup } from './native-chat-interactive-prompt' import { @@ -47,6 +50,46 @@ export function sendNativeChatMessage( return { cancel: () => clearTimeout(timer), settleAfterMs: NATIVE_CHAT_SUBMIT_DELAY_MS } } +function waitForNativeChatSubmit(signal?: AbortSignal): Promise { + if (signal?.aborted) { + return Promise.resolve(false) + } + return new Promise((resolve) => { + let timer: ReturnType | null = null + const finish = (completed: boolean): void => { + if (timer === null) { + return + } + clearTimeout(timer) + timer = null + signal?.removeEventListener('abort', onAbort) + resolve(completed) + } + const onAbort = (): void => finish(false) + timer = setTimeout(() => finish(true), NATIVE_CHAT_SUBMIT_DELAY_MS) + signal?.addEventListener('abort', onAbort, { once: true }) + }) +} + +export async function sendNativeChatMessageVerified( + settings: ReturnType, + ptyId: string, + text: string, + signal?: AbortSignal +): Promise { + // Why: option commands await remote/SSH acceptance so the Enter cannot race + // ahead of the body while a model-change observer is already armed. + const bodyAccepted = await sendRuntimePtyInputVerified( + settings, + ptyId, + buildNativeChatPasteBytes(text) + ) + if (!bodyAccepted || signal?.aborted || !(await waitForNativeChatSubmit(signal))) { + return false + } + return sendRuntimePtyInputVerified(settings, ptyId, NATIVE_CHAT_SUBMIT) +} + export function sendNativeChatMessageWithImageAttachments( settings: ReturnType, ptyId: string, diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-cache.ts b/src/renderer/src/components/native-chat/native-chat-session-option-cache.ts new file mode 100644 index 000000000000..ae9418cd6508 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-session-option-cache.ts @@ -0,0 +1,84 @@ +import type { AgentType } from '../../../../shared/agent-status-types' +import type { + SessionOptionValue, + SessionOptionValueSource +} from '../../../../shared/native-chat-session-options' +import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache' + +export type TrackedNativeChatSessionOption = { + value: SessionOptionValue + source: Exclude +} + +export type NativeChatSessionOptionRecord = { + agent: AgentType + model?: TrackedNativeChatSessionOption + valuesByModel: Record> +} + +const sessionOptionCache = new Map() + +export function createNativeChatSessionOptionRecord( + agent: AgentType +): NativeChatSessionOptionRecord { + return { agent, valuesByModel: {} } +} + +export function cloneNativeChatSessionOptionRecord( + record: NativeChatSessionOptionRecord +): NativeChatSessionOptionRecord { + return { + agent: record.agent, + ...(record.model ? { model: { ...record.model } } : {}), + valuesByModel: Object.fromEntries( + Object.entries(record.valuesByModel).map(([modelId, values]) => [ + modelId, + Object.fromEntries(Object.entries(values).map(([id, tracked]) => [id, { ...tracked }])) + ]) + ) + } +} + +export function readNativeChatSessionOptionCache( + scopeKey: string, + fallbackScopeKey?: string +): NativeChatSessionOptionRecord | null { + const record = sessionOptionCache.get(scopeKey) ?? sessionOptionCache.get(fallbackScopeKey ?? '') + return record ? cloneNativeChatSessionOptionRecord(record) : null +} + +export function writeNativeChatSessionOptionCache( + scopeKey: string, + record: NativeChatSessionOptionRecord +): void { + setBoundedScopeCacheEntry( + sessionOptionCache, + scopeKey, + cloneNativeChatSessionOptionRecord(record) + ) +} + +export function seedNativeChatAppliedSessionOptions( + scopeKey: string, + agent: AgentType, + values: Record | null | undefined +): void { + const modelId = typeof values?.model === 'string' ? values.model : null + if (!modelId) { + return + } + const record = createNativeChatSessionOptionRecord(agent) + record.model = { value: modelId, source: 'applied' } + const modelValues: Record = {} + for (const [id, value] of Object.entries(values ?? {})) { + if (id !== 'model') { + modelValues[id] = { value, source: 'applied' } + } + } + record.valuesByModel[modelId] = modelValues + writeNativeChatSessionOptionCache(scopeKey, record) +} + +export function clearNativeChatSessionOptionCacheForTests(): void { + sessionOptionCache.clear() +} diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-command-builder.ts b/src/renderer/src/components/native-chat/native-chat-session-option-command-builder.ts new file mode 100644 index 000000000000..94de397b0ef0 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-session-option-command-builder.ts @@ -0,0 +1,40 @@ +import { + findCatalogModel, + type AgentSessionOptionCatalog, + type CatalogModel, + type CatalogOptionApply +} from '../../../../shared/agent-session-option-catalog' +import type { SessionOptionValue } from '../../../../shared/native-chat-session-options' +import type { NativeChatSessionOptionRecord } from './native-chat-session-option-cache' +import { flattenNativeChatSessionOptionRecord } from './native-chat-session-option-snapshot' + +export function buildNativeChatSessionOptionCommand(args: { + optionId: string + value: SessionOptionValue + apply: CatalogOptionApply + modelId: string | null + catalog: AgentSessionOptionCatalog + models: CatalogModel[] + record: NativeChatSessionOptionRecord +}): string | null { + const midSession = args.apply.midSession + if (midSession?.kind === 'command') { + return midSession.build(args.value) + } + if (midSession?.kind === 'toggle-command' || midSession?.kind === 'agent-picker') { + return midSession.command + } + if (!args.apply.composedIntoModel || !args.modelId || !args.catalog.composeModelValue) { + return null + } + const model = findCatalogModel({ ...args.catalog, models: args.models }, args.modelId) + const values = flattenNativeChatSessionOptionRecord(args.record, args.modelId) + for (const option of model?.options ?? []) { + values[option.id] ??= option.kind.defaultValue + } + values[args.optionId] = args.value + const composed = args.catalog.composeModelValue(args.modelId, values) + return args.catalog.modelApply.midSession?.kind === 'command' + ? args.catalog.modelApply.midSession.build(composed) + : null +} diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-command-dispatch.ts b/src/renderer/src/components/native-chat/native-chat-session-option-command-dispatch.ts new file mode 100644 index 000000000000..c510990dc5ea --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-session-option-command-dispatch.ts @@ -0,0 +1,17 @@ +import type { CatalogAgentInteractionDetection } from '../../../../shared/agent-session-option-catalog' +import type { ClaudeModelSwitchOutcome } from './claude-model-switch-confirmation' + +export type NativeChatSessionOptionDispatchResult = { + outcome?: ClaudeModelSwitchOutcome +} + +export type NativeChatSessionOptionDispatchCommand = ( + command: string, + options?: { + detectAgentInteraction?: CatalogAgentInteractionDetection + expectedChoiceLabel?: string + } +) => + | Promise + | NativeChatSessionOptionDispatchResult + | void diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-command-matching.ts b/src/renderer/src/components/native-chat/native-chat-session-option-command-matching.ts new file mode 100644 index 000000000000..08509c369351 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-session-option-command-matching.ts @@ -0,0 +1,31 @@ +import type { CatalogMidSessionApply } from '../../../../shared/agent-session-option-catalog' +import type { SessionOptionValue } from '../../../../shared/native-chat-session-options' + +export function parseBuiltSessionOptionCommand( + build: (value: SessionOptionValue) => string, + command: string +): string | null { + const marker = '__orca_session_option_value__' + const template = build(marker) + const markerIndex = template.indexOf(marker) + if (markerIndex < 0) { + return null + } + const prefix = template.slice(0, markerIndex) + const suffix = template.slice(markerIndex + marker.length) + if (!command.startsWith(prefix) || !command.endsWith(suffix)) { + return null + } + const value = command.slice(prefix.length, command.length - suffix.length).trim() + return value || null +} + +export function isSessionOptionAgentPickerCommand( + midSession: CatalogMidSessionApply | undefined, + command: string +): boolean { + return ( + (midSession?.kind === 'agent-picker' && command === midSession.command) || + (midSession?.kind === 'command' && command === midSession.pickerCommand) + ) +} diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-command-recording.ts b/src/renderer/src/components/native-chat/native-chat-session-option-command-recording.ts new file mode 100644 index 000000000000..051e14b1c1dc --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-session-option-command-recording.ts @@ -0,0 +1,105 @@ +import { + findCatalogModel, + type AgentSessionOptionCatalog, + type CatalogMidSessionApply, + type CatalogModel +} from '../../../../shared/agent-session-option-catalog' +import type { SessionOptionValue } from '../../../../shared/native-chat-session-options' +import type { NativeChatSessionOptionRecord } from './native-chat-session-option-cache' +import { + isSessionOptionAgentPickerCommand, + parseBuiltSessionOptionCommand +} from './native-chat-session-option-command-matching' + +function clearModel(record: NativeChatSessionOptionRecord): void { + const modelId = typeof record.model?.value === 'string' ? record.model.value : null + record.model = undefined + if (modelId) { + delete record.valuesByModel[modelId] + } +} + +function recordCommandApply(args: { + record: NativeChatSessionOptionRecord + optionId: string + midSession: CatalogMidSessionApply | undefined + command: string + persist: (modelId: string | null, optionId: string, value: SessionOptionValue) => void +}): boolean { + const { record, optionId, midSession, command, persist } = args + if (!midSession || midSession.kind === 'unsupported') { + return false + } + if (midSession.kind === 'toggle-command' && command === midSession.command) { + const modelId = typeof record.model?.value === 'string' ? record.model.value : null + if (modelId) { + delete record.valuesByModel[modelId]?.[optionId] + } + return true + } + if (isSessionOptionAgentPickerCommand(midSession, command)) { + clearModel(record) + return true + } + if (midSession.kind !== 'command') { + return false + } + const value = parseBuiltSessionOptionCommand(midSession.build, command) + if (!value) { + return false + } + const previousModelId = typeof record.model?.value === 'string' ? record.model.value : null + if (optionId === 'model') { + if (previousModelId !== value) { + // Why: a model command can reset model-scoped state, so an older value + // from a prior visit is no longer evidence about this live session. + delete record.valuesByModel[value] + } + record.model = { value, source: 'dispatched' } + persist(value, optionId, value) + return true + } + if (!previousModelId) { + return true + } + record.valuesByModel[previousModelId] = { + ...record.valuesByModel[previousModelId], + [optionId]: { value, source: 'dispatched' } + } + persist(previousModelId, optionId, value) + return true +} + +export function recordNativeChatSessionOptionCommand(args: { + catalog: AgentSessionOptionCatalog + models: CatalogModel[] + record: NativeChatSessionOptionRecord + command: string + persist: (modelId: string | null, optionId: string, value: SessionOptionValue) => void +}): { changed: boolean; opensAgentPicker: boolean } { + const { catalog, models, record, persist } = args + const command = args.command.trim() + let opensAgentPicker = isSessionOptionAgentPickerCommand(catalog.modelApply.midSession, command) + let changed = recordCommandApply({ + record, + optionId: 'model', + midSession: catalog.modelApply.midSession, + command, + persist + }) + const modelId = typeof record.model?.value === 'string' ? record.model.value : null + const model = modelId ? findCatalogModel({ ...catalog, models }, modelId) : undefined + for (const option of model?.options ?? []) { + opensAgentPicker = + opensAgentPicker || isSessionOptionAgentPickerCommand(option.apply.midSession, command) + changed = + recordCommandApply({ + record, + optionId: option.id, + midSession: option.apply.midSession, + command, + persist + }) || changed + } + return { changed, opensAgentPicker } +} diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts b/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts new file mode 100644 index 000000000000..bdf8ee6d1a20 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts @@ -0,0 +1,57 @@ +import type { AgentType } from '../../../../shared/agent-status-types' +import type { CatalogModel } from '../../../../shared/agent-session-option-catalog' +import { getCommitMessageModelDiscoveryHostKeyForScope } from '../../../../shared/commit-message-host-key' +import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' +import { getConnectionIdFromState } from '@/lib/connection-context' +import { + discoverRuntimeCommitMessageModels, + getRuntimeGitScope, + type RuntimeGitContext +} from '@/runtime/runtime-git-client' +import { useAppStore } from '@/store' + +export type NativeChatModelDiscoveryContext = { + hostKey: string + runtime: RuntimeGitContext +} + +export function resolveNativeChatModelDiscoveryContext( + terminalTabId: string +): NativeChatModelDiscoveryContext | null { + const state = useAppStore.getState() + const worktreeId = + Object.entries(state.tabsByWorktree ?? {}).find(([, tabs]) => + tabs.some((tab) => tab.id === terminalTabId) + )?.[0] ?? null + const connectionId = getConnectionIdFromState(state, worktreeId) + if (worktreeId && connectionId === undefined) { + return null + } + const settings = getSettingsForAgentTabRuntimeOwner(terminalTabId) + const worktreePath = worktreeId ? (state.getKnownWorktreeById?.(worktreeId)?.path ?? '') : '' + const scope = getRuntimeGitScope(settings, connectionId) + return { + hostKey: getCommitMessageModelDiscoveryHostKeyForScope(scope), + runtime: { + settings, + worktreeId, + worktreePath, + ...(connectionId ? { connectionId } : {}) + } + } +} + +export async function discoverNativeChatCatalogModels( + agent: AgentType, + context: RuntimeGitContext +): Promise { + const result = await discoverRuntimeCommitMessageModels(context, agent) + if (!result.success || result.models.length === 0) { + return null + } + return result.models.map((model) => ({ + id: model.id, + label: model.label, + options: [] + })) +} diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts b/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts new file mode 100644 index 000000000000..008d857ec9c8 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { CatalogModel } from '../../../../shared/agent-session-option-catalog' +import { + clearNativeChatModelEnrichmentForTests, + ensureNativeChatModelEnrichment, + readNativeChatEnrichedModels, + subscribeNativeChatEnrichedModels +} from './native-chat-session-option-enrichment' + +describe('native chat session option enrichment', () => { + beforeEach(() => clearNativeChatModelEnrichmentForTests()) + + it('keeps reads synchronous while one host-scoped probe is in flight', async () => { + let resolveDiscovery: ((models: CatalogModel[]) => void) | undefined + const discover = vi.fn( + () => + new Promise((resolve) => { + resolveDiscovery = resolve + }) + ) + const listener = vi.fn() + subscribeNativeChatEnrichedModels('cursor', 'ssh:one', listener) + + ensureNativeChatModelEnrichment({ agent: 'cursor', hostKey: 'ssh:one', discover }) + ensureNativeChatModelEnrichment({ agent: 'cursor', hostKey: 'ssh:one', discover }) + + expect(readNativeChatEnrichedModels('cursor', 'ssh:one')).toBeNull() + expect(discover).toHaveBeenCalledOnce() + + resolveDiscovery?.([ + { id: 'gpt-5.3-codex', label: 'GPT 5.3 live', options: [] }, + { id: 'account-model', label: 'Account model', options: [] } + ]) + await vi.waitFor(() => expect(listener).toHaveBeenCalledOnce()) + + const models = readNativeChatEnrichedModels('cursor', 'ssh:one')! + expect(models.find((model) => model.id === 'gpt-5.3-codex')).toMatchObject({ + label: 'GPT 5.3 live', + options: expect.arrayContaining([expect.objectContaining({ id: 'effort' })]) + }) + expect(models.at(-1)).toMatchObject({ id: 'account-model' }) + expect(readNativeChatEnrichedModels('cursor', 'ssh:two')).toBeNull() + }) + + it('falls back permanently to the seed after a failed once-per-host probe', async () => { + const discover = vi.fn().mockRejectedValue(new Error('offline')) + ensureNativeChatModelEnrichment({ agent: 'cursor', hostKey: 'local', discover }) + await vi.waitFor(() => expect(discover).toHaveBeenCalledOnce()) + await Promise.resolve() + + ensureNativeChatModelEnrichment({ agent: 'cursor', hostKey: 'local', discover }) + expect(discover).toHaveBeenCalledOnce() + expect(readNativeChatEnrichedModels('cursor', 'local')).toBeNull() + }) + + it('does not probe agents whose catalogs have no discovery command', () => { + const discover = vi.fn() + ensureNativeChatModelEnrichment({ agent: 'claude', hostKey: 'local', discover }) + expect(discover).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.ts b/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.ts new file mode 100644 index 000000000000..5fde5625c525 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.ts @@ -0,0 +1,87 @@ +import type { AgentType } from '../../../../shared/agent-status-types' +import { + getAgentSessionOptionCatalog, + mergeCatalogModels, + type CatalogModel +} from '../../../../shared/agent-session-option-catalog' + +type CatalogEnrichmentEntry = { + state: 'idle' | 'pending' | 'settled' + models: CatalogModel[] | null + listeners: Set<(models: CatalogModel[]) => void> +} + +const enrichmentByAgentHost = new Map() + +function enrichmentKey(agent: AgentType, hostKey: string): string { + return JSON.stringify([agent, hostKey]) +} + +export function readNativeChatEnrichedModels( + agent: AgentType, + hostKey: string +): CatalogModel[] | null { + const models = enrichmentByAgentHost.get(enrichmentKey(agent, hostKey))?.models + return models ? [...models] : null +} + +export function subscribeNativeChatEnrichedModels( + agent: AgentType, + hostKey: string, + listener: (models: CatalogModel[]) => void +): () => void { + const key = enrichmentKey(agent, hostKey) + const entry = enrichmentByAgentHost.get(key) ?? { + state: 'idle' as const, + models: null, + listeners: new Set<(models: CatalogModel[]) => void>() + } + entry.listeners.add(listener) + enrichmentByAgentHost.set(key, entry) + return () => entry.listeners.delete(listener) +} + +export function ensureNativeChatModelEnrichment(args: { + agent: AgentType + hostKey: string + discover: () => Promise +}): void { + const catalog = getAgentSessionOptionCatalog(args.agent) + if (!catalog?.listModels) { + return + } + const key = enrichmentKey(args.agent, args.hostKey) + const existing = enrichmentByAgentHost.get(key) + if (existing?.state === 'pending' || existing?.state === 'settled') { + return + } + const entry: CatalogEnrichmentEntry = existing ?? { + state: 'idle', + models: null, + listeners: new Set() + } + entry.state = 'pending' + enrichmentByAgentHost.set(key, entry) + + // Why: model discovery must never delay rendering or launching; the seed is + // immediately usable while this once-per-host probe runs in the background. + void args + .discover() + .then((discovered) => { + entry.state = 'settled' + if (!discovered || discovered.length === 0) { + return + } + entry.models = mergeCatalogModels(catalog.models, discovered) + for (const listener of entry.listeners) { + listener([...entry.models]) + } + }) + .catch(() => { + entry.state = 'settled' + }) +} + +export function clearNativeChatModelEnrichmentForTests(): void { + enrichmentByAgentHost.clear() +} diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-labels.ts b/src/renderer/src/components/native-chat/native-chat-session-option-labels.ts new file mode 100644 index 000000000000..33b940d88f32 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-session-option-labels.ts @@ -0,0 +1,135 @@ +import type { + SessionOptionDescriptor, + SessionOptionDisabledReason, + SessionOptionSelectChoice +} from '../../../../shared/native-chat-session-options' +import { translate } from '@/i18n/i18n' + +export function nativeChatSessionOptionLabel(descriptor: SessionOptionDescriptor): string { + switch (descriptor.id) { + case 'model': + return translate('components.native-chat.composer.model', 'Model') + case 'effort': + return translate('components.native-chat.composer.effort', descriptor.label) + case 'fastMode': + return translate('components.native-chat.composer.fastMode', 'Fast mode') + case 'thinking': + return translate('components.native-chat.composer.thinking', 'Thinking') + default: + return descriptor.label + } +} + +export function nativeChatSessionChoiceLabel(choice: SessionOptionSelectChoice): string { + switch (choice.value) { + case 'minimal': + return translate('components.native-chat.composer.optionValue.minimal', 'Minimal') + case 'low': + return translate('components.native-chat.composer.optionValue.low', 'Low') + case 'medium': + return translate('components.native-chat.composer.optionValue.medium', 'Medium') + case 'high': + return translate('components.native-chat.composer.optionValue.high', 'High') + case 'xhigh': + return translate('components.native-chat.composer.optionValue.xhigh', 'Extra high') + case 'max': + return translate('components.native-chat.composer.optionValue.max', 'Max') + default: + return choice.label + } +} + +export function nativeChatSessionOptionDisabledReason( + reason: SessionOptionDisabledReason | undefined +): string | null { + // Exhaustive over SessionOptionDisabledReason: a new key is a compile error + // here, so the localized label can never silently drift from the producer. + switch (reason) { + case 'set-when-session-starts': + return translate( + 'components.native-chat.composer.setWhenSessionStarts', + 'Set when the session starts.' + ) + case 'available-after-session-start': + return translate( + 'components.native-chat.composer.availableAfterSessionStarts', + 'Available after the session starts.' + ) + default: + return null + } +} + +export function nativeChatModelPillLabel(descriptor: SessionOptionDescriptor): string { + const modelLabel = translate('components.native-chat.composer.model', 'Model') + if ( + descriptor.valueSource === 'unknown' || + descriptor.kind.type !== 'select' || + !descriptor.kind.currentValue + ) { + return modelLabel + } + const valueLabel = nativeChatSessionChoiceLabel( + descriptor.kind.choices.find((choice) => choice.value === descriptor.kind.currentValue) ?? { + value: descriptor.kind.currentValue, + label: descriptor.kind.currentValue + } + ) + return translate('components.native-chat.composer.modelWithValue', 'Model: {{value0}}', { + value0: valueLabel + }) +} + +export function nativeChatOptionsPillTitle( + descriptors: readonly SessionOptionDescriptor[] +): string { + const effort = descriptors.find((descriptor) => descriptor.id === 'effort') + // Why: an effort-backed group is primarily the effort picker, even when it also reports modes. + return effort + ? nativeChatSessionOptionLabel(effort) + : translate('components.native-chat.composer.sessionOptions', 'Session options') +} + +export function nativeChatOptionsPillLabel( + descriptors: readonly SessionOptionDescriptor[] +): string { + const effort = descriptors.find((descriptor) => descriptor.id === 'effort') + const labels: string[] = [] + for (const descriptor of descriptors) { + if (descriptor.valueSource === 'unknown') { + continue + } + if (descriptor.kind.type === 'select' && descriptor.kind.currentValue) { + const choice = descriptor.kind.choices.find( + (candidate) => candidate.value === descriptor.kind.currentValue + ) + labels.push( + nativeChatSessionChoiceLabel( + choice ?? { + value: descriptor.kind.currentValue, + label: descriptor.kind.currentValue + } + ) + ) + } else if (descriptor.kind.type === 'boolean' && descriptor.kind.currentValue === true) { + labels.push( + descriptor.id === 'fastMode' + ? translate('components.native-chat.composer.optionValue.fast', 'Fast') + : nativeChatSessionOptionLabel(descriptor) + ) + } + } + if (labels.length > 0) { + const joinedLabels = labels.join(' · ') + if (effort) { + return translate('components.native-chat.composer.effortWithValue', 'Effort: {{value0}}', { + value0: joinedLabels + }) + } + return joinedLabels + } + if (effort) { + return nativeChatSessionOptionLabel(effort) + } + return translate('components.native-chat.composer.options', 'Options') +} diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-reporting.ts b/src/renderer/src/components/native-chat/native-chat-session-option-reporting.ts new file mode 100644 index 000000000000..411a0f988d7a --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-session-option-reporting.ts @@ -0,0 +1,28 @@ +import type { SessionOptionValue } from '../../../../shared/native-chat-session-options' +import type { NativeChatSessionOptionRecord } from './native-chat-session-option-cache' + +export function applyNativeChatReportedSessionOptions( + record: NativeChatSessionOptionRecord, + values: Record +): boolean { + const modelId = typeof values.model === 'string' ? values.model : null + if (!modelId) { + return false + } + const modelChanged = record.model?.value !== modelId + let changed = modelChanged || record.model?.source !== 'reported' + record.model = { value: modelId, source: 'reported' } + const modelValues = modelChanged ? {} : { ...record.valuesByModel[modelId] } + for (const [id, value] of Object.entries(values)) { + if (id === 'model') { + continue + } + const current = modelValues[id] + if (current?.value !== value || current.source !== 'reported') { + changed = true + } + modelValues[id] = { value, source: 'reported' } + } + record.valuesByModel[modelId] = modelValues + return changed +} diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-snapshot.ts b/src/renderer/src/components/native-chat/native-chat-session-option-snapshot.ts new file mode 100644 index 000000000000..987e66edf4b4 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-session-option-snapshot.ts @@ -0,0 +1,178 @@ +import { + findCatalogModel, + type AgentSessionOptionCatalog, + type CatalogMidSessionApply, + type CatalogModel, + type CatalogOption +} from '../../../../shared/agent-session-option-catalog' +import type { + SessionOptionDescriptor, + SessionOptionSelectChoice, + SessionOptionValue +} from '../../../../shared/native-chat-session-options' +import type { + NativeChatSessionOptionRecord, + TrackedNativeChatSessionOption +} from './native-chat-session-option-cache' +import { translate } from '@/i18n/i18n' + +export type NativeChatSessionOptionMode = 'draft' | 'live' + +function choiceWithCurrent( + choices: readonly SessionOptionSelectChoice[], + tracked: TrackedNativeChatSessionOption | undefined +): SessionOptionSelectChoice[] { + const result = [...choices] + const current = typeof tracked?.value === 'string' ? tracked.value : null + if (current && !result.some((choice) => choice.value === current)) { + result.push({ value: current, label: current }) + } + return result +} + +function settableState(args: { + mode: NativeChatSessionOptionMode + apply: { launchArgs?: unknown; composedIntoModel?: true; midSession?: CatalogMidSessionApply } + composedModelApply?: { midSession?: CatalogMidSessionApply } +}): Pick { + if (args.mode === 'draft') { + return args.apply.launchArgs || args.apply.composedIntoModel + ? { settable: true } + : { settable: false, disabledReason: 'available-after-session-start' } + } + const midSession = args.apply.midSession + if (args.apply.composedIntoModel && args.composedModelApply?.midSession?.kind === 'command') { + return { settable: true } + } + return midSession && midSession.kind !== 'unsupported' + ? { settable: true } + : { settable: false, disabledReason: 'set-when-session-starts' } +} + +function actionForApply( + apply: { midSession?: CatalogMidSessionApply }, + tracked: TrackedNativeChatSessionOption | undefined, + mode: NativeChatSessionOptionMode +): SessionOptionDescriptor['action'] { + if (mode !== 'live') { + return undefined + } + if (apply.midSession?.kind === 'agent-picker') { + return { type: 'agent-picker' } + } + if (apply.midSession?.kind === 'toggle-command' && !tracked) { + return { type: 'toggle-command' } + } + return undefined +} + +function optionDescriptor(args: { + option: CatalogOption + tracked: TrackedNativeChatSessionOption | undefined + mode: NativeChatSessionOptionMode + composedModelApply: AgentSessionOptionCatalog['modelApply'] +}): SessionOptionDescriptor | null { + const { option, tracked, mode, composedModelApply } = args + const action = actionForApply(option.apply, tracked, mode) + const settable = settableState({ mode, apply: option.apply, composedModelApply }) + if (option.kind.type === 'select') { + const choices = choiceWithCurrent(option.kind.choices, tracked) + if (choices.length <= 1) { + return null + } + return { + id: option.id, + label: option.label, + ...(option.description ? { description: option.description } : {}), + ...(option.category ? { category: option.category } : {}), + kind: { + type: 'select', + ...(typeof tracked?.value === 'string' ? { currentValue: tracked.value } : {}), + choices + }, + valueSource: tracked?.source ?? 'unknown', + ...settable, + ...(action ? { action } : {}) + } + } + return { + id: option.id, + label: option.label, + ...(option.description ? { description: option.description } : {}), + ...(option.category ? { category: option.category } : {}), + kind: { + type: 'boolean', + ...(typeof tracked?.value === 'boolean' ? { currentValue: tracked.value } : {}) + }, + valueSource: tracked?.source ?? 'unknown', + ...settable, + ...(action ? { action } : {}) + } +} + +export function buildNativeChatSessionOptionSnapshot(args: { + catalog: AgentSessionOptionCatalog + models: readonly CatalogModel[] + record: NativeChatSessionOptionRecord + mode: NativeChatSessionOptionMode +}): SessionOptionDescriptor[] { + const { catalog, models, record, mode } = args + const modelTracked = record.model + const modelChoices = choiceWithCurrent( + models.map(({ id, label, description }) => ({ + value: id, + label, + ...(description ? { description } : {}) + })), + modelTracked + ) + const modelSettable = settableState({ mode, apply: catalog.modelApply }) + const modelAction = actionForApply(catalog.modelApply, modelTracked, mode) + const snapshot: SessionOptionDescriptor[] = [ + { + id: 'model', + label: translate('components.native-chat.composer.model', 'Model'), + category: 'model', + kind: { + type: 'select', + ...(typeof modelTracked?.value === 'string' ? { currentValue: modelTracked.value } : {}), + choices: modelChoices + }, + valueSource: modelTracked?.source ?? 'unknown', + ...modelSettable, + ...(modelAction ? { action: modelAction } : {}) + } + ] + if (typeof modelTracked?.value !== 'string') { + return snapshot + } + const model = findCatalogModel({ ...catalog, models: [...models] }, modelTracked.value) + const trackedValues = record.valuesByModel[modelTracked.value] ?? {} + for (const option of model?.options ?? []) { + const descriptor = optionDescriptor({ + option, + tracked: trackedValues[option.id], + mode, + composedModelApply: catalog.modelApply + }) + if (descriptor) { + snapshot.push(descriptor) + } + } + return snapshot +} + +export function flattenNativeChatSessionOptionRecord( + record: NativeChatSessionOptionRecord, + modelId: string +): Record { + return { + model: modelId, + ...Object.fromEntries( + Object.entries(record.valuesByModel[modelId] ?? {}).map(([id, tracked]) => [ + id, + tracked.value + ]) + ) + } +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-dictation-actions.ts b/src/renderer/src/components/native-chat/use-native-chat-dictation-actions.ts new file mode 100644 index 000000000000..7d4caf72b8b6 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-dictation-actions.ts @@ -0,0 +1,28 @@ +import { useCallback, type Dispatch, type RefObject, type SetStateAction } from 'react' +import { dispatchDictationControl } from '../dictation/dictation-control-events' + +export function useNativeChatDictationActions(args: { + textareaRef: RefObject + setDictationPressed: Dispatch> +}): { + toggleDictation: () => void + startHoldDictation: () => void + stopHoldDictation: () => void +} { + const { setDictationPressed, textareaRef } = args + const focusForDictation = useCallback(() => textareaRef.current?.focus(), [textareaRef]) + const toggleDictation = useCallback(() => { + focusForDictation() + dispatchDictationControl('toggle') + }, [focusForDictation]) + const startHoldDictation = useCallback(() => { + setDictationPressed(true) + focusForDictation() + dispatchDictationControl('start') + }, [focusForDictation, setDictationPressed]) + const stopHoldDictation = useCallback(() => { + setDictationPressed(false) + dispatchDictationControl('stop') + }, [setDictationPressed]) + return { toggleDictation, startHoldDictation, stopHoldDictation } +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-file-attachment-actions.ts b/src/renderer/src/components/native-chat/use-native-chat-file-attachment-actions.ts new file mode 100644 index 000000000000..5a6094cfea33 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-file-attachment-actions.ts @@ -0,0 +1,27 @@ +import { useCallback, useEffect } from 'react' +import { NATIVE_FILE_DROP_TARGET } from '../../../../shared/native-file-drop' + +export function useNativeChatFileAttachmentActions( + attachExternalPaths: (paths: string[]) => void +): { pickAttachment: () => void } { + useEffect( + () => + window.api.ui.onFileDrop((payload) => { + if (payload.target === NATIVE_FILE_DROP_TARGET.composer) { + attachExternalPaths(payload.paths) + } + }), + [attachExternalPaths] + ) + + const pickAttachment = useCallback(() => { + void (async () => { + const filePath = await window.api.shell.pickAttachment() + if (filePath) { + attachExternalPaths([filePath]) + } + })() + }, [attachExternalPaths]) + + return { pickAttachment } +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-file-link-click.ts b/src/renderer/src/components/native-chat/use-native-chat-file-link-click.ts new file mode 100644 index 000000000000..3e745bcd2bc6 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-file-link-click.ts @@ -0,0 +1,27 @@ +import { useCallback } from 'react' +import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown' +import { openDetectedFilePath } from '@/components/terminal-pane/terminal-file-open-routing' +import { resolveNativeChatFileLink, type NativeChatFileLinkContext } from './native-chat-file-link' + +export function useNativeChatFileLinkClick( + context: NativeChatFileLinkContext | null +): CommentMarkdownLinkClickHandler | undefined { + const openFileLink = useCallback( + (event, href) => { + const target = resolveNativeChatFileLink(href, context) + if (!target || !context) { + return + } + event.preventDefault() + event.stopPropagation() + openDetectedFilePath(target.absolutePath, target.line, target.column, { + worktreeId: context.worktreeId, + worktreePath: context.worktreePath, + runtimeEnvironmentId: context.runtimeEnvironmentId, + openWithSystemDefault: event.shiftKey + }) + }, + [context] + ) + return context ? openFileLink : undefined +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-session-option-command.ts b/src/renderer/src/components/native-chat/use-native-chat-session-option-command.ts new file mode 100644 index 000000000000..9720ab9f950c --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-session-option-command.ts @@ -0,0 +1,114 @@ +import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react' +import type { AgentType } from '../../../../shared/agent-status-types' +import { emitNativeChatMessageSent } from '@/lib/native-chat-telemetry' +import { + nativeChatComposerTargetIsRemote, + type NativeChatResolvedTarget +} from './native-chat-composer-target' +import { pushHistory, type HistoryState } from './native-chat-composer-state' +import { sendNativeChatMessageVerified } from './native-chat-runtime-send' +import { + createClaudeModelSwitchConfirmationObserver, + type ClaudeModelSwitchConfirmationObserver +} from './claude-model-switch-confirmation' +import type { NativeChatSessionOptionDispatchCommand } from './native-chat-session-option-command-dispatch' + +export function useNativeChatSessionOptionCommand(args: { + agent: AgentType + disabled: boolean + onSlashCommand?: (command: string) => void + resolveTarget: () => NativeChatResolvedTarget | null + setHistory: Dispatch> +}): { dispatch: NativeChatSessionOptionDispatchCommand; isDispatching: boolean } { + const { agent, disabled, onSlashCommand, resolveTarget, setHistory } = args + const mountedRef = useRef(true) + const activeObserversRef = useRef(new Set()) + const activeSendsRef = useRef(new Set()) + // Why: expose an in-flight flag so the composer can block a normal send while + // an option command's body+delayed-Enter (and its confirmation observer) is + // still writing to the same pty — otherwise the two write sequences interleave + // on one input line and corrupt both. + const [isDispatching, setIsDispatching] = useState(false) + + useEffect(() => { + mountedRef.current = true + const observers = activeObserversRef.current + const sends = activeSendsRef.current + return () => { + mountedRef.current = false + for (const send of sends) { + send.abort() + } + sends.clear() + for (const observer of observers) { + observer.dispose() + } + observers.clear() + } + }, []) + + const dispatch = useCallback( + async (command, options) => { + const target = resolveTarget() + if (!target || disabled) { + throw new Error('No live terminal is available.') + } + const detectClaudeConfirmation = + options?.detectAgentInteraction === 'claude-model-switch-confirmation' + let observer: ClaudeModelSwitchConfirmationObserver | null = null + if (detectClaudeConfirmation) { + observer = createClaudeModelSwitchConfirmationObserver({ + ptyId: target.ptyId, + settings: target.settings, + expectedModelLabel: options?.expectedChoiceLabel ?? null + }) + activeObserversRef.current.add(observer) + await observer.ready + if (!mountedRef.current) { + activeObserversRef.current.delete(observer) + observer.dispose() + throw new Error('Native chat command was canceled because the composer closed.') + } + // Why: arm only after the observer reaches the live PTY tail, then + // submit immediately so historical output cannot satisfy the match. + observer.arm() + } + const sendController = new AbortController() + activeSendsRef.current.add(sendController) + setIsDispatching(true) + try { + const accepted = await sendNativeChatMessageVerified( + target.settings, + target.ptyId, + command, + sendController.signal + ) + if (!accepted) { + throw new Error('The terminal did not accept the command.') + } + // Why: start the model-switch detection clock only now that the command + // has been accepted, so SSH/remote send latency doesn't eat the window + // before the agent has responded. + observer?.startDetection() + onSlashCommand?.(command.trim()) + emitNativeChatMessageSent({ + agent, + runtime: nativeChatComposerTargetIsRemote(target.ptyId) ? 'remote' : 'local' + }) + setHistory((previous) => pushHistory(previous, command)) + const outcome = observer ? await observer.result : undefined + return { outcome } + } finally { + activeSendsRef.current.delete(sendController) + setIsDispatching(activeSendsRef.current.size > 0) + if (observer) { + activeObserversRef.current.delete(observer) + observer.dispose() + } + } + }, + [agent, disabled, onSlashCommand, resolveTarget, setHistory] + ) + + return { dispatch, isDispatching } +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-session-options.ts b/src/renderer/src/components/native-chat/use-native-chat-session-options.ts new file mode 100644 index 000000000000..23c5f87a294c --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-session-options.ts @@ -0,0 +1,155 @@ +import { useEffect, useMemo, useSyncExternalStore } from 'react' +import type { AgentType } from '../../../../shared/agent-status-types' +import { updateNativeChatSessionOptionDefaults } from '../../../../shared/native-chat-session-option-defaults' +import type { SessionOptionDescriptor } from '../../../../shared/native-chat-session-options' +import { useAppStore } from '../../store' +import { + createNativeChatPtySessionOptions, + type NativeChatPtySessionOptionsSurface +} from './native-chat-pty-session-options' +import type { NativeChatSessionOptionDispatchCommand } from './native-chat-session-option-command-dispatch' +import { + ensureNativeChatModelEnrichment, + readNativeChatEnrichedModels, + subscribeNativeChatEnrichedModels +} from './native-chat-session-option-enrichment' +import { + discoverNativeChatCatalogModels, + resolveNativeChatModelDiscoveryContext +} from './native-chat-session-option-discovery' +import { readClaudeSessionOptionsFromTerminalScreen } from './claude-terminal-session-options' + +const EMPTY_SNAPSHOT: SessionOptionDescriptor[] = [] +const subscribeEmpty = (): (() => void) => () => {} +const getEmptySnapshot = (): SessionOptionDescriptor[] => EMPTY_SNAPSHOT + +export function useNativeChatSessionOptions(args: { + agent: AgentType + terminalTabId: string + targetPtyId: string | null + dispatchCommand: NativeChatSessionOptionDispatchCommand + onAgentPicker?: () => void + readTerminalScreen?: () => string | null +}): { + surface: NativeChatPtySessionOptionsSurface | null + snapshot: SessionOptionDescriptor[] +} { + const { agent, terminalTabId, targetPtyId, dispatchCommand, onAgentPicker, readTerminalScreen } = + args + const discoveryContext = useMemo( + () => resolveNativeChatModelDiscoveryContext(terminalTabId), + [terminalTabId] + ) + const surface = useMemo(() => { + // Why: native chat currently attaches only after startup is already queued; + // exposing a draft picker here would claim it can still mutate that command. + if (!targetPtyId) { + return null + } + const scopeKey = targetPtyId ?? terminalTabId + const reportedValues = + agent === 'claude' ? readClaudeSessionOptionsFromTerminalScreen(readTerminalScreen?.()) : null + let settingsWrite = Promise.resolve() + return createNativeChatPtySessionOptions({ + agent, + scopeKey, + ...(targetPtyId ? { fallbackScopeKey: terminalTabId } : {}), + ...(discoveryContext + ? { + initialModels: + readNativeChatEnrichedModels(agent, discoveryContext.hostKey) ?? undefined + } + : {}), + mode: targetPtyId ? 'live' : 'draft', + reportedValues, + dispatchCommand, + onAgentPicker, + persistSelection: async ({ modelId, optionId, value }) => { + // Why: read the live persisted defaults at write time (after any prior + // write in this chain settles) and merge only this selection onto them, + // rather than a baseline captured once at surface creation. A frozen + // baseline would let a second same-agent pane's write be clobbered, + // since updateSettings shallow-merges nativeChatSessionOptions. Chaining + // still keeps rapid consecutive picks in selection order. + settingsWrite = settingsWrite + .catch(() => undefined) + .then(() => { + const base = useAppStore.getState().settings?.nativeChatSessionOptions + const next = updateNativeChatSessionOptionDefaults({ + persisted: base, + agent, + modelId, + optionId, + value + }) + return useAppStore.getState().updateSettings({ nativeChatSessionOptions: next }) + }) + await settingsWrite + } + }) + }, [ + agent, + dispatchCommand, + discoveryContext, + onAgentPicker, + readTerminalScreen, + targetPtyId, + terminalTabId + ]) + + useEffect(() => { + if (!surface || agent !== 'claude') { + return + } + let cancelled = false + const reportCurrentValues = async (): Promise => { + let authoritativeScreen: string | null = null + if (targetPtyId && window.api?.pty?.getMainBufferSnapshot) { + try { + const snapshot = await window.api.pty.getMainBufferSnapshot(targetPtyId, { + scrollbackRows: 0 + }) + // Why: the API snapshots the main buffer, which is stale while a TUI + // owns the alternate screen. The mounted xterm is authoritative then. + authoritativeScreen = snapshot?.alternateScreen ? null : (snapshot?.data ?? null) + } catch { + // The mounted renderer buffer remains a transport-neutral fallback. + } + } + const reportedValues = + readClaudeSessionOptionsFromTerminalScreen(authoritativeScreen) ?? + readClaudeSessionOptionsFromTerminalScreen(readTerminalScreen?.()) + if (!cancelled && reportedValues) { + surface.reportSessionOptions(reportedValues) + } + } + void reportCurrentValues() + return () => { + cancelled = true + } + }, [agent, readTerminalScreen, surface, targetPtyId]) + + useEffect(() => { + if (!surface || !discoveryContext) { + return + } + const unsubscribe = subscribeNativeChatEnrichedModels( + agent, + discoveryContext.hostKey, + (models) => surface.replaceModels(models) + ) + ensureNativeChatModelEnrichment({ + agent, + hostKey: discoveryContext.hostKey, + discover: () => discoverNativeChatCatalogModels(agent, discoveryContext.runtime) + }) + return unsubscribe + }, [agent, discoveryContext, surface]) + + const snapshot = useSyncExternalStore( + surface?.subscribe ?? subscribeEmpty, + surface?.getSnapshot ?? getEmptySnapshot, + surface?.getSnapshot ?? getEmptySnapshot + ) + return { surface, snapshot } +} diff --git a/src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts b/src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts index 73571a2f330a..1e57102600fe 100644 --- a/src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts +++ b/src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts @@ -14,7 +14,8 @@ describe('buildOnboardingFolderAgentStartup', () => { }) expect(startup).toEqual({ - command: "codex '--dangerously-bypass-approvals-and-sandbox'", + command: + "codex '-m' 'gpt-5.6-sol' '-c' 'model_reasoning_effort=medium' '--dangerously-bypass-approvals-and-sandbox'", env: {}, launchAgent: 'codex', launchConfig: { @@ -22,6 +23,7 @@ describe('buildOnboardingFolderAgentStartup', () => { agentArgs: '--dangerously-bypass-approvals-and-sandbox', agentEnv: {} }, + sessionOptions: { model: 'gpt-5.6-sol', effort: 'medium' }, telemetry: { agent_kind: 'codex', launch_source: 'onboarding', @@ -97,7 +99,8 @@ describe('buildOnboardingFolderAgentStartup', () => { false ) ).toEqual({ - command: "echo onboarding-folder-agent '--dangerously-bypass-approvals-and-sandbox'", + command: + "echo onboarding-folder-agent '-m' 'gpt-5.6-sol' '-c' 'model_reasoning_effort=medium' '--dangerously-bypass-approvals-and-sandbox'", env: {}, launchAgent: 'codex', launchConfig: { @@ -105,6 +108,7 @@ describe('buildOnboardingFolderAgentStartup', () => { agentArgs: '--dangerously-bypass-approvals-and-sandbox', agentEnv: {} }, + sessionOptions: { model: 'gpt-5.6-sol', effort: 'medium' }, telemetry: { agent_kind: 'codex', launch_source: 'onboarding', diff --git a/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts b/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts index 336a3882b3d4..d3afd06e1390 100644 --- a/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts +++ b/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts @@ -3,6 +3,7 @@ import { useAppStore } from '@/store' import type { TuiAgent } from '../../../../shared/types' import type { SourceControlAgentActionDeliveryPlanState } from './SourceControlAgentActionDialogForm' import { buildSourceControlAgentConnectionErrorPlan } from './source-control-agent-action-dialog-support' +import { resolveNativeChatSessionOptionDefaults } from '../../../../shared/native-chat-session-option-defaults' type BuildSourceControlAgentDeliveryPlanArgs = { selectedAgent: TuiAgent | null @@ -34,6 +35,12 @@ export function buildSourceControlAgentDeliveryPlan({ agent: selectedAgent, commandInput, agentArgs, + sessionOptions: selectedAgent + ? resolveNativeChatSessionOptionDefaults( + useAppStore.getState().settings?.nativeChatSessionOptions, + selectedAgent + ) + : undefined, promptDelivery, detectedAgents, disabledAgents: useAppStore.getState().settings?.disabledTuiAgents, diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts index ee48eac66f2f..faf2b6ede754 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts @@ -20,6 +20,7 @@ import { isWslUncPath } from '../../../../shared/wsl-paths' import { resolveLocalWindowsAgentStartupShell } from '../../../../shared/windows-terminal-shell' import type { AgentStartupShell } from '../../../../shared/tui-agent-startup-shell' import type { LaunchSource } from '../../../../shared/telemetry-events' +import type { SessionOptionValue } from '../../../../shared/native-chat-session-options' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' import { getLinkedItemDisplayName, @@ -46,6 +47,7 @@ type SubmitFolderWorkspaceCreateParams = { agentCmdOverrides: Record | undefined agentArgs?: string | null agentEnv?: Record + sessionOptions?: Record terminalWindowsShell?: string | null isRemote?: boolean launchSource?: LaunchSource @@ -71,6 +73,7 @@ export function buildFolderWorkspaceLinkedStartupPlan(args: { agentCmdOverrides: Record | undefined agentArgs?: string | null agentEnv?: Record + sessionOptions?: Record platform: NodeJS.Platform shell?: AgentStartupShell isRemote: boolean @@ -87,6 +90,7 @@ export function buildFolderWorkspaceLinkedStartupPlan(args: { cmdOverrides: args.agentCmdOverrides ?? {}, agentArgs: args.agentArgs, agentEnv: args.agentEnv, + sessionOptions: args.sessionOptions, platform: args.platform, shell: args.shell, isRemote: args.isRemote @@ -99,6 +103,7 @@ export function buildFolderWorkspaceLinkedStartupPlan(args: { expectedProcess: draftLaunchPlan.expectedProcess, followupPrompt: null, launchConfig: draftLaunchPlan.launchConfig, + ...(draftLaunchPlan.sessionOptions ? { sessionOptions: draftLaunchPlan.sessionOptions } : {}), ...(draftLaunchPlan.startupCommandDelivery ? { startupCommandDelivery: draftLaunchPlan.startupCommandDelivery } : {}), @@ -114,6 +119,7 @@ export function buildFolderWorkspaceLinkedStartupPlan(args: { cmdOverrides: args.agentCmdOverrides ?? {}, agentArgs: args.agentArgs, agentEnv: args.agentEnv, + sessionOptions: args.sessionOptions, platform: args.platform, shell: args.shell, isRemote: args.isRemote, @@ -159,6 +165,7 @@ export async function submitFolderWorkspaceCreate({ agentCmdOverrides, agentArgs, agentEnv, + sessionOptions, terminalWindowsShell, launchSource = 'sidebar', runtimeEnvironmentId = null, @@ -189,6 +196,7 @@ export async function submitFolderWorkspaceCreate({ agentCmdOverrides, agentArgs, agentEnv, + sessionOptions, platform: launchPlatform, shell: launchShell, isRemote: launchIsRemote @@ -200,6 +208,7 @@ export async function submitFolderWorkspaceCreate({ cmdOverrides: agentCmdOverrides ?? {}, agentArgs, agentEnv, + sessionOptions, platform: launchPlatform, shell: launchShell, isRemote: launchIsRemote, @@ -247,6 +256,7 @@ export async function submitFolderWorkspaceCreate({ launchConfig: startupPlan.launchConfig, ...(startupPlan.launchToken ? { launchToken: startupPlan.launchToken } : {}), launchAgent: quickAgent, + ...(startupPlan.sessionOptions ? { sessionOptions: startupPlan.sessionOptions } : {}), ...(startupPlan.draftPrompt ? { draftPrompt: startupPlan.draftPrompt } : {}), ...(startupPlan.startupCommandDelivery ? { startupCommandDelivery: startupPlan.startupCommandDelivery } diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 975aad10f941..db963a29f679 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -813,6 +813,13 @@ export default function TerminalPane({ } toggleNativeChatForLeaf(activeLeafId) }, [toggleNativeChatForLeaf]) + const readNativeChatTerminalScreen = useCallback((): string | null => { + if (!chatLeafId) { + return null + } + const pane = managerRef.current?.getPanes().find((candidate) => candidate.leafId === chatLeafId) + return pane?.serializeAddon.serialize({ scrollback: 0 }) ?? null + }, [chatLeafId]) const setTabLayout = useAppStore((store) => store.setTabLayout) const expectedLayoutLeafIdsAttr = expectedLayoutLeafIds.length > 0 ? expectedLayoutLeafIds.join(' ') : undefined @@ -3112,6 +3119,7 @@ export default function TerminalPane({ launchAgent={chatPaneLaunchAgent} resolvedAgent={chatPaneResolvedAgent} onSwitchToTerminal={() => toggleNativeChatForLeaf(chatPane.leafId)} + readTerminalScreen={readNativeChatTerminalScreen} contextMenuActions={{ onSplitRight: () => contextMenu.runForPane(chatPane.id, contextMenu.onSplitRight), onSplitDown: () => contextMenu.runForPane(chatPane.id, contextMenu.onSplitDown), diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index aaad1b6a6221..42f2ef4dd5b0 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -23,6 +23,8 @@ import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '@/lib/tui-agen import { filterEnabledTuiAgents, isTuiAgentEnabled } from '../../../shared/tui-agent-selection' import { repoIsRemote } from '../../../shared/agent-launch-remote' import { resolveLocalWindowsAgentStartupShell } from '../../../shared/windows-terminal-shell' +import { resolveNativeChatSessionOptionDefaults } from '../../../shared/native-chat-session-option-defaults' +import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache' import { resolveTuiAgentLaunchArgs, resolveTuiAgentLaunchEnv @@ -3356,6 +3358,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS ? resolveTuiAgentLaunchArgs(agent, settings?.agentDefaultArgs) : undefined, agentEnv: agent ? resolveTuiAgentLaunchEnv(agent, settings?.agentDefaultEnv) : undefined, + sessionOptions: agent + ? resolveNativeChatSessionOptionDefaults(settings?.nativeChatSessionOptions, agent) + : undefined, terminalWindowsShell: settings?.terminalWindowsShell, isRemote: folderTargetIsRemote, launchSource: telemetrySource === 'onboarding' ? 'onboarding' : 'new_workspace_composer', @@ -3412,6 +3417,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS settings?.agentDefaultArgs, settings?.agentDefaultEnv, settings?.autoRenameBranchFromWork, + settings?.nativeChatSessionOptions, settings?.terminalWindowsShell, telemetrySource ] @@ -3611,6 +3617,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS cmdOverrides: settings?.agentCmdOverrides ?? {}, agentArgs: resolveTuiAgentLaunchArgs(tuiAgent, settings?.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(tuiAgent, settings?.agentDefaultEnv), + sessionOptions: resolveNativeChatSessionOptionDefaults( + settings?.nativeChatSessionOptions, + tuiAgent + ), platform: selectedRepoAgentLaunchPlatform, shell: selectedRepoStartupShell, isRemote: selectedRepoIsRemote @@ -3731,6 +3741,13 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS } : {}) }) + if (startupPlan) { + const optionScopeKey = + (activation !== false ? activation.primaryTabId : null) ?? result.startupTerminal?.tabId + if (optionScopeKey) { + seedNativeChatAppliedSessionOptions(optionScopeKey, tuiAgent, startupPlan.sessionOptions) + } + } if (startupPlan && !backendSpawnedStartup) { void ensureAgentStartupInTerminal({ worktreeId: worktree.id, @@ -3792,6 +3809,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS settings?.agentDefaultArgs, settings?.agentDefaultEnv, settings?.autoRenameBranchFromWork, + settings?.nativeChatSessionOptions, smartNameMode, setSidebarOpen, setupDecision, @@ -4030,6 +4048,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS cmdOverrides: settings?.agentCmdOverrides ?? {}, agentArgs: resolveTuiAgentLaunchArgs(agent, settings?.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(agent, settings?.agentDefaultEnv), + sessionOptions: resolveNativeChatSessionOptionDefaults( + settings?.nativeChatSessionOptions, + agent + ), platform: selectedRepoAgentLaunchPlatform, shell: selectedRepoStartupShell, isRemote: selectedRepoIsRemote @@ -4043,6 +4065,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS expectedProcess: draftLaunchPlan.expectedProcess, followupPrompt: null, launchConfig: draftLaunchPlan.launchConfig, + ...(draftLaunchPlan.sessionOptions + ? { sessionOptions: draftLaunchPlan.sessionOptions } + : {}), ...(draftLaunchPlan.startupCommandDelivery ? { startupCommandDelivery: draftLaunchPlan.startupCommandDelivery } : {}), @@ -4055,6 +4080,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS cmdOverrides: settings?.agentCmdOverrides ?? {}, agentArgs: resolveTuiAgentLaunchArgs(agent, settings?.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(agent, settings?.agentDefaultEnv), + sessionOptions: resolveNativeChatSessionOptionDefaults( + settings?.nativeChatSessionOptions, + agent + ), platform: selectedRepoAgentLaunchPlatform, shell: selectedRepoStartupShell, isRemote: selectedRepoIsRemote, @@ -4244,6 +4273,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS settings?.agentDefaultArgs, settings?.agentDefaultEnv, settings?.autoRenameBranchFromWork, + settings?.nativeChatSessionOptions, smartNameMode, disabledTuiAgents, setupDecision, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index f275b3923b6f..45f8b3fb8bce 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -13184,7 +13184,30 @@ "pastedImageLabel": "Pasted image", "imagePasteFailed": "Image paste failed.", "worktreeNotReady": "Worktree not ready — try again in a moment.", - "uploadingAttachments": "Uploading {{value0}} file(s) to remote…" + "uploadingAttachments": "Uploading {{value0}} file(s) to remote…", + "model": "Model", + "modelWithValue": "Model: {{value0}}", + "effort": "Effort", + "effortWithValue": "Effort: {{value0}}", + "fastMode": "Fast mode", + "thinking": "Thinking", + "options": "Options", + "sessionOptions": "Session options", + "chooseInAgentPicker": "Choose in agent picker…", + "toggleOption": "Toggle {{value0}}", + "sentNotConfirmed": "Sent to the agent — not confirmed", + "setWhenSessionStarts": "Set when the session starts.", + "availableAfterSessionStarts": "Available after the session starts.", + "optionUpdateFailed": "Could not update option", + "optionValue": { + "fast": "Fast", + "minimal": "Minimal", + "low": "Low", + "medium": "Medium", + "high": "High", + "xhigh": "Extra high", + "max": "Max" + } }, "tool": { "running": "Running…", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 9261f9971d07..48b2a4b5284f 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -13184,7 +13184,30 @@ "pastedImageLabel": "Imagen pegada", "imagePasteFailed": "Error al pegar imagen.", "worktreeNotReady": "Worktree no está listo — intenta de nuevo en un momento.", - "uploadingAttachments": "Subiendo {{value0}} archivo(s) al remoto…" + "uploadingAttachments": "Subiendo {{value0}} archivo(s) al remoto…", + "model": "Modelo", + "modelWithValue": "Modelo: {{value0}}", + "effort": "Esfuerzo", + "effortWithValue": "Esfuerzo: {{value0}}", + "fastMode": "Modo rápido", + "thinking": "Razonamiento", + "options": "Opciones", + "sessionOptions": "Opciones de sesión", + "chooseInAgentPicker": "Elegir en el selector del agente…", + "toggleOption": "Alternar {{value0}}", + "sentNotConfirmed": "Enviado al agente — sin confirmar", + "setWhenSessionStarts": "Configúralo al iniciar la sesión.", + "availableAfterSessionStarts": "Disponible después de iniciar la sesión.", + "optionUpdateFailed": "No se pudo actualizar la opción", + "optionValue": { + "fast": "Rápido", + "minimal": "Mínimo", + "low": "Bajo", + "medium": "Medio", + "high": "Alto", + "xhigh": "Muy alto", + "max": "Máximo" + } }, "tool": { "running": "Ejecutando…", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index e1cf3313cdc0..538b1ef1b61d 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -13184,7 +13184,30 @@ "pastedImageLabel": "貼り付けた画像", "imagePasteFailed": "画像の貼り付けに失敗しました。", "worktreeNotReady": "Worktreeが準備できていません — しばらくしてから再試行してください。", - "uploadingAttachments": "{{value0}}個のファイルをリモートにアップロード中…" + "uploadingAttachments": "{{value0}}個のファイルをリモートにアップロード中…", + "model": "モデル", + "modelWithValue": "モデル: {{value0}}", + "effort": "推論レベル", + "effortWithValue": "推論レベル: {{value0}}", + "fastMode": "高速モード", + "thinking": "思考", + "options": "オプション", + "sessionOptions": "セッションオプション", + "chooseInAgentPicker": "エージェントの選択画面で選ぶ…", + "toggleOption": "{{value0}}を切り替える", + "sentNotConfirmed": "エージェントに送信済み — 未確認", + "setWhenSessionStarts": "セッション開始時に設定してください。", + "availableAfterSessionStarts": "セッション開始後に利用できます。", + "optionUpdateFailed": "オプションを更新できませんでした", + "optionValue": { + "fast": "高速", + "minimal": "最小", + "low": "低", + "medium": "中", + "high": "高", + "xhigh": "最高", + "max": "最大" + } }, "tool": { "running": "実行中…", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index d22ad9a5db8e..ccb2d833551b 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -13184,7 +13184,30 @@ "pastedImageLabel": "붙여넣은 이미지", "imagePasteFailed": "이미지 붙여넣기 실패.", "worktreeNotReady": "워크트리가 준비되지 않았습니다 — 잠시 후 다시 시도하세요.", - "uploadingAttachments": "{{value0}}개 파일을 원격으로 업로드 중…" + "uploadingAttachments": "{{value0}}개 파일을 원격으로 업로드 중…", + "model": "모델", + "modelWithValue": "모델: {{value0}}", + "effort": "추론 수준", + "effortWithValue": "추론 수준: {{value0}}", + "fastMode": "빠른 모드", + "thinking": "사고", + "options": "옵션", + "sessionOptions": "세션 옵션", + "chooseInAgentPicker": "에이전트 선택기에서 선택…", + "toggleOption": "{{value0}} 전환", + "sentNotConfirmed": "에이전트에 전송됨 — 확인되지 않음", + "setWhenSessionStarts": "세션을 시작할 때 설정하세요.", + "availableAfterSessionStarts": "세션이 시작된 후 사용할 수 있습니다.", + "optionUpdateFailed": "옵션을 업데이트하지 못했습니다", + "optionValue": { + "fast": "빠름", + "minimal": "최소", + "low": "낮음", + "medium": "중간", + "high": "높음", + "xhigh": "매우 높음", + "max": "최대" + } }, "tool": { "running": "실행 중…", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index b9fe5b678d65..86769650574e 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -13184,7 +13184,30 @@ "pastedImageLabel": "粘贴的图片", "imagePasteFailed": "图片粘贴失败。", "worktreeNotReady": "工作树未就绪 — 请稍后重试。", - "uploadingAttachments": "正在上传 {{value0}} 个文件到远程…" + "uploadingAttachments": "正在上传 {{value0}} 个文件到远程…", + "model": "模型", + "modelWithValue": "模型:{{value0}}", + "effort": "推理强度", + "effortWithValue": "推理强度:{{value0}}", + "fastMode": "快速模式", + "thinking": "思考", + "options": "选项", + "sessionOptions": "会话选项", + "chooseInAgentPicker": "在代理选择器中选择…", + "toggleOption": "切换{{value0}}", + "sentNotConfirmed": "已发送给代理 — 尚未确认", + "setWhenSessionStarts": "请在会话开始时设置。", + "availableAfterSessionStarts": "会话开始后可用。", + "optionUpdateFailed": "无法更新选项", + "optionValue": { + "fast": "快速", + "minimal": "最小", + "low": "低", + "medium": "中", + "high": "高", + "xhigh": "极高", + "max": "最大" + } }, "tool": { "running": "正在运行…", diff --git a/src/renderer/src/i18n/native-chat-locales.test.ts b/src/renderer/src/i18n/native-chat-locales.test.ts index 950080dc323d..9e27c12b2072 100644 --- a/src/renderer/src/i18n/native-chat-locales.test.ts +++ b/src/renderer/src/i18n/native-chat-locales.test.ts @@ -8,6 +8,7 @@ import zh from './locales/zh.json' const localizedCatalogs = { es, ja, ko, zh } const englishSetting = en.auto.components.settings.ExperimentalPane.nativeChat const englishSearch = en.auto.components.settings.experimental.search.nativeChat +const englishComposer = en.components['native-chat'].composer describe('native chat locale copy', () => { it.each(Object.entries(localizedCatalogs))( @@ -25,6 +26,25 @@ describe('native chat locale copy', () => { expect(localized).not.toBe(english) } expect(search.grok).toBe('grok') + const composer = catalog.components['native-chat'].composer + for (const key of [ + 'model', + 'modelWithValue', + 'effort', + 'effortWithValue', + 'fastMode', + 'thinking', + 'options', + 'sessionOptions', + 'chooseInAgentPicker', + 'sentNotConfirmed' + ] as const) { + expect(composer[key].trim()).not.toBe('') + expect(composer[key]).not.toBe(englishComposer[key]) + } + for (const key of ['fast', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'] as const) { + expect(composer.optionValue[key]).not.toBe(englishComposer.optionValue[key]) + } } ) }) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts index c43f98ef9694..b3c5029a317d 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts @@ -340,7 +340,8 @@ describe('launchAgentInNewTab', () => { environmentId: 'web-runtime', targetGroupId: 'group-1', activate: true, - command: "codex '--model' 'gpt-5' '--reasoning-effort' 'high' 'fix the spinner'", + command: + "codex '-m' 'gpt-5.6-sol' '-c' 'model_reasoning_effort=medium' '--model' 'gpt-5' '--reasoning-effort' 'high' 'fix the spinner'", env: { CODEX_PROFILE: 'captured' }, startupCommandDelivery: 'shell-ready', launchConfig: { @@ -485,7 +486,8 @@ describe('launchAgentInNewTab', () => { expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( 'tab-1', expect.objectContaining({ - command: "claude '--dangerously-skip-permissions' --prefill 'review Bob''s change'" + command: + "claude '--model' 'sonnet' '--effort' 'high' '--dangerously-skip-permissions' --prefill 'review Bob''s change'" }) ) }) @@ -503,7 +505,7 @@ describe('launchAgentInNewTab', () => { expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( 'tab-1', expect.objectContaining({ - command: 'claude "--dangerously-skip-permissions"' + command: 'claude "--model" "sonnet" "--effort" "high" "--dangerously-skip-permissions"' }) ) }) @@ -521,7 +523,7 @@ describe('launchAgentInNewTab', () => { expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( 'tab-1', expect.objectContaining({ - command: "claude '--dangerously-skip-permissions'" + command: "claude '--model' 'sonnet' '--effort' 'high' '--dangerously-skip-permissions'" }) ) }) @@ -541,7 +543,8 @@ describe('launchAgentInNewTab', () => { expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( 'tab-1', expect.objectContaining({ - command: 'codex "--model" "gpt-5" "fix the spinner"' + command: + 'codex "-m" "gpt-5.6-sol" "-c" "model_reasoning_effort=medium" "--model" "gpt-5" "fix the spinner"' }) ) }) @@ -561,7 +564,8 @@ describe('launchAgentInNewTab', () => { expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( 'tab-1', expect.objectContaining({ - command: "claude '--dangerously-skip-permissions' --prefill 'review Bob'\\''s change'" + command: + "claude '--model' 'sonnet' '--effort' 'high' '--dangerously-skip-permissions' --prefill 'review Bob'\\''s change'" }) ) }) @@ -579,7 +583,7 @@ describe('launchAgentInNewTab', () => { expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( 'tab-1', expect.objectContaining({ - command: "claude '--dangerously-skip-permissions'" + command: "claude '--model' 'sonnet' '--effort' 'high' '--dangerously-skip-permissions'" }) ) }) @@ -616,7 +620,8 @@ describe('launchAgentInNewTab', () => { expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( 'tab-1', expect.objectContaining({ - command: "claude '--dangerously-skip-permissions' --prefill 'review Bob'\\''s change'" + command: + "claude '--model' 'sonnet' '--effort' 'high' '--dangerously-skip-permissions' --prefill 'review Bob'\\''s change'" }) ) }) @@ -637,7 +642,7 @@ describe('launchAgentInNewTab', () => { expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( 'tab-1', expect.objectContaining({ - command: "claude '--dangerously-skip-permissions'" + command: "claude '--model' 'sonnet' '--effort' 'high' '--dangerously-skip-permissions'" }) ) expect(mockPasteDraftWhenAgentReady).toHaveBeenCalledWith( @@ -843,7 +848,7 @@ describe('launchAgentInNewTab', () => { expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( 'tab-1', expect.objectContaining({ - command: "codex '--model' 'gpt-5.5'" + command: "codex '-m' 'gpt-5.6-sol' '-c' 'model_reasoning_effort=medium' '--model' 'gpt-5.5'" }) ) }) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index 292cc9e46267..44e1f2048118 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -28,6 +28,8 @@ import type { TuiAgent } from '../../../shared/types' import type { LaunchSource } from '../../../shared/telemetry-events' import { translate } from '@/i18n/i18n' import { getConnectionIdFromState } from '@/lib/connection-context' +import { resolveNativeChatSessionOptionDefaults } from '../../../shared/native-chat-session-option-defaults' +import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache' export type LaunchAgentInNewTabArgs = { agent: TuiAgent @@ -129,7 +131,11 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI shell: queuedShell, isRemote, agentArgs: effectiveAgentArgs, - agentEnv + agentEnv, + sessionOptions: resolveNativeChatSessionOptionDefaults( + store.settings?.nativeChatSessionOptions, + agent + ) } const trimmedPrompt = prompt?.trim() ?? '' const hasPrompt = trimmedPrompt.length > 0 @@ -169,6 +175,9 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI expectedProcess: draftLaunchPlan.expectedProcess, followupPrompt: null, launchConfig: draftLaunchPlan.launchConfig, + ...(draftLaunchPlan.sessionOptions + ? { sessionOptions: draftLaunchPlan.sessionOptions } + : {}), ...(draftLaunchPlan.startupCommandDelivery ? { startupCommandDelivery: draftLaunchPlan.startupCommandDelivery } : {}), @@ -244,6 +253,7 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI quickCommandLabel, ...initialViewModeProps }) + seedNativeChatAppliedSessionOptions(tab.id, agent, startupPlan.sessionOptions) store.queueTabStartupCommand(tab.id, { command: startupPlan.launchCommand, ...(startupPlan.env ? { env: startupPlan.env } : {}), diff --git a/src/renderer/src/lib/launch-work-item-direct-agent.ts b/src/renderer/src/lib/launch-work-item-direct-agent.ts index 6e259f9a2e1d..08fb556a66fc 100644 --- a/src/renderer/src/lib/launch-work-item-direct-agent.ts +++ b/src/renderer/src/lib/launch-work-item-direct-agent.ts @@ -16,6 +16,8 @@ import { resolveTuiAgentLaunchEnv } from '../../../shared/tui-agent-launch-defaults' import { translate } from '@/i18n/i18n' +import { resolveNativeChatSessionOptionDefaults } from '../../../shared/native-chat-session-option-defaults' +import type { PersistedNativeChatSessionOptions } from '../../../shared/native-chat-session-options' export function buildDirectWorkItemAgentStartupPlan(args: { agent: TuiAgent | null @@ -27,6 +29,7 @@ export function buildDirectWorkItemAgentStartupPlan(args: { agentCmdOverrides?: Partial> agentDefaultArgs?: Partial> agentDefaultEnv?: Partial>> + nativeChatSessionOptions?: PersistedNativeChatSessionOptions } | null | undefined @@ -48,6 +51,10 @@ export function buildDirectWorkItemAgentStartupPlan(args: { ? resolveTuiAgentLaunchArgs(args.agent, args.settings?.agentDefaultArgs) : args.agentArgs const effectiveAgentEnv = resolveTuiAgentLaunchEnv(args.agent, args.settings?.agentDefaultEnv) + const sessionOptions = resolveNativeChatSessionOptionDefaults( + args.settings?.nativeChatSessionOptions, + args.agent + ) const draftLaunchPlan = args.promptDelivery === 'submit-after-ready' ? null @@ -58,7 +65,8 @@ export function buildDirectWorkItemAgentStartupPlan(args: { platform: args.launchPlatform, isRemote: args.isRemote, agentArgs: effectiveAgentArgs, - agentEnv: effectiveAgentEnv + agentEnv: effectiveAgentEnv, + sessionOptions }) if (draftLaunchPlan) { @@ -69,6 +77,9 @@ export function buildDirectWorkItemAgentStartupPlan(args: { expectedProcess: draftLaunchPlan.expectedProcess, followupPrompt: null, launchConfig: draftLaunchPlan.launchConfig, + ...(draftLaunchPlan.sessionOptions + ? { sessionOptions: draftLaunchPlan.sessionOptions } + : {}), ...(draftLaunchPlan.startupCommandDelivery ? { startupCommandDelivery: draftLaunchPlan.startupCommandDelivery } : {}), @@ -87,6 +98,7 @@ export function buildDirectWorkItemAgentStartupPlan(args: { isRemote: args.isRemote, agentArgs: effectiveAgentArgs, agentEnv: effectiveAgentEnv, + sessionOptions, allowEmptyPromptLaunch: true }) if (startupPlan && args.promptDelivery === 'draft') { @@ -110,6 +122,7 @@ export function buildDirectWorkItemStartupOpts( launchConfig?: SleepingAgentLaunchConfig launchAgent?: TuiAgent draftPrompt?: string + sessionOptions?: AgentStartupPlan['sessionOptions'] startupCommandDelivery?: StartupCommandDelivery telemetry?: AgentStartedTelemetry } @@ -126,6 +139,7 @@ export function buildDirectWorkItemStartupOpts( command: plan.launchCommand, ...(plan.env ? { env: plan.env } : {}), launchConfig: plan.launchConfig, + ...(plan.sessionOptions ? { sessionOptions: plan.sessionOptions } : {}), ...(agent ? { launchAgent: agent } : {}), ...(plan.draftPrompt ? { draftPrompt: plan.draftPrompt } : {}), ...(plan.startupCommandDelivery diff --git a/src/renderer/src/lib/launch-work-item-direct.test.ts b/src/renderer/src/lib/launch-work-item-direct.test.ts index 04da6b1d3411..9eedb05be753 100644 --- a/src/renderer/src/lib/launch-work-item-direct.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct.test.ts @@ -402,6 +402,7 @@ describe('launchWorkItemDirect', () => { cmdOverrides: {}, agentArgs: '--dangerously-skip-permissions', agentEnv: {}, + sessionOptions: { model: 'sonnet', effort: 'high' }, platform: 'win32', isRemote: false }) @@ -525,6 +526,7 @@ describe('launchWorkItemDirect', () => { cmdOverrides: {}, agentArgs: '--yolo', agentEnv: {}, + sessionOptions: { model: 'auto' }, platform: 'linux', isRemote: true }) @@ -534,6 +536,7 @@ describe('launchWorkItemDirect', () => { cmdOverrides: {}, agentArgs: '--yolo', agentEnv: {}, + sessionOptions: { model: 'auto' }, platform: 'linux', isRemote: true, allowEmptyPromptLaunch: true diff --git a/src/renderer/src/lib/onboarding-folder-agent-startup.ts b/src/renderer/src/lib/onboarding-folder-agent-startup.ts index 81bb5431609b..57a1feb39b88 100644 --- a/src/renderer/src/lib/onboarding-folder-agent-startup.ts +++ b/src/renderer/src/lib/onboarding-folder-agent-startup.ts @@ -9,6 +9,8 @@ import type { AgentStartedTelemetry } from '@/lib/worktree-activation' import type { StartupCommandDelivery } from '../../../shared/codex-startup-delivery' import type { SleepingAgentLaunchConfig } from '../../../shared/agent-session-resume' import type { GlobalSettings, OnboardingState, TuiAgent } from '../../../shared/types' +import { resolveNativeChatSessionOptionDefaults } from '../../../shared/native-chat-session-option-defaults' +import type { SessionOptionValue } from '../../../shared/native-chat-session-options' export type OnboardingFolderAgentStartup = { command: string @@ -16,6 +18,7 @@ export type OnboardingFolderAgentStartup = { launchConfig?: SleepingAgentLaunchConfig launchAgent?: TuiAgent startupCommandDelivery?: StartupCommandDelivery + sessionOptions?: Record telemetry: AgentStartedTelemetry } @@ -45,6 +48,10 @@ export function buildOnboardingFolderAgentStartup( cmdOverrides: settings.agentCmdOverrides ?? {}, agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv), + sessionOptions: resolveNativeChatSessionOptionDefaults( + settings.nativeChatSessionOptions, + agent + ), platform: getClientPlatform(), allowEmptyPromptLaunch: true }) @@ -57,6 +64,7 @@ export function buildOnboardingFolderAgentStartup( ...(startupPlan.env ? { env: startupPlan.env } : {}), launchConfig: startupPlan.launchConfig, launchAgent: agent, + ...(startupPlan.sessionOptions ? { sessionOptions: startupPlan.sessionOptions } : {}), ...(startupPlan.startupCommandDelivery ? { startupCommandDelivery: startupPlan.startupCommandDelivery } : {}), diff --git a/src/renderer/src/lib/source-control-agent-action-plan.ts b/src/renderer/src/lib/source-control-agent-action-plan.ts index c865f576ed98..05bc1de3c28e 100644 --- a/src/renderer/src/lib/source-control-agent-action-plan.ts +++ b/src/renderer/src/lib/source-control-agent-action-plan.ts @@ -10,6 +10,7 @@ import { isTuiAgentEnabled } from '../../../shared/tui-agent-selection' import type { TuiAgent } from '../../../shared/types' import { translate } from '@/i18n/i18n' import { resolveLocalWindowsAgentStartupShell } from '../../../shared/windows-terminal-shell' +import type { SessionOptionValue } from '../../../shared/native-chat-session-options' export type SourceControlLaunchPlanDelivery = | 'argv' @@ -36,6 +37,7 @@ export function planSourceControlAgentActionLaunch(args: { disabledAgents?: TuiAgent[] cmdOverrides?: Partial> agentArgs?: string | null + sessionOptions?: Record platform?: NodeJS.Platform terminalWindowsShell?: string | null /** Why: SSH remotes deploy the CLI shim as plain `orca`, so the Linux-only @@ -107,6 +109,7 @@ export function planSourceControlAgentActionLaunch(args: { shell, isRemote, agentArgs: args.agentArgs, + sessionOptions: args.sessionOptions, allowEmptyPromptLaunch: true }) delivery = 'paste-submit' @@ -118,7 +121,8 @@ export function planSourceControlAgentActionLaunch(args: { platform, shell, isRemote, - agentArgs: args.agentArgs + agentArgs: args.agentArgs, + sessionOptions: args.sessionOptions }) if (draftLaunchPlan) { startupPlan = { @@ -127,6 +131,9 @@ export function planSourceControlAgentActionLaunch(args: { expectedProcess: draftLaunchPlan.expectedProcess, followupPrompt: null, launchConfig: draftLaunchPlan.launchConfig, + ...(draftLaunchPlan.sessionOptions + ? { sessionOptions: draftLaunchPlan.sessionOptions } + : {}), ...(draftLaunchPlan.startupCommandDelivery ? { startupCommandDelivery: draftLaunchPlan.startupCommandDelivery } : {}), @@ -142,6 +149,7 @@ export function planSourceControlAgentActionLaunch(args: { shell, isRemote, agentArgs: args.agentArgs, + sessionOptions: args.sessionOptions, allowEmptyPromptLaunch: true }) delivery = 'draft-paste' @@ -155,6 +163,7 @@ export function planSourceControlAgentActionLaunch(args: { shell, isRemote, agentArgs: args.agentArgs, + sessionOptions: args.sessionOptions, allowEmptyPromptLaunch: true }) delivery = 'draft-paste' @@ -167,6 +176,7 @@ export function planSourceControlAgentActionLaunch(args: { shell, isRemote, agentArgs: args.agentArgs, + sessionOptions: args.sessionOptions, allowEmptyPromptLaunch: false }) delivery = 'argv' diff --git a/src/renderer/src/lib/worktree-activation-created-agent.test.ts b/src/renderer/src/lib/worktree-activation-created-agent.test.ts index 174c1fab32da..89fcb83c92d6 100644 --- a/src/renderer/src/lib/worktree-activation-created-agent.test.ts +++ b/src/renderer/src/lib/worktree-activation-created-agent.test.ts @@ -96,7 +96,8 @@ describe('activateAndRevealWorktree created agent reopen', () => { expect(result).toEqual({ primaryTabId: reopenedTab?.id }) expect(reopenedTab).toBeDefined() expect(state.pendingStartupByTabId[reopenedTab!.id]).toEqual({ - command: "codex '--dangerously-bypass-approvals-and-sandbox'", + command: + "codex '-m' 'gpt-5.6-sol' '-c' 'model_reasoning_effort=medium' '--dangerously-bypass-approvals-and-sandbox'", env: {}, launchAgent: 'codex', launchConfig: { @@ -105,6 +106,7 @@ describe('activateAndRevealWorktree created agent reopen', () => { agentEnv: {} }, launchToken: expect.any(String), + sessionOptions: { model: 'gpt-5.6-sol', effort: 'medium' }, telemetry: { agent_kind: 'codex', launch_source: 'sidebar', diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index 52004e147dde..5c5ad3bde39b 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -65,6 +65,9 @@ import { toast } from 'sonner' import { initialAgentTabViewModeProps } from './native-chat-initial-view-mode' import { getConnectionId } from '@/lib/connection-context' import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' +import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache' +import { resolveNativeChatSessionOptionDefaults } from '../../../shared/native-chat-session-option-defaults' +import type { SessionOptionValue } from '../../../shared/native-chat-session-options' /** Telemetry payload threaded from the launch site to `pty:spawn`. Main * fires `agent_started` only after the spawn succeeds — see @@ -82,6 +85,7 @@ export type WorktreeStartupPayload = { draftPrompt?: string startupCommandDelivery?: StartupCommandDelivery initialAgentStatus?: { agent: TuiAgent; prompt: string } + sessionOptions?: Record telemetry?: AgentStartedTelemetry } @@ -259,6 +263,10 @@ function buildCreatedAgentReopenStartup(worktree: Worktree): WorktreeStartupPayl cmdOverrides: state.settings?.agentCmdOverrides ?? {}, agentArgs: resolveTuiAgentLaunchArgs(agent, state.settings?.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(agent, state.settings?.agentDefaultEnv), + sessionOptions: resolveNativeChatSessionOptionDefaults( + state.settings?.nativeChatSessionOptions, + agent + ), platform: launchPlatform, isRemote: repo ? repoIsRemote(repo) : false, allowEmptyPromptLaunch: true @@ -272,6 +280,7 @@ function buildCreatedAgentReopenStartup(worktree: Worktree): WorktreeStartupPayl ...(startupPlan.env ? { env: startupPlan.env } : {}), launchConfig: startupPlan.launchConfig, launchAgent: agent, + ...(startupPlan.sessionOptions ? { sessionOptions: startupPlan.sessionOptions } : {}), ...(startupPlan.startupCommandDelivery ? { startupCommandDelivery: startupPlan.startupCommandDelivery } : {}), @@ -602,6 +611,13 @@ export function ensureWorktreeHasInitialTerminal( // pane so the main terminal begins in the requested agent session instead of // opening to an idle shell and forcing the user to repeat the same prompt. if (sequencedStartup) { + if (launchAgent) { + seedNativeChatAppliedSessionOptions( + terminalTab.id, + launchAgent, + sequencedStartup.sessionOptions + ) + } store.queueTabStartupCommand(terminalTab.id, sequencedStartup) } queueSetupAndIssueCommands( @@ -683,6 +699,14 @@ function applyDefaultTerminalTabs( store.setActiveTab(firstTabId) } if (startup) { + const startupAgent = + startup.launchAgent ?? + (startup.telemetry + ? (agentKindToTuiAgent(startup.telemetry.agent_kind) ?? undefined) + : undefined) + if (startupAgent) { + seedNativeChatAppliedSessionOptions(firstTabId, startupAgent, startup.sessionOptions) + } store.queueTabStartupCommand(firstTabId, startup) } queueSetupAndIssueCommands( diff --git a/src/renderer/src/lib/worktree-creation-flow.ts b/src/renderer/src/lib/worktree-creation-flow.ts index 6bdac108c235..a975405de264 100644 --- a/src/renderer/src/lib/worktree-creation-flow.ts +++ b/src/renderer/src/lib/worktree-creation-flow.ts @@ -25,6 +25,7 @@ import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation' import { createBrowserUuid } from '@/lib/browser-uuid' +import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache' type ContinueBackgroundWorktreeCreationOptions = { revealCreationSurface?: boolean @@ -254,6 +255,16 @@ async function executeWorktreeCreation( // Why: clearing synchronously right after activation lets React commit the // panel→terminal swap in one frame — no two-row flicker, no empty-terminal flash. useAppStore.getState().removePendingWorktreeCreation(creationId, { cleanupVm: false }) + if (preparedRequest.startupPlan && preparedRequest.agent) { + const optionScopeKey = primaryTabId ?? result.startupTerminal?.tabId + if (optionScopeKey) { + seedNativeChatAppliedSessionOptions( + optionScopeKey, + preparedRequest.agent, + preparedRequest.startupPlan.sessionOptions + ) + } + } if (preparedRequest.startupPlan && !backendSpawned) { void ensureAgentStartupInTerminal({ worktreeId: worktree.id, diff --git a/src/renderer/src/runtime/runtime-terminal-stream.test.ts b/src/renderer/src/runtime/runtime-terminal-stream.test.ts index 776c080e9bca..385c7d9feae5 100644 --- a/src/renderer/src/runtime/runtime-terminal-stream.test.ts +++ b/src/renderer/src/runtime/runtime-terminal-stream.test.ts @@ -132,6 +132,60 @@ describe('remote runtime terminal data subscriptions', () => { expect(_getRemoteRuntimeTerminalMultiplexerCountForTest()).toBe(0) }) + it('can start at the live tail without replaying the initial snapshot', async () => { + const watcher = vi.fn() + const subscription = subscribeToRuntimeTerminalData( + { activeRuntimeEnvironmentId: 'env-fallback' }, + 'remote:env-1@@terminal-1', + 'watcher-1', + watcher, + { startAtLiveTail: true } + ) + + await vi.waitFor(() => expect(sendBinary).toHaveBeenCalled()) + const subscribeFrame = decodeTerminalStreamFrame(sendBinary.mock.calls[0][0]) + const subscribePayload = + subscribeFrame && decodeTerminalStreamJson<{ streamId: number }>(subscribeFrame.payload) + const streamId = subscribePayload!.streamId + callbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotStart, + streamId, + seq: 0, + payload: encodeTerminalStreamJson({ seq: 0 }) + }) + ) + callbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotChunk, + streamId, + seq: 0, + payload: encodeTerminalStreamText('historical') + }) + ) + callbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotEnd, + streamId, + seq: 0, + payload: new Uint8Array() + }) + ) + const dispose = await subscription + + expect(watcher).not.toHaveBeenCalled() + callbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Output, + streamId, + seq: 4, + payload: encodeTerminalStreamText('live') + }) + ) + expect(watcher).toHaveBeenCalledWith('live') + dispose() + }) + it('keeps the shared terminal multiplexer until the last watcher closes', async () => { const firstDispose = await subscribeToRuntimeTerminalData( { activeRuntimeEnvironmentId: 'env-fallback' }, diff --git a/src/renderer/src/runtime/runtime-terminal-stream.ts b/src/renderer/src/runtime/runtime-terminal-stream.ts index 7dbdf3eb0021..314aa01b9d3e 100644 --- a/src/renderer/src/runtime/runtime-terminal-stream.ts +++ b/src/renderer/src/runtime/runtime-terminal-stream.ts @@ -4,6 +4,7 @@ import { getRemoteRuntimeTerminalMultiplexer } from './remote-runtime-terminal-m const REMOTE_PTY_ID_PREFIX = 'remote:' const REMOTE_PTY_OWNER_SEPARATOR = '@@' +const LIVE_TAIL_SUBSCRIPTION_TIMEOUT_MS = 10_000 export type RemoteRuntimePtyIdParts = { environmentId: string | null @@ -56,7 +57,8 @@ export async function subscribeToRuntimeTerminalData( settings: Pick | null | undefined, ptyId: string, clientId: string, - watcher: (data: string) => void + watcher: (data: string) => void, + options?: { startAtLiveTail?: boolean } ): Promise<() => void> { const terminal = getRemoteRuntimeTerminalHandle(ptyId) const ownerEnvironmentId = getRemoteRuntimePtyEnvironmentId(ptyId) @@ -67,14 +69,61 @@ export async function subscribeToRuntimeTerminalData( return () => {} } + let resolveLiveTail: (() => void) | null = null + let rejectLiveTail: ((error: Error) => void) | null = null + const liveTailReady = options?.startAtLiveTail + ? new Promise((resolve, reject) => { + resolveLiveTail = resolve + rejectLiveTail = reject + }) + : null + const rejectPendingLiveTail = (message: string): void => { + rejectLiveTail?.(new Error(message)) + resolveLiveTail = null + rejectLiveTail = null + } + const stream = await getRemoteRuntimeTerminalMultiplexer(target.environmentId).subscribeTerminal({ terminal, client: { id: clientId, type: 'desktop' }, callbacks: { onData: (data) => watcher(data), - onSnapshot: watcher + onSnapshot: (data) => { + if (!options?.startAtLiveTail) { + watcher(data) + } + }, + onSubscribed: () => { + resolveLiveTail?.() + resolveLiveTail = null + rejectLiveTail = null + }, + onEnd: () => rejectPendingLiveTail('Remote terminal ended before live output was ready.'), + onError: (message) => rejectPendingLiveTail(message), + onTransportClose: () => + rejectPendingLiveTail('Remote terminal closed before live output was ready.') } }) + if (liveTailReady) { + let timeout: ReturnType | null = setTimeout( + () => rejectPendingLiveTail('Timed out waiting for remote terminal live output.'), + LIVE_TAIL_SUBSCRIPTION_TIMEOUT_MS + ) + try { + // Why: outcome observers must ignore historical snapshots and be armed + // before the command whose output they classify, including over SSH. + await liveTailReady + } catch (error) { + stream.close() + throw error + } finally { + if (timeout !== null) { + clearTimeout(timeout) + timeout = null + } + } + } + return () => stream.close() } diff --git a/src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts b/src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts index a18a2733dcc4..b5c44f9ca40b 100644 --- a/src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts +++ b/src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts @@ -58,7 +58,8 @@ describe('repo slice skipped-onboarding folder startup', () => { { sidebarRevealBehavior: 'auto', startup: { - command: "codex '--dangerously-bypass-approvals-and-sandbox'", + command: + "codex '-m' 'gpt-5.6-sol' '-c' 'model_reasoning_effort=medium' '--dangerously-bypass-approvals-and-sandbox'", env: {}, launchAgent: 'codex', launchConfig: { @@ -66,6 +67,7 @@ describe('repo slice skipped-onboarding folder startup', () => { agentArgs: '--dangerously-bypass-approvals-and-sandbox', agentEnv: {} }, + sessionOptions: { model: 'gpt-5.6-sol', effort: 'medium' }, telemetry: { agent_kind: 'codex', launch_source: 'onboarding', diff --git a/src/shared/agent-session-option-catalog-claude-codex.ts b/src/shared/agent-session-option-catalog-claude-codex.ts new file mode 100644 index 000000000000..47fa8226af7a --- /dev/null +++ b/src/shared/agent-session-option-catalog-claude-codex.ts @@ -0,0 +1,153 @@ +import type { AgentSessionOptionCatalog, CatalogOption } from './agent-session-option-catalog-types' + +function hasFlag(tokens: readonly string[], flags: readonly string[]): boolean { + return tokens.some((token) => + flags.some( + (flag) => + token === flag || + token.startsWith(`${flag}=`) || + (flag.startsWith('-') && !flag.startsWith('--') && token.startsWith(flag)) + ) + ) +} + +function hasCodexEffortOverride(tokens: readonly string[]): boolean { + if (hasFlag(tokens, ['--reasoning-effort'])) { + return true + } + return tokens.some((token, index) => { + const previous = tokens[index - 1] + return ( + (token.startsWith('model_reasoning_effort=') && + (previous === '-c' || previous === '--config')) || + token.startsWith('-cmodel_reasoning_effort=') || + token.startsWith('-c=model_reasoning_effort=') || + token.startsWith('--config=model_reasoning_effort=') + ) + }) +} + +const STANDARD_EFFORT_CHOICES = [ + { value: 'low', label: 'Low' }, + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' } +] + +const EXTENDED_EFFORT_CHOICES = [ + ...STANDARD_EFFORT_CHOICES, + { value: 'xhigh', label: 'Extra high' }, + { value: 'max', label: 'Max' } +] + +function claudeEffort(extended: boolean): CatalogOption { + return { + id: 'effort', + label: 'Effort', + category: 'thought_level', + kind: { + type: 'select', + choices: extended ? EXTENDED_EFFORT_CHOICES : STANDARD_EFFORT_CHOICES, + defaultValue: 'high' + }, + apply: { + launchArgs: (value) => ['--effort', String(value)], + agentArgsOverride: (tokens) => hasFlag(tokens, ['--effort']), + midSession: { kind: 'command', build: (value) => `/effort ${String(value)}` } + } + } +} + +const CLAUDE_FAST_MODE: CatalogOption = { + id: 'fastMode', + label: 'Fast mode', + category: 'mode', + kind: { type: 'boolean', defaultValue: false }, + apply: { midSession: { kind: 'toggle-command', command: '/fast' } } +} + +export const CLAUDE_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { + models: [ + { + id: 'fable', + label: 'Fable 5', + options: [claudeEffort(true)] + }, + { + id: 'opus', + label: 'Opus 4.8', + options: [claudeEffort(true), CLAUDE_FAST_MODE] + }, + { + id: 'sonnet', + label: 'Sonnet 5', + isDefault: true, + options: [claudeEffort(true)] + }, + { + id: 'haiku', + label: 'Haiku', + options: [] + } + ], + modelApply: { + launchArgs: (value) => ['--model', String(value)], + agentArgsOverride: (tokens) => hasFlag(tokens, ['--model']), + midSession: { + kind: 'command', + build: (value) => `/model ${String(value)}`, + pickerCommand: '/model', + // Why: Claude sometimes confirms a cached-history switch. Detect the + // actual prompt so ordinary model changes stay in native chat. + detectAgentInteraction: 'claude-model-switch-confirmation' + } + } +} + +const CODEX_EFFORT_CHOICES = [ + { value: 'minimal', label: 'Minimal' }, + { value: 'low', label: 'Low' }, + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' }, + { value: 'xhigh', label: 'Extra high' } +] + +function codexEffort(includeExtraHigh: boolean): CatalogOption { + return { + id: 'effort', + label: 'Reasoning effort', + category: 'thought_level', + kind: { + type: 'select', + choices: includeExtraHigh + ? CODEX_EFFORT_CHOICES + : CODEX_EFFORT_CHOICES.filter((choice) => choice.value !== 'xhigh'), + defaultValue: 'medium' + }, + apply: { + launchArgs: (value) => ['-c', `model_reasoning_effort=${String(value)}`], + agentArgsOverride: hasCodexEffortOverride, + midSession: { kind: 'agent-picker', command: '/model' } + } + } +} + +export const CODEX_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { + // Why: Codex model access depends on auth. Keep this seed short and allow + // unknown persisted ids to pass through instead of claiming a complete list. + models: [ + { id: 'gpt-5.6-sol', label: 'GPT-5.6 Sol', options: [codexEffort(true)] }, + { id: 'gpt-5.6-terra', label: 'GPT-5.6 Terra', options: [codexEffort(true)] }, + { id: 'gpt-5.6-luna', label: 'GPT-5.6 Luna', options: [codexEffort(false)] }, + { id: 'gpt-5.5', label: 'GPT-5.5', options: [codexEffort(true)] }, + { + id: 'gpt-5.2-codex', + label: 'GPT-5.2 Codex', + options: [codexEffort(true)] + } + ], + modelApply: { + launchArgs: (value) => ['-m', String(value)], + agentArgsOverride: (tokens) => hasFlag(tokens, ['-m', '--model']), + midSession: { kind: 'agent-picker', command: '/model' } + } +} diff --git a/src/shared/agent-session-option-catalog-gemini-cursor.ts b/src/shared/agent-session-option-catalog-gemini-cursor.ts new file mode 100644 index 000000000000..b655c8e0637b --- /dev/null +++ b/src/shared/agent-session-option-catalog-gemini-cursor.ts @@ -0,0 +1,112 @@ +import type { + AgentSessionOptionCatalog, + CatalogModel, + CatalogOption +} from './agent-session-option-catalog-types' + +function hasModelFlag(tokens: readonly string[]): boolean { + return tokens.some( + (token) => + token === '-m' || + token === '--model' || + token.startsWith('-m=') || + (token.startsWith('-m') && !token.startsWith('--')) || + token.startsWith('--model=') + ) +} + +export const GEMINI_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { + models: [ + { id: 'gemini-3-pro-preview', label: 'Gemini 3 Pro Preview', options: [] }, + { id: 'gemini-3-flash-preview', label: 'Gemini 3 Flash Preview', options: [] }, + { id: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro', options: [] }, + { id: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash', options: [] } + ], + modelApply: { + launchArgs: (value) => ['-m', String(value)], + agentArgsOverride: hasModelFlag, + midSession: { kind: 'agent-picker', command: '/model' } + } +} + +const CURSOR_EFFORT: CatalogOption = { + id: 'effort', + label: 'Effort', + category: 'thought_level', + kind: { + type: 'select', + choices: [ + { value: 'low', label: 'Low' }, + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' } + ], + defaultValue: 'high' + }, + apply: { composedIntoModel: true } +} + +const CURSOR_FAST: CatalogOption = { + id: 'fastMode', + label: 'Fast mode', + category: 'mode', + kind: { type: 'boolean', defaultValue: false }, + apply: { composedIntoModel: true } +} + +const CURSOR_THINKING: CatalogOption = { + id: 'thinking', + label: 'Thinking', + category: 'model_config', + kind: { type: 'boolean', defaultValue: true }, + apply: { composedIntoModel: true } +} + +function parseCursorModels(stdout: string): CatalogModel[] { + const seen = new Set() + const models: CatalogModel[] = [] + for (const line of stdout.split(/\r?\n/)) { + const match = line.trim().match(/^(?:[-*]\s+)?([a-z0-9][a-z0-9._-]*)(?:\s+\(.*\))?$/i) + const id = match?.[1] + if (!id || id.toLowerCase() === 'models' || seen.has(id)) { + continue + } + seen.add(id) + models.push({ id, label: id === 'auto' ? 'Auto' : id, options: [] }) + } + return models +} + +export const CURSOR_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { + models: [ + { id: 'auto', label: 'Auto', isDefault: true, options: [] }, + { + id: 'gpt-5.3-codex', + label: 'GPT-5.3 Codex', + options: [CURSOR_EFFORT, CURSOR_FAST] + }, + { + id: 'claude-opus-4-8', + label: 'Claude Opus 4.8', + options: [CURSOR_THINKING, CURSOR_EFFORT] + } + ], + modelApply: { + launchArgs: (value) => ['--model', String(value)], + agentArgsOverride: hasModelFlag, + midSession: { kind: 'command', build: (value) => `/model ${String(value)}` } + }, + composeModelValue: (modelId, values) => { + if (modelId === 'auto') { + return modelId + } + if (modelId.startsWith('claude-')) { + const thinking = values.thinking === true ? '-thinking' : '' + const effort = typeof values.effort === 'string' ? `-${values.effort}` : '' + return `${modelId}${thinking}${effort}` + } + const effort = typeof values.effort === 'string' ? `-${values.effort}` : '' + const fast = values.fastMode === true ? '-fast' : '' + return `${modelId}${effort}${fast}` + }, + listModels: { command: 'cursor-agent models', parse: parseCursorModels } +} diff --git a/src/shared/agent-session-option-catalog-types.ts b/src/shared/agent-session-option-catalog-types.ts new file mode 100644 index 000000000000..a416b10913e2 --- /dev/null +++ b/src/shared/agent-session-option-catalog-types.ts @@ -0,0 +1,63 @@ +import type { AgentType } from './agent-status-types' +import type { + SessionOptionDescriptor, + SessionOptionSelectChoice, + SessionOptionValue +} from './native-chat-session-options' + +export type CatalogAgentInteractionDetection = 'claude-model-switch-confirmation' + +export type CatalogMidSessionApply = + | { + kind: 'command' + build: (value: SessionOptionValue) => string + pickerCommand?: string + detectAgentInteraction?: CatalogAgentInteractionDetection + } + | { kind: 'toggle-command'; command: string } + | { kind: 'agent-picker'; command: string } + | { kind: 'unsupported' } + +export type CatalogOptionApply = { + launchArgs?: (value: SessionOptionValue) => string[] + /** Why: later free-form args win, so the launch record must discard any + * picker value that those args may have replaced. */ + agentArgsOverride?: (tokens: readonly string[]) => boolean + composedIntoModel?: true + midSession?: CatalogMidSessionApply +} + +export type CatalogOption = { + id: string + label: string + description?: string + category?: SessionOptionDescriptor['category'] + kind: + | { + type: 'select' + choices: SessionOptionSelectChoice[] + defaultValue: string + } + | { type: 'boolean'; defaultValue: boolean } + apply: CatalogOptionApply +} + +export type CatalogModel = { + id: string + label: string + description?: string + isDefault?: boolean + options: CatalogOption[] +} + +export type AgentSessionOptionCatalog = { + models: CatalogModel[] + modelApply: CatalogOptionApply + composeModelValue?: (modelId: string, values: Record) => string + listModels?: { + command: string + parse: (stdout: string) => CatalogModel[] + } +} + +export type AgentSessionOptionCatalogMap = Partial> diff --git a/src/shared/agent-session-option-catalog.test.ts b/src/shared/agent-session-option-catalog.test.ts new file mode 100644 index 000000000000..c4e7eca14042 --- /dev/null +++ b/src/shared/agent-session-option-catalog.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest' +import { getAgentSessionOptionCatalog, mergeCatalogModels } from './agent-session-option-catalog' +import { resolveAgentSessionOptionLaunch } from './agent-session-option-launch' +import { + resolveNativeChatSessionOptionDefaults, + updateNativeChatSessionOptionDefaults +} from './native-chat-session-option-defaults' + +describe('agent session option catalog', () => { + it('returns no catalog for unknown agents', () => { + expect(getAgentSessionOptionCatalog('future-agent')).toBeNull() + }) + + it('keeps Claude option sets model-scoped', () => { + const catalog = getAgentSessionOptionCatalog('claude') + expect( + catalog?.models.find((model) => model.id === 'opus')?.options.map(({ id }) => id) + ).toEqual(['effort', 'fastMode']) + expect(catalog?.models.find((model) => model.id === 'haiku')?.options).toEqual([]) + }) + + it('merges discovered labels while preserving cataloged option shapes', () => { + const seed = getAgentSessionOptionCatalog('cursor')!.models + const merged = mergeCatalogModels(seed, [ + { id: 'gpt-5.3-codex', label: 'GPT 5.3 (live)', options: [] }, + { id: 'new-account-model', label: 'new-account-model', options: [] } + ]) + expect(merged.find((model) => model.id === 'gpt-5.3-codex')).toMatchObject({ + label: 'GPT 5.3 (live)', + options: expect.arrayContaining([expect.objectContaining({ id: 'effort' })]) + }) + expect(merged.at(-1)).toEqual({ + id: 'new-account-model', + label: 'new-account-model', + options: [] + }) + }) + + it('parses Cursor model discovery without treating headings as models', () => { + const parsed = getAgentSessionOptionCatalog('cursor')!.listModels!.parse( + 'Available models:\n- auto (default)\n- gpt-5.3-codex\nmodels\n' + ) + expect(parsed.map(({ id }) => id)).toEqual(['auto', 'gpt-5.3-codex']) + }) + + it('composes Cursor effort and fast mode into the supported slug form', () => { + const resolved = resolveAgentSessionOptionLaunch('cursor', { + model: 'gpt-5.3-codex', + effort: 'high', + fastMode: true + }) + expect(resolved.args).toEqual(['--model', 'gpt-5.3-codex-high-fast']) + expect(resolved.appliedValues).toEqual({ + model: 'gpt-5.3-codex', + effort: 'high', + fastMode: true + }) + }) + + it('passes unknown model and option values through launch mappings', () => { + expect( + resolveAgentSessionOptionLaunch('claude', { + model: 'claude-future', + effort: 'future-effort' + }) + ).toEqual({ args: ['--model', 'claude-future'], appliedValues: { model: 'claude-future' } }) + expect( + resolveAgentSessionOptionLaunch('claude', { model: 'opus', effort: 'future-effort' }) + ).toMatchObject({ + args: ['--model', 'opus', '--effort', 'future-effort'], + appliedValues: { model: 'opus', effort: 'future-effort' } + }) + }) + + it('restores defaults per model without leaking values across models', () => { + let persisted = updateNativeChatSessionOptionDefaults({ + persisted: undefined, + agent: 'claude', + modelId: 'opus', + optionId: 'model', + value: 'opus' + }) + persisted = updateNativeChatSessionOptionDefaults({ + persisted, + agent: 'claude', + modelId: 'opus', + optionId: 'effort', + value: 'xhigh' + }) + persisted = updateNativeChatSessionOptionDefaults({ + persisted, + agent: 'claude', + modelId: 'sonnet', + optionId: 'model', + value: 'sonnet' + }) + + expect(resolveNativeChatSessionOptionDefaults(persisted, 'claude')).toEqual({ + model: 'sonnet', + effort: 'high' + }) + expect(persisted.claude?.valuesByModel?.opus).toEqual({ effort: 'xhigh' }) + }) + + it('seeds a fresh launch from the catalog defaults', () => { + expect(resolveNativeChatSessionOptionDefaults(undefined, 'claude')).toEqual({ + model: 'sonnet', + effort: 'high' + }) + expect(resolveNativeChatSessionOptionDefaults({}, 'future-agent')).toBeUndefined() + }) +}) diff --git a/src/shared/agent-session-option-catalog.ts b/src/shared/agent-session-option-catalog.ts new file mode 100644 index 000000000000..b5520afaa511 --- /dev/null +++ b/src/shared/agent-session-option-catalog.ts @@ -0,0 +1,80 @@ +import type { AgentType } from './agent-status-types' +import { + CLAUDE_SESSION_OPTION_CATALOG, + CODEX_SESSION_OPTION_CATALOG +} from './agent-session-option-catalog-claude-codex' +import { + CURSOR_SESSION_OPTION_CATALOG, + GEMINI_SESSION_OPTION_CATALOG +} from './agent-session-option-catalog-gemini-cursor' +import type { + AgentSessionOptionCatalog, + AgentSessionOptionCatalogMap, + CatalogModel, + CatalogOption +} from './agent-session-option-catalog-types' +import type { SessionOptionValue } from './native-chat-session-options' + +export type { + AgentSessionOptionCatalog, + CatalogAgentInteractionDetection, + CatalogMidSessionApply, + CatalogModel, + CatalogOption, + CatalogOptionApply +} from './agent-session-option-catalog-types' + +const CATALOGS: AgentSessionOptionCatalogMap = { + claude: CLAUDE_SESSION_OPTION_CATALOG, + codex: CODEX_SESSION_OPTION_CATALOG, + gemini: GEMINI_SESSION_OPTION_CATALOG, + cursor: CURSOR_SESSION_OPTION_CATALOG +} + +export function getAgentSessionOptionCatalog(agent: AgentType): AgentSessionOptionCatalog | null { + return CATALOGS[agent] ?? null +} + +export function findCatalogModel( + catalog: AgentSessionOptionCatalog, + modelId: string +): CatalogModel | undefined { + return catalog.models.find((model) => model.id === modelId) +} + +export function findCatalogOption( + model: CatalogModel | undefined, + optionId: string +): CatalogOption | undefined { + return model?.options.find((option) => option.id === optionId) +} + +export function catalogDefaultModel(catalog: AgentSessionOptionCatalog): CatalogModel | undefined { + return catalog.models.find((model) => model.isDefault) ?? catalog.models[0] +} + +export function catalogDefaultValues(model: CatalogModel): Record { + return Object.fromEntries(model.options.map((option) => [option.id, option.kind.defaultValue])) +} + +/** Merge live rows over the static seed while retaining only option shapes Orca + * can actually map. Newly discovered ids remain model-only until cataloged. */ +export function mergeCatalogModels( + seed: readonly CatalogModel[], + discovered: readonly CatalogModel[] +): CatalogModel[] { + const discoveredById = new Map(discovered.map((model) => [model.id, model])) + const merged = seed.map((model) => { + const live = discoveredById.get(model.id) + if (!live) { + return model + } + discoveredById.delete(model.id) + return { ...model, ...live, options: model.options } + }) + return [...merged, ...discoveredById.values()] +} + +export function sessionOptionValueIsValid(value: unknown): value is SessionOptionValue { + return typeof value === 'string' || typeof value === 'boolean' +} diff --git a/src/shared/agent-session-option-launch.ts b/src/shared/agent-session-option-launch.ts new file mode 100644 index 000000000000..9181039f0a7e --- /dev/null +++ b/src/shared/agent-session-option-launch.ts @@ -0,0 +1,64 @@ +import type { AgentType } from './agent-status-types' +import { findCatalogModel, getAgentSessionOptionCatalog } from './agent-session-option-catalog' +import type { SessionOptionValue } from './native-chat-session-options' + +export type ResolvedSessionOptionLaunch = { + args: string[] + appliedValues: Record +} + +export function resolveAgentSessionOptionLaunch( + agent: AgentType, + values: Record | null | undefined, + trailingAgentArgs: readonly string[] = [] +): ResolvedSessionOptionLaunch { + const catalog = getAgentSessionOptionCatalog(agent) + const modelId = typeof values?.model === 'string' ? values.model : null + if (!catalog || !values || !modelId) { + return { args: [], appliedValues: {} } + } + + const model = findCatalogModel(catalog, modelId) + const appliedValues: Record = {} + const args: string[] = [] + const modelValues = model + ? Object.fromEntries( + model.options.map((option) => [option.id, values[option.id] ?? option.kind.defaultValue]) + ) + : {} + const composedModelId = catalog.composeModelValue + ? catalog.composeModelValue(modelId, modelValues) + : modelId + const modelOverridden = catalog.modelApply.agentArgsOverride?.(trailingAgentArgs) === true + + if (catalog.modelApply.launchArgs) { + args.push(...catalog.modelApply.launchArgs(composedModelId)) + if (!modelOverridden) { + appliedValues.model = modelId + } + } + if (!model) { + return { args, appliedValues } + } + + for (const option of model.options) { + const value = modelValues[option.id] + if (value === undefined) { + continue + } + if (option.apply.composedIntoModel) { + if (catalog.modelApply.launchArgs && !modelOverridden) { + appliedValues[option.id] = value + } + continue + } + if (!option.apply.launchArgs) { + continue + } + args.push(...option.apply.launchArgs(value)) + if (!modelOverridden && !option.apply.agentArgsOverride?.(trailingAgentArgs)) { + appliedValues[option.id] = value + } + } + return { args, appliedValues } +} diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 0573c89ec932..d5ee81998b80 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -286,6 +286,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings { openLinksInAppPreferencePrompted: false, openAgentTabsInChatByDefault: false, experimentalNativeChat: false, + nativeChatSessionOptions: {}, openInApplications: [...DEFAULT_OPEN_IN_APPLICATIONS], rightSidebarOpenByDefault: true, showGitIgnoredFiles: true, diff --git a/src/shared/native-chat-session-option-defaults.ts b/src/shared/native-chat-session-option-defaults.ts new file mode 100644 index 000000000000..98bd01871135 --- /dev/null +++ b/src/shared/native-chat-session-option-defaults.ts @@ -0,0 +1,72 @@ +import type { AgentType } from './agent-status-types' +import { + catalogDefaultModel, + findCatalogModel, + getAgentSessionOptionCatalog, + sessionOptionValueIsValid +} from './agent-session-option-catalog' +import type { + PersistedNativeChatSessionOptions, + SessionOptionValue +} from './native-chat-session-options' + +export function resolveNativeChatSessionOptionDefaults( + persisted: PersistedNativeChatSessionOptions | null | undefined, + agent: AgentType +): Record | undefined { + const catalog = getAgentSessionOptionCatalog(agent) + const entry = persisted?.[agent] + // Why: fresh launches must be authoritative too, so resolve the catalog's + // declared fallback before the launch command is assembled. + const modelId = + typeof entry?.model === 'string' && entry.model.trim() + ? entry.model + : catalog + ? catalogDefaultModel(catalog)?.id + : undefined + if (!modelId) { + return undefined + } + const values: Record = { model: modelId } + const storedValues = entry?.valuesByModel?.[modelId] + if (storedValues && typeof storedValues === 'object') { + for (const [id, value] of Object.entries(storedValues)) { + if (sessionOptionValueIsValid(value)) { + values[id] = value + } + } + } + + const model = catalog ? findCatalogModel(catalog, modelId) : undefined + for (const option of model?.options ?? []) { + values[option.id] ??= option.kind.defaultValue + } + return values +} + +export function updateNativeChatSessionOptionDefaults(args: { + persisted: PersistedNativeChatSessionOptions | null | undefined + agent: AgentType + modelId: string + optionId: string + value: SessionOptionValue +}): PersistedNativeChatSessionOptions { + const currentAgent = args.persisted?.[args.agent] + const currentModelValues = currentAgent?.valuesByModel?.[args.modelId] ?? {} + const valuesByModel = { + ...currentAgent?.valuesByModel, + ...(args.optionId === 'model' + ? {} + : { + [args.modelId]: { ...currentModelValues, [args.optionId]: args.value } + }) + } + return { + ...args.persisted, + [args.agent]: { + ...currentAgent, + model: args.optionId === 'model' ? String(args.value) : args.modelId, + valuesByModel + } + } +} diff --git a/src/shared/native-chat-session-options.ts b/src/shared/native-chat-session-options.ts new file mode 100644 index 000000000000..d159919e6a78 --- /dev/null +++ b/src/shared/native-chat-session-options.ts @@ -0,0 +1,57 @@ +export type SessionOptionValue = string | boolean + +export type SessionOptionSelectChoice = { + value: string + label: string + description?: string +} + +export type SessionOptionValueSource = 'applied' | 'dispatched' | 'reported' | 'unknown' + +/** Closed set of reasons an option is not settable in the current mode. A key + * (not free English) so the producer and the localized label stay in sync — + * an exhaustive switch turns any drift into a type error instead of leaking + * untranslated text. */ +export type SessionOptionDisabledReason = + | 'available-after-session-start' + | 'set-when-session-starts' + +export type SessionOptionDescriptor = { + id: string + label: string + description?: string + category?: 'model' | 'thought_level' | 'model_config' | 'mode' + kind: + | { + type: 'select' + currentValue?: string + choices: SessionOptionSelectChoice[] + } + | { type: 'boolean'; currentValue?: boolean } + valueSource: SessionOptionValueSource + settable: boolean + disabledReason?: SessionOptionDisabledReason + /** Why: picker-only and toggle-only PTY commands cannot be represented as + * a truthful radio/checkbox state, so the producer exposes an action row. */ + action?: { type: 'agent-picker' | 'toggle-command' } +} + +export type SessionOptionSetResult = { + snapshot: SessionOptionDescriptor[] +} + +export type PersistedNativeChatSessionOptions = Partial< + Record< + string, + { + model?: string + valuesByModel?: Record> + } + > +> + +export type SessionOptionsSurface = { + getSnapshot(): SessionOptionDescriptor[] + setOption(id: string, value: SessionOptionValue): Promise + subscribe(listener: (snapshot: SessionOptionDescriptor[]) => void): () => void +} diff --git a/src/shared/tui-agent-launch-command.ts b/src/shared/tui-agent-launch-command.ts new file mode 100644 index 000000000000..d433fc617218 --- /dev/null +++ b/src/shared/tui-agent-launch-command.ts @@ -0,0 +1,62 @@ +import { resolveAgentSessionOptionLaunch } from './agent-session-option-launch' +import type { SessionOptionValue } from './native-chat-session-options' +import { getTuiAgentLaunchCommand, TUI_AGENT_CONFIG } from './tui-agent-config' +import { + planAgentCliArgsSuffix, + quoteStartupArg, + tokenizeStartupCommand, + type AgentStartupShell +} from './tui-agent-startup-shell' +import type { TuiAgent } from './types' + +export type ResolvedAgentLaunchCommand = + | { + ok: true + command: string + commandWithoutSessionOptions: string + appliedSessionOptions: Record + } + | { ok: false; error: string } + +export function resolveAgentLaunchCommand(args: { + agent: TuiAgent + cmdOverrides: Partial> + platform: NodeJS.Platform + shell: AgentStartupShell + agentArgs?: string | null + sessionOptions?: Record + isRemote?: boolean +}): ResolvedAgentLaunchCommand { + const override = args.cmdOverrides[args.agent] + const command = + override || + getTuiAgentLaunchCommand(TUI_AGENT_CONFIG[args.agent], args.platform, { + isRemote: args.isRemote + }) + const suffix = planAgentCliArgsSuffix(args.agentArgs, args.shell) + if (!suffix.ok) { + return suffix + } + const trailingTokens = args.agentArgs?.trim() + ? tokenizeStartupCommand(args.agentArgs.trim(), args.shell) + : { ok: true as const, tokens: [] } + if (!trailingTokens.ok) { + return { ok: false, error: `CLI arguments are invalid: ${trailingTokens.error}` } + } + const resolvedOptions = resolveAgentSessionOptionLaunch( + args.agent, + args.sessionOptions, + trailingTokens.tokens + ) + const optionSuffix = resolvedOptions.args.map((arg) => quoteStartupArg(arg, args.shell)).join(' ') + const commandWithOptions = optionSuffix ? `${command} ${optionSuffix}` : command + const commandWithoutSessionOptions = suffix.suffix ? `${command} ${suffix.suffix}` : command + // Why: session flags precede the free-form suffix so the user's explicit + // repeated flag remains the final, winning occurrence. + return { + ok: true, + command: suffix.suffix ? `${commandWithOptions} ${suffix.suffix}` : commandWithOptions, + commandWithoutSessionOptions, + appliedSessionOptions: resolvedOptions.appliedValues + } +} diff --git a/src/shared/tui-agent-startup-session-options.test.ts b/src/shared/tui-agent-startup-session-options.test.ts new file mode 100644 index 000000000000..af97826f530b --- /dev/null +++ b/src/shared/tui-agent-startup-session-options.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest' +import { + buildAgentDraftLaunchPlan, + buildAgentResumeStartupPlan, + buildAgentStartupPlan +} from './tui-agent-startup' + +describe('tui agent startup session options', () => { + it('emits catalog options before user arguments without recording an overridden model', () => { + const plan = buildAgentStartupPlan({ + agent: 'claude', + prompt: '', + cmdOverrides: {}, + platform: 'linux', + allowEmptyPromptLaunch: true, + sessionOptions: { model: 'opus', effort: 'xhigh', fastMode: true }, + agentArgs: '--model haiku' + }) + expect(plan?.launchCommand).toBe("claude '--model' 'opus' '--effort' 'xhigh' '--model' 'haiku'") + expect(plan?.sessionOptions).toBeUndefined() + }) + + it('keeps the model record but drops an effort overridden by user arguments', () => { + const plan = buildAgentStartupPlan({ + agent: 'claude', + prompt: '', + cmdOverrides: {}, + platform: 'linux', + allowEmptyPromptLaunch: true, + sessionOptions: { model: 'opus', effort: 'xhigh' }, + agentArgs: '--effort low' + }) + expect(plan?.sessionOptions).toEqual({ model: 'opus' }) + }) + + it('recognizes a long Codex model flag overriding the generated short flag', () => { + const plan = buildAgentStartupPlan({ + agent: 'codex', + prompt: '', + cmdOverrides: {}, + platform: 'linux', + allowEmptyPromptLaunch: true, + sessionOptions: { model: 'gpt-5.6-sol', effort: 'medium' }, + agentArgs: '--model gpt-5.5' + }) + expect(plan?.sessionOptions).toBeUndefined() + }) + + it('keeps one-time picker flags out of the command captured for resume', () => { + const plan = buildAgentStartupPlan({ + agent: 'codex', + prompt: '', + cmdOverrides: {}, + platform: 'linux', + allowEmptyPromptLaunch: true, + sessionOptions: { model: 'gpt-5.6-sol', effort: 'medium' }, + agentArgs: '--dangerously-bypass-approvals-and-sandbox' + }) + expect(plan?.launchConfig.agentCommand).toBe( + "codex '--dangerously-bypass-approvals-and-sandbox'" + ) + }) + + it('quotes option values for a remote POSIX launch', () => { + const plan = buildAgentStartupPlan({ + agent: 'claude', + prompt: '', + cmdOverrides: {}, + platform: 'linux', + isRemote: true, + allowEmptyPromptLaunch: true, + sessionOptions: { model: "team's-model", effort: 'high' } + }) + expect(plan?.launchCommand).toContain("'team'\\''s-model'") + }) + + it('threads options through native draft launches', () => { + const plan = buildAgentDraftLaunchPlan({ + agent: 'claude', + draft: 'review this', + cmdOverrides: {}, + platform: 'linux', + sessionOptions: { model: 'opus', effort: 'high' } + }) + expect(plan?.launchCommand).toContain("claude '--model' 'opus' '--effort' 'high'") + expect(plan?.sessionOptions).toEqual({ model: 'opus', effort: 'high' }) + }) + + it('never injects session options into resume commands', () => { + const plan = buildAgentResumeStartupPlan({ + agent: 'codex', + providerSession: { key: 'session_id', id: 'thread-1' }, + cmdOverrides: {}, + platform: 'linux', + sessionOptions: { model: 'gpt-5.5', effort: 'high' } + }) + expect(plan?.launchCommand).toBe("codex 'resume' 'thread-1'") + expect(plan?.sessionOptions).toBeUndefined() + }) +}) diff --git a/src/shared/tui-agent-startup.ts b/src/shared/tui-agent-startup.ts index 6e3a334dbc1b..ff9b44d3f022 100644 --- a/src/shared/tui-agent-startup.ts +++ b/src/shared/tui-agent-startup.ts @@ -8,17 +8,18 @@ import { import { clearEnvCommand, commandSeparator, - planAgentCliArgsSuffix, quoteStartupArg, resolveStartupShell, type AgentStartupShell } from './tui-agent-startup-shell' -import { getTuiAgentLaunchCommand, TUI_AGENT_CONFIG } from './tui-agent-config' +import { TUI_AGENT_CONFIG } from './tui-agent-config' import type { StartupCommandDelivery } from './codex-startup-delivery' import { buildSleepingAgentLaunchConfig } from './sleeping-agent-launch-config' import { planHermesStartupQuery } from './hermes-startup-query' import { inlineAgentDraftFitsPlatform } from './agent-draft-platform-limit' import type { TuiAgent } from './types' +import type { SessionOptionValue } from './native-chat-session-options' +import { resolveAgentLaunchCommand } from './tui-agent-launch-command' export type AgentStartupPlan = { agent: TuiAgent @@ -30,29 +31,15 @@ export type AgentStartupPlan = { draftPrompt?: string | null env?: Record startupCommandDelivery?: StartupCommandDelivery + /** Values actually emitted into this launch command, kept as base model ids + * so the native-chat surface can render only launch-backed state. */ + sessionOptions?: Record } -function resolveBaseCommand(args: { - agent: TuiAgent - cmdOverrides: Partial> - platform: NodeJS.Platform - shell: AgentStartupShell - agentArgs?: string | null - isRemote?: boolean -}): { ok: true; command: string } | { ok: false; error: string } { - const override = args.cmdOverrides[args.agent] - const command = - override || - getTuiAgentLaunchCommand(TUI_AGENT_CONFIG[args.agent], args.platform, { - isRemote: args.isRemote - }) - const suffix = planAgentCliArgsSuffix(args.agentArgs, args.shell) - if (!suffix.ok) { - return suffix - } - // Why: Codex status hooks live in Orca's runtime CODEX_HOME; adding - // --profile-v2 makes Codex load a second hook representation and warn. - return { ok: true, command: suffix.suffix ? `${command} ${suffix.suffix}` : command } +function appliedSessionOptionProps( + values: Record +): Pick { + return Object.keys(values).length > 0 ? { sessionOptions: { ...values } } : {} } export function buildAgentStartupPlan(args: { @@ -64,6 +51,7 @@ export function buildAgentStartupPlan(args: { allowEmptyPromptLaunch?: boolean agentArgs?: string | null agentEnv?: Record | null + sessionOptions?: Record /** Why: SSH remotes deploy the CLI shim as plain `orca`, so the Linux-only * `orca-ide` rename must be skipped for remote launches. */ isRemote?: boolean @@ -73,12 +61,13 @@ export function buildAgentStartupPlan(args: { const trimmedPrompt = prompt.trim() const config = TUI_AGENT_CONFIG[agent] const usesQuery = config.promptInjectionMode === 'hermes-query' && Boolean(trimmedPrompt) - const baseCommand = resolveBaseCommand({ + const baseCommand = resolveAgentLaunchCommand({ agent, cmdOverrides, platform, shell, agentArgs: usesQuery ? null : args.agentArgs, + sessionOptions: args.sessionOptions, isRemote: args.isRemote }) if (!baseCommand.ok) { @@ -86,7 +75,9 @@ export function buildAgentStartupPlan(args: { } const launchConfig = buildSleepingAgentLaunchConfig({ ...args, - agentCommand: baseCommand.command + // Why: picker flags are a one-time launch choice; a resumed provider + // session restores its own state and must retain only explicit user args. + agentCommand: baseCommand.commandWithoutSessionOptions }) if (!trimmedPrompt) { @@ -99,6 +90,7 @@ export function buildAgentStartupPlan(args: { expectedProcess: config.expectedProcess, followupPrompt: null, launchConfig, + ...appliedSessionOptionProps(baseCommand.appliedSessionOptions), ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}) } } @@ -113,6 +105,7 @@ export function buildAgentStartupPlan(args: { expectedProcess: config.expectedProcess, followupPrompt: null, launchConfig, + ...appliedSessionOptionProps(baseCommand.appliedSessionOptions), ...(agent === 'codex' ? { startupCommandDelivery: 'shell-ready' as const } : {}), ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}) } @@ -125,6 +118,7 @@ export function buildAgentStartupPlan(args: { expectedProcess: config.expectedProcess, followupPrompt: null, launchConfig, + ...appliedSessionOptionProps(baseCommand.appliedSessionOptions), ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}) } } @@ -150,6 +144,7 @@ export function buildAgentStartupPlan(args: { expectedProcess: config.expectedProcess, followupPrompt: null, launchConfig, + ...appliedSessionOptionProps(baseCommand.appliedSessionOptions), ...(queryPlan.env ? { env: queryPlan.env } : {}) } } @@ -161,6 +156,7 @@ export function buildAgentStartupPlan(args: { expectedProcess: config.expectedProcess, followupPrompt: null, launchConfig, + ...appliedSessionOptionProps(baseCommand.appliedSessionOptions), ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}) } } @@ -172,6 +168,7 @@ export function buildAgentStartupPlan(args: { expectedProcess: config.expectedProcess, followupPrompt: null, launchConfig, + ...appliedSessionOptionProps(baseCommand.appliedSessionOptions), ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}) } } @@ -182,6 +179,7 @@ export function buildAgentStartupPlan(args: { expectedProcess: config.expectedProcess, followupPrompt: trimmedPrompt, launchConfig, + ...appliedSessionOptionProps(baseCommand.appliedSessionOptions), ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}) } } @@ -195,6 +193,7 @@ export function buildAgentResumeStartupPlan(args: { agentArgs?: string | null agentEnv?: Record | null agentCommand?: string | null + sessionOptions?: Record /** Why: see buildAgentStartupPlan — remote launches use the plain `orca` shim. */ isRemote?: boolean }): AgentStartupPlan | null { @@ -207,7 +206,7 @@ export function buildAgentResumeStartupPlan(args: { const resolvedAgentCommand = args.agentCommand?.trim() const baseCommand = resolvedAgentCommand ? ({ ok: true, command: resolvedAgentCommand } as const) - : resolveBaseCommand({ + : resolveAgentLaunchCommand({ agent: args.agent, cmdOverrides: args.cmdOverrides, platform: args.platform, @@ -244,6 +243,7 @@ export type AgentDraftLaunchPlan = { launchConfig: SleepingAgentLaunchConfig env?: Record startupCommandDelivery?: StartupCommandDelivery + sessionOptions?: Record } export function buildAgentDraftLaunchPlan(args: { @@ -254,6 +254,7 @@ export function buildAgentDraftLaunchPlan(args: { shell?: AgentStartupShell agentArgs?: string | null agentEnv?: Record | null + sessionOptions?: Record /** Why: see buildAgentStartupPlan — remote launches use the plain `orca` shim. */ isRemote?: boolean }): AgentDraftLaunchPlan | null { @@ -264,12 +265,13 @@ export function buildAgentDraftLaunchPlan(args: { if (!trimmed) { return null } - const baseCommand = resolveBaseCommand({ + const baseCommand = resolveAgentLaunchCommand({ agent, cmdOverrides, platform, shell, agentArgs: args.agentArgs, + sessionOptions: args.sessionOptions, isRemote: args.isRemote }) if (!baseCommand.ok) { @@ -277,7 +279,8 @@ export function buildAgentDraftLaunchPlan(args: { } const launchConfig = buildSleepingAgentLaunchConfig({ ...args, - agentCommand: baseCommand.command + // Why: see the new-session path above — resume must not replay picker flags. + agentCommand: baseCommand.commandWithoutSessionOptions }) let plan: AgentDraftLaunchPlan | null = null if (config.draftPromptFlag) { @@ -287,6 +290,7 @@ export function buildAgentDraftLaunchPlan(args: { launchCommand: `${baseCommand.command} ${config.draftPromptFlag} ${quoted}`, expectedProcess: config.expectedProcess, launchConfig, + ...appliedSessionOptionProps(baseCommand.appliedSessionOptions), // Why: native draft flags carry user text on argv and must survive rc-file startup. ...(agent === 'codex' ? { startupCommandDelivery: 'shell-ready' as const } : {}), ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}) @@ -298,6 +302,7 @@ export function buildAgentDraftLaunchPlan(args: { launchCommand: `${baseCommand.command}${commandSeparator(shell)}${clearVar}`, expectedProcess: config.expectedProcess, launchConfig, + ...appliedSessionOptionProps(baseCommand.appliedSessionOptions), env: { ...args.agentEnv, [config.draftPromptEnvVar]: trimmed } } } diff --git a/src/shared/types.ts b/src/shared/types.ts index 3de8e943d16f..c805b9a0131c 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -41,6 +41,7 @@ import type { LocalWindowsRuntimePreference } from './project-execution-runtime' import type { UsagePercentageDisplay } from './usage-percentage-display' +import type { PersistedNativeChatSessionOptions } from './native-chat-session-options' // Re-exported for backward compat with renderer call sites that import // `WorkspaceCreateTelemetrySource` from '../../../shared/types'. @@ -2680,6 +2681,9 @@ export type GlobalSettings = { /** Experimental: native chat surface for Claude/Codex terminal sessions. * Off by default while the desktop UX is still being exercised. */ experimentalNativeChat?: boolean + /** Last explicit native-chat model and model-scoped option selections. Live + * panes still require an applied/dispatched record before showing a value. */ + nativeChatSessionOptions?: PersistedNativeChatSessionOptions /** Extra launcher rows for the worktree "Open in" submenu. VS Code is always shown first. */ openInApplications?: OpenInApplication[] /** Deprecated: migration/backward-compat only. Use PersistedUIState.rightSidebarOpen. */ From 3a74721f8951eb8884acd90a3bd4826b95c511fd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:17:03 -0700 Subject: [PATCH 095/351] fix(skills): hide split icon and stop update-terminal respawn on refocus (#9082) * fix(skills): hide split icon and stop update-terminal respawn on refocus The "Update skills" modal embeds a one-off command terminal via OnboardingInlineCommandTerminal. Two issues: 1. The pane header showed the regular terminal's split ("Split Terminal Right") icon, which makes no sense for an ephemeral single-command terminal. Add an `allowSplit` prop (default true) threaded TerminalPane -> TerminalPaneHeaderOverlay, and have all onboarding inline command terminals pass `allowSplit={false}` to omit it. 2. Alt-tabbing out of Orca and back respawned the terminal. On window focus, useSkillFreshness republishes {inventory: null, loading: true} to retract stale update authority; the dialog reacts by nulling the draft command, then re-sets it after the scan, and the `key={terminalCommand}` remount spawns a fresh PTY. Add a ref-counted suspendSkillFreshnessFocusRescan(); the dialog holds focus rescans while a live terminal is showing so refocus no longer tears it down. Explicit Re-check and install-changed refreshes still run. * fix(skills): preserve freshness checks without respawning terminal * fix(skills): keep revalidated terminal safe and focused --- resources/skills/current-manifest.json | 18 +-- .../OnboardingInlineCommandTerminal.tsx | 1 + .../SkillFreshnessUpdateDialog.test.tsx | 56 +++++++++- .../skills/SkillFreshnessUpdateDialog.tsx | 105 ++++++++++++++---- .../components/terminal-pane/TerminalPane.tsx | 5 + .../TerminalPaneHeaderOverlay.test.tsx | 13 +++ .../TerminalPaneHeaderOverlay.tsx | 5 +- 7 files changed, 164 insertions(+), 39 deletions(-) diff --git a/resources/skills/current-manifest.json b/resources/skills/current-manifest.json index fca1162fa5e5..51cbb6af5ccc 100644 --- a/resources/skills/current-manifest.json +++ b/resources/skills/current-manifest.json @@ -1,11 +1,11 @@ { "schemaVersion": 1, - "appVersion": "1.4.144-rc.1", + "appVersion": "1.4.144-rc.2", "skills": [ { "name": "computer-use", "sourcePath": "skills/computer-use", - "appVersion": "1.4.144-rc.1", + "appVersion": "1.4.144-rc.2", "releaseRevision": 5, "packageDigest": "cd2809474d57fd7277adb277448e6fa446810d3cbad71ac0b473b9e8ff1bad68", "gitTreeSha": "306c0f8cb63bcac265a5b7975dc2f855be4f1344", @@ -24,7 +24,7 @@ { "name": "linear-tickets", "sourcePath": "skills/linear-tickets", - "appVersion": "1.4.144-rc.1", + "appVersion": "1.4.144-rc.2", "releaseRevision": 4, "packageDigest": "f198d7b22e5ee1673dac403f9cca0553b124e0a90e4fdd05d2c23b7344e32d2b", "gitTreeSha": "de9fc106bbb4e313a90ff9a9513a720909bbd176", @@ -43,7 +43,7 @@ { "name": "orca-cli", "sourcePath": "skills/orca-cli", - "appVersion": "1.4.144-rc.1", + "appVersion": "1.4.144-rc.2", "releaseRevision": 32, "packageDigest": "51740ff13f379ac5743d3fd28a14b17168dcef40f7048c20182dce166098c45f", "gitTreeSha": "ded93000a5f654e2b4f324501282459bd56afe19", @@ -62,7 +62,7 @@ { "name": "orca-emulator", "sourcePath": "skills/orca-emulator", - "appVersion": "1.4.144-rc.1", + "appVersion": "1.4.144-rc.2", "releaseRevision": 4, "packageDigest": "453b1d9aa20b51b8a4d32c7b6def6a93f7ef9c730de32abbcbc1788ad1b1820b", "gitTreeSha": "66be6abe99f1807da85934aee0e22daefc8f7656", @@ -81,7 +81,7 @@ { "name": "orca-emulator-android", "sourcePath": "skills/orca-emulator-android", - "appVersion": "1.4.144-rc.1", + "appVersion": "1.4.144-rc.2", "releaseRevision": 2, "packageDigest": "12272cf82e0731f11e424822b961882457034e730358cc65ea28e4eb9c8ff7f5", "gitTreeSha": "f7b0fc8cbf5cd78ca5156f6bbe3a20f1462d8f83", @@ -100,7 +100,7 @@ { "name": "orca-linear", "sourcePath": "skills/orca-linear", - "appVersion": "1.4.144-rc.1", + "appVersion": "1.4.144-rc.2", "releaseRevision": 2, "packageDigest": "d44d09e6ecb6a64da177083aad26a95f031cd1cf26ba059fdc888c2628aef64f", "gitTreeSha": "c34f42030f43e5a85737996fa375bbd79cb5bea8", @@ -119,7 +119,7 @@ { "name": "orca-per-workspace-env", "sourcePath": "skills/orca-per-workspace-env", - "appVersion": "1.4.144-rc.1", + "appVersion": "1.4.144-rc.2", "releaseRevision": 2, "packageDigest": "fa3b65a1a107fca3f0375c696852477b62f58c154b9eb5c0663c41edc4bcd30d", "gitTreeSha": "354e775b79ea6952ec63acac4d3ee8a9ae07a650", @@ -138,7 +138,7 @@ { "name": "orchestration", "sourcePath": "skills/orchestration", - "appVersion": "1.4.144-rc.1", + "appVersion": "1.4.144-rc.2", "releaseRevision": 24, "packageDigest": "9fbfa2ae3f3f99441563a4b8b1c6302107944480db8718ddb326a862a51f7ab9", "gitTreeSha": "086c41e0b353b4908d2963694b4a7c791d4b3982", diff --git a/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx b/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx index 6beb281bbc53..a0b8e894b472 100644 --- a/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx +++ b/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx @@ -294,6 +294,7 @@ export function OnboardingInlineCommandTerminal({ cwd={cwd} isActive isVisible + showSplitButton={false} onPtyExit={() => { onTerminalExit?.() closeTab(tabId, { recordInteraction: false, reason: 'pty-exit' }) diff --git a/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx b/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx index a810ae06cc61..fc9926f6ed67 100644 --- a/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx +++ b/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment happy-dom -import { act, useState, type ReactNode } from 'react' +import { act, useLayoutEffect, useRef, useState, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { @@ -24,6 +24,7 @@ const mocks = vi.hoisted(() => ({ onInteracted?: (method: 'keyboard' | 'pointer', event?: { key?: string }) => void onTerminalExit?: () => void }[], + terminalCommits: [] as [command: string, inert: boolean][], notifyChanged: vi.fn() })) @@ -42,8 +43,20 @@ vi.mock('@/hooks/useInstalledAgentSkills', () => ({ vi.mock('@/components/onboarding/OnboardingInlineCommandTerminal', () => ({ OnboardingInlineCommandTerminal: (props: (typeof mocks.terminalProps)[number]) => { + const terminalRef = useRef(null) mocks.terminalProps.push(props) - return
{props.command}
+ useLayoutEffect(() => { + mocks.terminalCommits.push([ + props.command, + terminalRef.current?.parentElement?.inert ?? false + ]) + }) + return ( +
+ {props.command} +