From 84ca07d5358aa91f33da7e636e512f918144274d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 00:51:59 +0000 Subject: [PATCH 01/38] 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 ee1674f4124..d6127bbe29b 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 5.6m + + downloads: 5.7m @@ -15,7 +15,7 @@ downloads downloads - 5.6m - 5.6m + 5.7m + 5.7m From 0da3d392dc6e3f4bcf4978c18fa5388ba5f2104f Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 15 Jul 2026 18:48:34 -0700 Subject: [PATCH 02/38] Reorganize heading slash commands and rename toggle heading labels (#8917) Move toggle heading commands (toggle-h1..h4) into their own "Toggle headings" group, separate from plain heading commands, so the slash menu distinguishes static headings from collapsible toggle headings. Also shorten toggle heading labels from "Toggle Heading N" to "Toggle HN" for consistency with the plain heading labels. --- .../editor/rich-markdown-commands.test.ts | 8 +- .../rich-markdown-heading-slash-commands.tsx | 170 +++++++++--------- .../rich-markdown-slash-command-primitives.ts | 8 +- src/renderer/src/i18n/locales/en.json | 8 +- 4 files changed, 101 insertions(+), 93 deletions(-) diff --git a/src/renderer/src/components/editor/rich-markdown-commands.test.ts b/src/renderer/src/components/editor/rich-markdown-commands.test.ts index d04b1de08f6..5caffa90e7a 100644 --- a/src/renderer/src/components/editor/rich-markdown-commands.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-commands.test.ts @@ -40,14 +40,14 @@ describe('rich markdown slash commands', () => { it('orders commands under section headers', () => { expect(slashCommands.map((command) => `${command.group}:${command.id}`)).toEqual([ 'Headings:heading-1', - 'Headings:toggle-h1', 'Headings:heading-2', - 'Headings:toggle-h2', 'Headings:heading-3', - 'Headings:toggle-h3', 'Headings:heading-4', - 'Headings:toggle-h4', 'Headings:heading-5', + 'Toggle headings:toggle-h1', + 'Toggle headings:toggle-h2', + 'Toggle headings:toggle-h3', + 'Toggle headings:toggle-h4', 'Basic blocks:blockquote', 'Basic blocks:ordered-list', 'Basic blocks:bullet-list', diff --git a/src/renderer/src/components/editor/rich-markdown-heading-slash-commands.tsx b/src/renderer/src/components/editor/rich-markdown-heading-slash-commands.tsx index b71b79f1e25..16d256c5f0f 100644 --- a/src/renderer/src/components/editor/rich-markdown-heading-slash-commands.tsx +++ b/src/renderer/src/components/editor/rich-markdown-heading-slash-commands.tsx @@ -25,27 +25,6 @@ export const headingSlashCommands: SlashCommand[] = [ editor.chain().focus().setHeading({ level: 1 }).run() } }, - { - id: 'toggle-h1', - get label() { - return translate( - 'auto.components.editor.rich.markdown.slash.commands.41482b15ce', - 'Toggle Heading 1' - ) - }, - aliases: ['toggle-h1', 'toggle heading', 'details heading', 'collapse heading'], - icon: icon(ChevronRight), - group: 'Headings', - get description() { - return translate( - 'auto.components.editor.rich.markdown.slash.commands.3294a2c0cc', - 'Create a collapsible section with a large heading summary.' - ) - }, - run: (editor) => { - insertToggle(editor, 'heading-1') - } - }, { id: 'heading-2', get label() { @@ -68,27 +47,6 @@ export const headingSlashCommands: SlashCommand[] = [ editor.chain().focus().setHeading({ level: 2 }).run() } }, - { - id: 'toggle-h2', - get label() { - return translate( - 'auto.components.editor.rich.markdown.slash.commands.7a2c1f9b04', - 'Toggle Heading 2' - ) - }, - aliases: ['toggle-h2'], - icon: icon(ChevronRight), - group: 'Headings', - get description() { - return translate( - 'auto.components.editor.rich.markdown.slash.commands.b3e5d8a1c6', - 'Create a collapsible section with a medium heading summary.' - ) - }, - run: (editor) => { - insertToggle(editor, 'heading-2') - } - }, { id: 'heading-3', get label() { @@ -111,27 +69,6 @@ export const headingSlashCommands: SlashCommand[] = [ editor.chain().focus().setHeading({ level: 3 }).run() } }, - { - id: 'toggle-h3', - get label() { - return translate( - 'auto.components.editor.rich.markdown.slash.commands.2f9d6b4e10', - 'Toggle Heading 3' - ) - }, - aliases: ['toggle-h3'], - icon: icon(ChevronRight), - group: 'Headings', - get description() { - return translate( - 'auto.components.editor.rich.markdown.slash.commands.8c1a3e7d52', - 'Create a collapsible section with a small heading summary.' - ) - }, - run: (editor) => { - insertToggle(editor, 'heading-3') - } - }, { id: 'heading-4', get label() { @@ -154,27 +91,6 @@ export const headingSlashCommands: SlashCommand[] = [ editor.chain().focus().setHeading({ level: 4 }).run() } }, - { - id: 'toggle-h4', - get label() { - return translate( - 'auto.components.editor.rich.markdown.slash.commands.5e0b9c2a71', - 'Toggle Heading 4' - ) - }, - aliases: ['toggle-h4'], - icon: icon(ChevronRight), - group: 'Headings', - get description() { - return translate( - 'auto.components.editor.rich.markdown.slash.commands.d4f16a8b39', - 'Create a collapsible section with a nested heading summary.' - ) - }, - run: (editor) => { - insertToggle(editor, 'heading-4') - } - }, { id: 'heading-5', get label() { @@ -196,5 +112,91 @@ export const headingSlashCommands: SlashCommand[] = [ // Use setHeading (not toggleHeading) so "/h5" is idempotent. editor.chain().focus().setHeading({ level: 5 }).run() } + }, + // Grouped separately (Notion-style) so plain headings and collapsible + // toggle headings are easy to scan as distinct families in the slash menu. + { + id: 'toggle-h1', + get label() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.41482b15ce', + 'Toggle H1' + ) + }, + aliases: ['toggle-h1', 'toggle heading', 'details heading', 'collapse heading'], + icon: icon(ChevronRight), + group: 'Toggle headings', + get description() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.3294a2c0cc', + 'Create a collapsible section with a large heading summary.' + ) + }, + run: (editor) => { + insertToggle(editor, 'heading-1') + } + }, + { + id: 'toggle-h2', + get label() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.7a2c1f9b04', + 'Toggle H2' + ) + }, + aliases: ['toggle-h2'], + icon: icon(ChevronRight), + group: 'Toggle headings', + get description() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.b3e5d8a1c6', + 'Create a collapsible section with a medium heading summary.' + ) + }, + run: (editor) => { + insertToggle(editor, 'heading-2') + } + }, + { + id: 'toggle-h3', + get label() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.2f9d6b4e10', + 'Toggle H3' + ) + }, + aliases: ['toggle-h3'], + icon: icon(ChevronRight), + group: 'Toggle headings', + get description() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.8c1a3e7d52', + 'Create a collapsible section with a small heading summary.' + ) + }, + run: (editor) => { + insertToggle(editor, 'heading-3') + } + }, + { + id: 'toggle-h4', + get label() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.5e0b9c2a71', + 'Toggle H4' + ) + }, + aliases: ['toggle-h4'], + icon: icon(ChevronRight), + group: 'Toggle headings', + get description() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.d4f16a8b39', + 'Create a collapsible section with a nested heading summary.' + ) + }, + run: (editor) => { + insertToggle(editor, 'heading-4') + } } ] diff --git a/src/renderer/src/components/editor/rich-markdown-slash-command-primitives.ts b/src/renderer/src/components/editor/rich-markdown-slash-command-primitives.ts index 447a7c08a53..3f982d9bba9 100644 --- a/src/renderer/src/components/editor/rich-markdown-slash-command-primitives.ts +++ b/src/renderer/src/components/editor/rich-markdown-slash-command-primitives.ts @@ -40,7 +40,13 @@ export type SlashCommandIcon = | { kind: 'component'; component: React.ComponentType<{ className?: string }> } | { kind: 'text'; value: string } -export type SlashCommandGroup = 'Headings' | 'Basic blocks' | 'Advanced' | 'Media' | 'Others' +export type SlashCommandGroup = + | 'Headings' + | 'Toggle headings' + | 'Basic blocks' + | 'Advanced' + | 'Media' + | 'Others' export type SlashCommand = { id: SlashCommandId diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 997b1ca6c81..1abdf8130ef 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -11845,18 +11845,18 @@ "45cf7ceb3f": "Medium section heading.", "c209a116b7": "Heading 2", "3294a2c0cc": "Create a collapsible section with a large heading summary.", - "41482b15ce": "Toggle Heading 1", + "41482b15ce": "Toggle H1", "570611864e": "Large section heading.", "e66e7f04c6": "Heading 1", "5f9a0ed7c4": "Heading 4", "01a71dbbdd": "Nested section heading.", "8440fa4acf": "Heading 5", "b287b93c66": "Deep section heading.", - "7a2c1f9b04": "Toggle Heading 2", + "7a2c1f9b04": "Toggle H2", "b3e5d8a1c6": "Create a collapsible section with a medium heading summary.", - "2f9d6b4e10": "Toggle Heading 3", + "2f9d6b4e10": "Toggle H3", "8c1a3e7d52": "Create a collapsible section with a small heading summary.", - "5e0b9c2a71": "Toggle Heading 4", + "5e0b9c2a71": "Toggle H4", "d4f16a8b39": "Create a collapsible section with a nested heading summary." } } From b0def3b13000e0924f5c82a368b0cf0abf4602ed Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 15 Jul 2026 18:49:34 -0700 Subject: [PATCH 03/38] fix(ssh): keep Windows CLI when the launcher compiler is missing (#8918) The remote CLI installer deleted the legacy orca.cmd bridge before it confirmed csc.exe existed, so a minimal Windows host without the .NET compiler lost its existing CLI command and got nothing back. Move the legacy-shim removal to run only after the compile and the launcher-existence guard both pass. Missing-compiler and compile-failure paths now exit non-zero while leaving the existing orca.cmd untouched; a successful upgrade still clears the unsafe %* bridge (orca.exe already shadows orca.cmd via PATHEXT). No %* forwarding is restored. Co-authored-by: Orca --- src/main/ssh/ssh-remote-cli-launcher.test.ts | 77 ++++++++++++++++++-- src/main/ssh/ssh-remote-cli-launcher.ts | 6 +- 2 files changed, 74 insertions(+), 9 deletions(-) diff --git a/src/main/ssh/ssh-remote-cli-launcher.test.ts b/src/main/ssh/ssh-remote-cli-launcher.test.ts index c9f9c0865de..62aa7bec660 100644 --- a/src/main/ssh/ssh-remote-cli-launcher.test.ts +++ b/src/main/ssh/ssh-remote-cli-launcher.test.ts @@ -1,6 +1,6 @@ import { Buffer } from 'node:buffer' import { spawnSync } from 'node:child_process' -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { describe, expect, it } from 'vitest' @@ -23,15 +23,18 @@ function decodePowerShellCommand(command: string): string { } describe('SSH remote Orca CLI launcher', () => { - it('compiles a native Windows launcher without a cmd.exe argument bridge', () => { - const hostPlatform = getRemoteHostPlatform('win32-x64') - const plan = createRemoteCliInstallPlan({ + function windowsInstallPlan(): ReturnType { + return createRemoteCliInstallPlan({ binDir: 'C:/Users/me user/.orca-relay/bin', relayDir: 'C:/Users/me user/.orca-remote/relay-v1', nodePath: 'C:/Program Files/nodejs/node.exe', sockPath: '\\\\.\\pipe\\orca-relay-123', - hostPlatform + hostPlatform: getRemoteHostPlatform('win32-x64') }) + } + + it('compiles a native Windows launcher without a cmd.exe argument bridge', () => { + const plan = windowsInstallPlan() expect(plan.launcherPath).toBe('C:/Users/me user/.orca-relay/bin/orca.exe') expect(plan.files).toHaveLength(1) @@ -54,7 +57,24 @@ describe('SSH remote Orca CLI launcher', () => { expect(compileScript).toContain('/out:orca.exe') expect(compileScript).toContain('C:/Users/me user/.orca-relay/bin/orca-launcher.cs') expect(compileScript).toContain('C:/Users/me user/.orca-relay/bin/orca.cmd') - expect(compileScript.indexOf('orca.cmd')).toBeLessThan(compileScript.indexOf('csc.exe')) + }) + + it('removes the legacy orca.cmd only after every compile guard has passed', () => { + const script = decodePowerShellCommand(windowsInstallPlan().postWriteCommands[0] ?? '') + const legacyShimRemoval = + "Remove-Item -LiteralPath 'C:/Users/me user/.orca-relay/bin/orca.cmd' -Force -ErrorAction SilentlyContinue" + // Why: a host missing csc.exe or failing the compile must keep its existing + // CLI, so every fail-closed guard precedes the legacy %* shim removal. + const guards = [ + "if (-not $compiler) { Write-Error 'Unable to find the .NET Framework C# compiler required for the Orca SSH CLI launcher.'; exit 1 }", + 'if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }', + "if (-not (Test-Path -LiteralPath 'C:/Users/me user/.orca-relay/bin/orca.exe' -PathType Leaf))" + ] + expect(script).toContain(legacyShimRemoval) + for (const guard of guards) { + expect(script).toContain(guard) + expect(script.indexOf(guard)).toBeLessThan(script.indexOf(legacyShimRemoval)) + } }) itWindows('preserves a multiline argument through the compiled remote launcher', () => { @@ -128,6 +148,51 @@ describe('SSH remote Orca CLI launcher', () => { } }) + itWindows('preserves the existing orca.cmd when the compiler is missing', () => { + const root = mkdtempSync(join(tmpdir(), 'orca remote cli ')) + try { + const binDir = join(root, 'bin').replaceAll('\\', '/') + mkdirSync(binDir, { recursive: true }) + const legacyShimPath = join(binDir, 'orca.cmd') + writeFileSync(legacyShimPath, '@echo legacy orca cli\r\n', 'utf8') + + const plan = createRemoteCliInstallPlan({ + binDir, + relayDir: join(root, 'relay').replaceAll('\\', '/'), + nodePath: process.execPath, + sockPath: '\\\\.\\pipe\\orca-relay-test', + hostPlatform: getRemoteHostPlatform('win32-x64') + }) + for (const file of plan.files) { + mkdirSync(dirname(file.path), { recursive: true }) + writeFileSync(file.path, file.contents, 'utf8') + } + + const encoded = plan.postWriteCommands[0]?.match(/-EncodedCommand\s+(\S+)/)?.[1] + expect(encoded).toBeTruthy() + // Point WINDIR at a directory with no csc.exe so compiler discovery fails. + const compile = spawnSync( + 'powershell.exe', + [ + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-EncodedCommand', + encoded! + ], + { encoding: 'utf8', env: { ...process.env, WINDIR: root, SystemRoot: root } } + ) + + expect(compile.status).not.toBe(0) + expect(existsSync(legacyShimPath), 'existing orca.cmd must survive a failed install').toBe( + true + ) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + it('keeps the POSIX launcher as an argv-preserving shell exec', () => { const plan = createRemoteCliInstallPlan({ binDir: '/home/me/.orca-relay/bin', diff --git a/src/main/ssh/ssh-remote-cli-launcher.ts b/src/main/ssh/ssh-remote-cli-launcher.ts index a246ce9c9b8..4122e0dc09d 100644 --- a/src/main/ssh/ssh-remote-cli-launcher.ts +++ b/src/main/ssh/ssh-remote-cli-launcher.ts @@ -169,9 +169,6 @@ function createWindowsLauncherCompileCommand( .join(' ') return powerShellCommand( [ - // Why: an upgrade must not leave the old %* batch bridge callable when - // compiler discovery fails; losing the convenience CLI is safer. - `Remove-Item -LiteralPath ${powerShellLiteral(legacyShimPath)} -Force -ErrorAction SilentlyContinue`, `Set-Location -ErrorAction Stop -LiteralPath ${powerShellLiteral(binDir)}`, '$windowsDirectory = if ($env:WINDIR) { $env:WINDIR } else { $env:SystemRoot }', `$compilerCandidates = @((Join-Path $windowsDirectory 'Microsoft.NET\\Framework64\\v4.0.30319\\csc.exe'), (Join-Path $windowsDirectory 'Microsoft.NET\\Framework\\v4.0.30319\\csc.exe'))`, @@ -180,6 +177,9 @@ function createWindowsLauncherCompileCommand( `& $compiler ${compilerArgs}`, 'if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }', `if (-not (Test-Path -LiteralPath ${powerShellLiteral(launcherPath)} -PathType Leaf)) { Write-Error 'The Orca SSH CLI launcher compiler produced no executable.'; exit 1 }`, + // Why: remove the legacy %* bridge only after a successful compile, so a + // host missing csc.exe keeps its existing CLI (orca.exe shadows orca.cmd). + `Remove-Item -LiteralPath ${powerShellLiteral(legacyShimPath)} -Force -ErrorAction SilentlyContinue`, `Remove-Item -LiteralPath ${powerShellLiteral(sourcePath)} -Force` ].join('; ') ) From 78d2b958bf075f5301b97a0210c5367c6a84cbf2 Mon Sep 17 00:00:00 2001 From: fsdwen Date: Thu, 16 Jul 2026 09:52:37 +0800 Subject: [PATCH 04/38] fix(issues): replace cursor-based pagination with page-number Search API (#8680) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(issues): replace cursor-based pagination with page-number Search API Problem ======= Issue pagination (#8649) had two bugs: 1. Pages 6-16 were unreachable — clicking page 16 highlighted page 5; clicking 6/7 did nothing. The old cursor-based approach (updated: --- src/main/github/client-issue-source.test.ts | 175 ++++------- src/main/github/client-work-items.test.ts | 200 +++++++----- src/main/github/client.ts | 296 ++++++++---------- src/main/ipc/github.test.ts | 8 +- src/main/ipc/github.ts | 4 +- src/main/runtime/orca-runtime.test.ts | 4 +- src/main/runtime/orca-runtime.ts | 10 +- src/main/runtime/rpc/methods/github.ts | 4 +- src/preload/api-types.ts | 2 +- src/preload/index.ts | 2 +- src/renderer/src/components/TaskPage.tsx | 157 +++++----- .../task-page-cache-selectors.test.ts | 4 +- .../components/task-page-cache-selectors.ts | 16 +- .../task-page-work-item-pagination.test.ts | 22 +- .../task-page-work-item-pagination.ts | 15 + src/renderer/src/store/slices/github.test.ts | 40 ++- src/renderer/src/store/slices/github.ts | 44 +-- src/renderer/src/web/web-preload-api.test.ts | 4 +- src/shared/work-items.ts | 7 + 19 files changed, 529 insertions(+), 485 deletions(-) diff --git a/src/main/github/client-issue-source.test.ts b/src/main/github/client-issue-source.test.ts index 5312343224a..df658007fe5 100644 --- a/src/main/github/client-issue-source.test.ts +++ b/src/main/github/client-issue-source.test.ts @@ -49,6 +49,47 @@ vi.mock('./rate-limit', () => ({ import { countWorkItems, getWorkItem, listWorkItems, _resetOwnerRepoCache } from './client' +const PR_LIST_FIELDS = + 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,reviewRequests' + +function issueSearchArgs( + ownerRepo: string, + options: { noCache?: boolean; query?: string } = {} +): string[] { + const query = options.query ?? 'is:issue is:open' + return [ + 'api', + ...(options.noCache ? [] : ['--cache', '120s']), + `search/issues?q=${encodeURIComponent(`repo:${ownerRepo} ${query}`)}&sort=created&order=desc&per_page=10&page=1`, + '--jq', + '.items' + ] +} + +function prListArgs(ownerRepo: string, query = 'is:pr is:open'): string[] { + return [ + 'pr', + 'list', + '--limit', + '10', + '--state', + 'all', + '--json', + PR_LIST_FIELDS, + '--repo', + ownerRepo, + '--search', + `${query} sort:created-desc` + ] +} + +function decodedIssueSearchPath(callIndex: number): string { + const args = ghExecFileAsyncMock.mock.calls[callIndex]?.[0] as string[] | undefined + const apiPath = args?.find((arg) => arg.startsWith('search/issues?')) + expect(apiPath).toBeDefined() + return decodeURIComponent(apiPath ?? '') +} + describe('GitHub issue source split', () => { beforeEach(() => { execFileAsyncMock.mockReset() @@ -115,26 +156,12 @@ describe('GitHub issue source split', () => { await listWorkItems('/repo-root', 10) - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 1, - [ - 'api', - '--cache', - '120s', - 'repos/stablyai/orca/issues?per_page=10&state=open&sort=updated&direction=desc' - ], - { cwd: '/repo-root' } - ) - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 2, - [ - 'api', - '--cache', - '120s', - 'repos/fork/orca/pulls?per_page=10&state=open&sort=updated&direction=desc' - ], - { cwd: '/repo-root' } - ) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(1, issueSearchArgs('stablyai/orca'), { + cwd: '/repo-root' + }) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(2, prListArgs('fork/orca'), { + cwd: '/repo-root' + }) }) it('omits gh api cache args for no-cache recent work-item requests', async () => { @@ -148,14 +175,12 @@ describe('GitHub issue source split', () => { expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 1, - ['api', 'repos/stablyai/orca/issues?per_page=10&state=open&sort=updated&direction=desc'], - { cwd: '/repo-root' } - ) - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 2, - ['api', 'repos/fork/orca/pulls?per_page=10&state=open&sort=updated&direction=desc'], + issueSearchArgs('stablyai/orca', { noCache: true }), { cwd: '/repo-root' } ) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(2, prListArgs('fork/orca'), { + cwd: '/repo-root' + }) }) it('lists SSH repo work items with explicit owner/repo and no local cwd', async () => { @@ -177,26 +202,8 @@ describe('GitHub issue source split', () => { {} ) expect(getOwnerRepoMock).toHaveBeenCalledWith('/home/jinwoo/orca', 'openclaw-2', {}) - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 1, - [ - 'api', - '--cache', - '120s', - 'repos/stablyai/orca/issues?per_page=10&state=open&sort=updated&direction=desc' - ], - {} - ) - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 2, - [ - 'api', - '--cache', - '120s', - 'repos/fork/orca/pulls?per_page=10&state=open&sort=updated&direction=desc' - ], - {} - ) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(1, issueSearchArgs('stablyai/orca'), {}) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(2, prListArgs('fork/orca'), {}) }) it('uses upstream for issue-only queries and origin for PR-only queries', async () => { @@ -206,10 +213,7 @@ describe('GitHub issue source split', () => { await listWorkItems('/repo-root', 10, 'is:issue') - expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - expect.arrayContaining(['--repo', 'stablyai/orca']), - { cwd: '/repo-root' } - ) + expect(decodedIssueSearchPath(0)).toContain('q=repo:stablyai/orca is:issue') ghExecFileAsyncMock.mockClear() getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) @@ -237,16 +241,9 @@ describe('GitHub issue source split', () => { await listWorkItems('/repo-root', 10, undefined, undefined, 'upstream') - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 2, - [ - 'api', - '--cache', - '120s', - 'repos/stablyai/orca/pulls?per_page=10&state=open&sort=updated&direction=desc' - ], - { cwd: '/repo-root' } - ) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(2, prListArgs('stablyai/orca'), { + cwd: '/repo-root' + }) }) it("uses upstream for queried PRs when preference='upstream'", async () => { @@ -515,16 +512,9 @@ describe('GitHub issue source split', () => { const result = await listWorkItems('/repo-root', 10, undefined, undefined, 'auto') expect(resolveIssueSourceMock).toHaveBeenCalledWith('/repo-root', 'auto', undefined, {}) - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 1, - [ - 'api', - '--cache', - '120s', - 'repos/stablyai/orca/issues?per_page=10&state=open&sort=updated&direction=desc' - ], - { cwd: '/repo-root' } - ) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(1, issueSearchArgs('stablyai/orca'), { + cwd: '/repo-root' + }) expect(result.issueSourceFellBack).toBeUndefined() }) @@ -540,16 +530,9 @@ describe('GitHub issue source split', () => { await listWorkItems('/repo-root', 10, undefined, undefined, 'auto') - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 1, - [ - 'api', - '--cache', - '120s', - 'repos/solo/orca/issues?per_page=10&state=open&sort=updated&direction=desc' - ], - { cwd: '/repo-root' } - ) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(1, issueSearchArgs('solo/orca'), { + cwd: '/repo-root' + }) }) it("preference='upstream' + upstream exists → queries upstream", async () => { @@ -564,13 +547,7 @@ describe('GitHub issue source split', () => { const result = await listWorkItems('/repo-root', 10, undefined, undefined, 'upstream') - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 1, - expect.arrayContaining([ - 'repos/stablyai/orca/issues?per_page=10&state=open&sort=updated&direction=desc' - ]), - { cwd: '/repo-root' } - ) + expect(decodedIssueSearchPath(0)).toContain('q=repo:stablyai/orca is:issue is:open') expect(result.issueSourceFellBack).toBeUndefined() }) @@ -586,13 +563,7 @@ describe('GitHub issue source split', () => { const result = await listWorkItems('/repo-root', 10, undefined, undefined, 'upstream') - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 1, - expect.arrayContaining([ - 'repos/solo/orca/issues?per_page=10&state=open&sort=updated&direction=desc' - ]), - { cwd: '/repo-root' } - ) + expect(decodedIssueSearchPath(0)).toContain('q=repo:solo/orca is:issue is:open') expect(result.issueSourceFellBack).toBe(true) }) @@ -608,13 +579,7 @@ describe('GitHub issue source split', () => { await listWorkItems('/repo-root', 10, undefined, undefined, 'origin') - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 1, - expect.arrayContaining([ - 'repos/fork/orca/issues?per_page=10&state=open&sort=updated&direction=desc' - ]), - { cwd: '/repo-root' } - ) + expect(decodedIssueSearchPath(0)).toContain('q=repo:fork/orca is:issue is:open') }) it("preference='origin' + no upstream → queries origin", async () => { @@ -629,13 +594,7 @@ describe('GitHub issue source split', () => { await listWorkItems('/repo-root', 10, undefined, undefined, 'origin') - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 1, - expect.arrayContaining([ - 'repos/solo/orca/issues?per_page=10&state=open&sort=updated&direction=desc' - ]), - { cwd: '/repo-root' } - ) + expect(decodedIssueSearchPath(0)).toContain('q=repo:solo/orca is:issue is:open') }) it('surfaces upstreamCandidate in sources regardless of effective preference', async () => { diff --git a/src/main/github/client-work-items.test.ts b/src/main/github/client-work-items.test.ts index d0e93eb306c..a30db9f19e0 100644 --- a/src/main/github/client-work-items.test.ts +++ b/src/main/github/client-work-items.test.ts @@ -180,18 +180,12 @@ describe('listWorkItems', () => { expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 1, [ - 'issue', - 'list', - '--limit', - '10', - '--json', - 'number,title,state,url,labels,updatedAt,author,assignees', - '--repo', - 'acme/widgets', - '--assignee', - '@me', - '--search', - 'sort:updated-desc' + 'api', + '--cache', + '120s', + `search/issues?q=${encodeURIComponent('repo:acme/widgets is:issue assignee:@me')}&sort=created&order=desc&per_page=10&page=1`, + '--jq', + '.items' ], { cwd: '/repo-root' } ) @@ -202,14 +196,14 @@ describe('listWorkItems', () => { 'list', '--limit', '10', + '--state', + 'all', '--json', 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,reviewRequests', '--repo', 'acme/widgets', - '--assignee', - '@me', '--search', - 'sort:updated-desc' + 'is:pr assignee:@me sort:created-desc' ], { cwd: '/repo-root' } ) @@ -218,24 +212,6 @@ describe('listWorkItems', () => { expect(prListFields).toContain('reviewRequests') expect(prListFields).not.toContain('mergeStateStatus') expect(items).toEqual([ - { - id: 'issue:12', - type: 'issue', - number: 12, - title: 'Fix bug', - state: 'open', - url: 'https://github.com/acme/widgets/issues/12', - labels: [], - updatedAt: '2026-03-29T00:00:00Z', - author: 'octocat', - assignees: [ - { - login: 'test-assignee', - name: 'Test Assignee', - avatarUrl: 'https://avatars.githubusercontent.com/u/1?v=4' - } - ] - }, { id: 'pr:42', type: 'pr', @@ -257,6 +233,24 @@ describe('listWorkItems', () => { avatarUrl: 'https://avatars.githubusercontent.com/u/1?v=4' } ] + }, + { + id: 'issue:12', + type: 'issue', + number: 12, + title: 'Fix bug', + state: 'open', + url: 'https://github.com/acme/widgets/issues/12', + labels: [], + updatedAt: '2026-03-29T00:00:00Z', + author: 'octocat', + assignees: [ + { + login: 'test-assignee', + name: 'Test Assignee', + avatarUrl: 'https://avatars.githubusercontent.com/u/1?v=4' + } + ] } ]) }) @@ -404,15 +398,14 @@ describe('listWorkItems', () => { 'list', '--limit', '10', + '--state', + 'all', '--json', 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,reviewRequests', '--repo', 'acme/widgets', - '--state', - 'open', - '--draft', '--search', - 'sort:updated-desc' + 'is:pr is:open draft:true sort:created-desc' ], { cwd: '/repo-root' } ) @@ -465,14 +458,14 @@ describe('listWorkItems', () => { 'list', '--limit', '10', + '--state', + 'all', '--json', 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,reviewRequests', '--repo', 'acme/widgets', - '--state', - 'merged', '--search', - 'sort:updated-desc' + 'is:pr is:merged sort:created-desc' ], { cwd: '/repo-root' } ) @@ -536,7 +529,12 @@ describe('listWorkItems', () => { const { items } = await listWorkItems('/repo-root', 10, 'is:pr is:closed') expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - expect.arrayContaining(['--state', 'closed', '--search', '-is:merged sort:updated-desc']), + expect.arrayContaining([ + '--state', + 'all', + '--search', + 'is:pr is:closed -is:merged sort:created-desc' + ]), { cwd: '/repo-root' } ) expect(items).toMatchObject([{ id: 'pr:9', type: 'pr', state: 'closed' }]) @@ -605,7 +603,7 @@ describe('listWorkItems', () => { expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - expect.arrayContaining(['--search', 'review-requested:@me sort:updated-desc']), + expect.arrayContaining(['--search', 'is:pr is:open review-requested:@me sort:created-desc']), { cwd: '/repo-root' } ) expect(ghExecFileAsyncMock).not.toHaveBeenCalledWith( @@ -614,32 +612,86 @@ describe('listWorkItems', () => { ) }) - it('pins list ordering to updated-desc so the updatedAt cursor pages consistently', async () => { + it('uses the requested numbered Search API page for issues', async () => { getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) - await listWorkItems('/repo-root', 10, 'is:issue is:open') + await listWorkItems('/repo-root', 10, 'is:issue is:open', 2) expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - expect.arrayContaining(['--search', 'sort:updated-desc']), + [ + 'api', + '--cache', + '120s', + `search/issues?q=${encodeURIComponent('repo:acme/widgets is:issue is:open')}&sort=created&order=desc&per_page=10&page=2`, + '--jq', + '.items' + ], { cwd: '/repo-root' } ) }) - it('combines the inclusive updatedAt cursor with updated-desc ordering on later pages', async () => { + it('fetches and slices stable PR results for the requested numbered page', async () => { getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify( + [1, 2, 3, 4].map((number) => ({ + number, + title: `PR ${number}`, + state: 'OPEN', + url: `https://github.com/acme/widgets/pull/${number}`, + labels: [], + updatedAt: `2026-07-0${number}T00:00:00Z`, + author: { login: 'octocat' }, + isDraft: false, + headRefName: `feature/${number}`, + headRefOid: `head-${number}`, + baseRefName: 'main' + })) + ) + }) - await listWorkItems('/repo-root', 10, 'is:issue is:open', '2026-07-01T00:00:00Z') + const { items } = await listWorkItems('/repo-root', 2, 'is:pr is:open', 2) - // Why: the bound is inclusive (`<=`) so boundary items sharing the cursor's - // exact updatedAt aren't skipped; the renderer dedupes the re-fetched rows. expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - expect.arrayContaining(['--search', 'updated:<=2026-07-01T00:00:00Z sort:updated-desc']), + expect.arrayContaining(['--limit', '4', '--search', 'is:pr is:open sort:created-desc']), { cwd: '/repo-root' } ) + expect(items.map((item) => item.number)).toEqual([4, 3]) + }) + + it('filters pull request rows out of issue Search API results', async () => { + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify([ + { + number: 2, + title: 'PR-shaped search row', + state: 'open', + html_url: 'https://github.com/acme/widgets/pull/2', + labels: [], + updated_at: '2026-07-02T00:00:00Z', + user: { login: 'octocat' }, + pull_request: { url: 'https://api.github.com/repos/acme/widgets/pulls/2' } + }, + { + number: 1, + title: 'Issue row', + state: 'open', + html_url: 'https://github.com/acme/widgets/issues/1', + labels: [], + updated_at: '2026-07-01T00:00:00Z', + user: { login: 'octocat' } + } + ]) + }) + + const { items } = await listWorkItems('/repo-root', 10, 'is:issue is:open') + + expect(items.map((item) => item.id)).toEqual(['issue:1']) }) it('returns open issues and PRs for the all-open preset query', async () => { @@ -679,18 +731,12 @@ describe('listWorkItems', () => { const { items } = await listWorkItems('/repo-root', 10, 'is:open') expect(ghExecFileAsyncMock).toHaveBeenCalledWith( [ - 'issue', - 'list', - '--limit', - '10', - '--json', - 'number,title,state,url,labels,updatedAt,author,assignees', - '--repo', - 'acme/widgets', - '--state', - 'open', - '--search', - 'sort:updated-desc' + 'api', + '--cache', + '120s', + `search/issues?q=${encodeURIComponent('repo:acme/widgets is:issue is:open')}&sort=created&order=desc&per_page=10&page=1`, + '--jq', + '.items' ], { cwd: '/repo-root' } ) @@ -700,29 +746,18 @@ describe('listWorkItems', () => { 'list', '--limit', '10', + '--state', + 'all', '--json', 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,reviewRequests', '--repo', 'acme/widgets', - '--state', - 'open', '--search', - 'sort:updated-desc' + 'is:pr is:open sort:created-desc' ], { cwd: '/repo-root' } ) expect(items).toEqual([ - { - id: 'issue:1', - type: 'issue', - number: 1, - title: 'Open issue', - state: 'open', - url: 'https://github.com/acme/widgets/issues/1', - labels: [], - updatedAt: '2026-03-31T00:00:00Z', - author: 'octocat' - }, { id: 'pr:2', type: 'pr', @@ -737,6 +772,17 @@ describe('listWorkItems', () => { baseRefName: 'main', headSha: 'head-2', prRepo: { owner: 'acme', repo: 'widgets' } + }, + { + id: 'issue:1', + type: 'issue', + number: 1, + title: 'Open issue', + state: 'open', + url: 'https://github.com/acme/widgets/issues/1', + labels: [], + updatedAt: '2026-03-31T00:00:00Z', + author: 'octocat' } ]) }) diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 15a2f8b34bc..54572bd406f 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -32,7 +32,7 @@ import { isGitHubWorkItemsQueryTooLarge } from '../../shared/github-work-items-q import { parseTaskQuery, type ParsedTaskQuery } from '../../shared/task-query' import { GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE, - sortWorkItemsByUpdatedAt + sortWorkItemsByNumber } from '../../shared/work-items' import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' @@ -449,17 +449,13 @@ export async function getAuthenticatedViewer(): Promise { } // Why: main-process maps omit repoId because the IPC handler never receives -// a repo identifier beyond path. The renderer stamps repoId after IPC so -// single-repo and cross-repo items are uniform downstream. -type MainWorkItem = Omit +// a repo identifier beyond path. Exported because runtime consumers receive +// listWorkItems results before the renderer stamps repoId. +export type MainWorkItem = Omit -const WORK_ITEM_ISSUE_LIST_JSON_FIELDS = 'number,title,state,url,labels,updatedAt,author,assignees' - -// Why: the Tasks pager slices pages on updatedAt, so every list/search fetch -// must return rows newest-updated-first for the cursor to advance correctly. -// This is the search-qualifier spelling of the same contract the REST list -// paths express as `sort=updated&direction=desc`; keep the two in sync (#8649). -const WORK_ITEM_LIST_SORT_QUALIFIER = 'sort:updated-desc' +// Why: issue numbers follow creation order within a repository. Pinning this +// sort keeps gh's rich PR rows aligned with numbered Search API issue pages. +const WORK_ITEM_NUMBER_SORT_QUALIFIER = 'sort:created-desc' const WORK_ITEM_PR_LIST_JSON_FIELDS = 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,reviewRequests' @@ -956,78 +952,98 @@ async function fetchPullRequestWorkItem( return mapPullRequestWorkItem(JSON.parse(stdout) as Record) } -function buildWorkItemListArgs(args: { +type WorkItemListRequest = { + args: string[] + offset: number +} + +function normalizeWorkItemPage(page: number | undefined): number { + return typeof page === 'number' && Number.isFinite(page) && page >= 1 ? Math.floor(page) : 1 +} + +function buildWorkItemListRequest(args: { kind: 'issue' | 'pr' ownerRepo: OwnerRepo | null limit: number query: ParsedTaskQuery - before?: string -}): string[] { - const { kind, ownerRepo, limit, query, before } = args - const fields = kind === 'issue' ? WORK_ITEM_ISSUE_LIST_JSON_FIELDS : WORK_ITEM_PR_LIST_JSON_FIELDS - const command = kind === 'issue' ? ['issue', 'list'] : ['pr', 'list'] - const out = [...command, '--limit', String(limit), '--json', fields] + page: number +}): WorkItemListRequest { + const { kind, ownerRepo, limit, query, page } = args + const searchParts: string[] = [] - if (ownerRepo) { - out.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`) + if (kind === 'issue' && ownerRepo) { + searchParts.push(`repo:${ownerRepo.owner}/${ownerRepo.repo}`) + } + searchParts.push(kind === 'issue' ? 'is:issue' : 'is:pr') + + if (query.state === 'open') { + searchParts.push('is:open') + } else if (query.state === 'closed') { + searchParts.push('is:closed') + if (kind === 'pr') { + searchParts.push('-is:merged') + } + } else if (query.state === 'merged') { + searchParts.push('is:merged') } - if (query.state) { - out.push('--state', query.state) + if (kind === 'pr' && query.draft) { + searchParts.push('draft:true') } - // Why: GitHub considers merged PRs as "closed". When the user filters for - // closed-only, exclude merged PRs via the search predicate so the displayed - // and counted results match the user's intent. - const excludeMergedFromClosed = kind === 'pr' && query.state === 'closed' if (query.assignee) { - out.push('--assignee', query.assignee) + searchParts.push(`assignee:${quoteGitHubSearchValue(query.assignee)}`) } if (query.author) { - out.push('--author', query.author) + searchParts.push(`author:${quoteGitHubSearchValue(query.author)}`) } if (query.labels.length > 0) { for (const label of query.labels) { - out.push('--label', label) + searchParts.push(`label:${quoteGitHubSearchValue(label)}`) } } - // Why: only add --draft when the user explicitly typed `is:draft`. Previously - // this fired for any PR-scoped open query, which made `is:pr is:open` (the - // "PRs" preset) silently filter to drafts-only. - if (kind === 'pr' && query.draft) { - out.push('--draft') - } - - const searchParts: string[] = [] - if (excludeMergedFromClosed) { - searchParts.push('-is:merged') - } - // Why: cursor-based pagination. GitHub search supports updated:<=DATE to - // fetch items at or older than the cursor. We use the oldest item's updatedAt - // from the previous page as the cursor. The bound is inclusive (`<=`) so items - // sharing the boundary row's exact updatedAt aren't skipped between pages; the - // renderer dedupes the re-fetched boundary rows by id (#8649). - if (before) { - searchParts.push(`updated:<=${before}`) - } if (kind === 'pr' && query.reviewRequested) { - searchParts.push(`review-requested:${query.reviewRequested}`) + searchParts.push(`review-requested:${quoteGitHubSearchValue(query.reviewRequested)}`) } if (kind === 'pr' && query.reviewedBy) { - searchParts.push(`reviewed-by:${query.reviewedBy}`) + searchParts.push(`reviewed-by:${quoteGitHubSearchValue(query.reviewedBy)}`) } if (query.freeText) { searchParts.push(query.freeText) } - // Why: pagination cursors slice on updatedAt, but `gh issue list` defaults - // to created-desc and `--search` defaults to best-match. `gh issue/pr list` - // has no --sort flag, so the only lever is the search query — which forces us - // to always emit --search. Without pinning the sort, recently-updated old - // items never appear on any page, so the pager advertises pages the fetch - // chain can never reach (#8649). - searchParts.push(WORK_ITEM_LIST_SORT_QUALIFIER) + + if (kind === 'issue') { + return { + args: [ + 'api', + '--cache', + '120s', + `search/issues?q=${encodeURIComponent(searchParts.join(' '))}&sort=created&order=desc&per_page=${limit}&page=${page}`, + '--jq', + '.items' + ], + offset: 0 + } + } + + // Why: search/issues omits the PR fields used by the Tasks management + // columns. Fetch through gh's rich PR list and slice its stable created sort. + searchParts.push(WORK_ITEM_NUMBER_SORT_QUALIFIER) + const out = [ + 'pr', + 'list', + '--limit', + String(Math.min(page * limit, 1000)), + '--state', + 'all', + '--json', + WORK_ITEM_PR_LIST_JSON_FIELDS + ] + if (ownerRepo) { + out.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`) + } out.push('--search', searchParts.join(' ')) - return out + return { args: out, offset: (page - 1) * limit } } // Why: internal shape shared by listRecentWorkItems / listQueriedWorkItems so @@ -1105,6 +1121,7 @@ async function listRecentWorkItems( issueOwnerRepo: OwnerRepo | null, prOwnerRepo: OwnerRepo | null, limit: number, + page: number, connectionId?: string | null, noCache?: boolean, localGitOptions: LocalGitExecOptions = {} @@ -1112,72 +1129,49 @@ async function listRecentWorkItems( const repoContext = githubRepoContext(repoPath, connectionId, localGitOptions) const ghOptions = ghRepoExecOptions(repoContext) const requiresExplicitRepo = Boolean(connectionId) - const restCacheArgs = noCache ? [] : ['--cache', '120s'] assertSshRepoHasResolvedGitHubSource({ connectionId, issueOwnerRepo, prOwnerRepo }) + const recentQuery = parseTaskQuery('is:open') + const issueRequest = buildWorkItemListRequest({ + kind: 'issue', + ownerRepo: issueOwnerRepo, + limit, + query: recentQuery, + page + }) + const prRequest = buildWorkItemListRequest({ + kind: 'pr', + ownerRepo: prOwnerRepo, + limit, + query: recentQuery, + page + }) + if (noCache) { + issueRequest.args.splice(1, 2) + } if (issueOwnerRepo || prOwnerRepo || requiresExplicitRepo) { // Why: allSettled so a 403 on upstream issues doesn't zero out the origin // PR half — the UI renders partial results plus a banner for the failing // side, matching the parent design doc's partial-failure rule (§2). const [issuesSettled, prsSettled] = await Promise.allSettled([ issueOwnerRepo - ? ghExecFileAsync( - [ - 'api', - ...restCacheArgs, - `repos/${issueOwnerRepo.owner}/${issueOwnerRepo.repo}/issues?per_page=${limit}&state=open&sort=updated&direction=desc` - ], - ghOptions - ) + ? ghExecFileAsync(issueRequest.args, ghOptions) : requiresExplicitRepo ? Promise.resolve({ stdout: '[]' }) - : ghCwdResolvedExec( - repoContext, - [ - 'issue', - 'list', - '--limit', - String(limit), - '--state', - 'open', - '--json', - WORK_ITEM_ISSUE_LIST_JSON_FIELDS - ], - ghOptions - ), + : ghCwdResolvedExec(repoContext, issueRequest.args, ghOptions), prOwnerRepo - ? ghExecFileAsync( - [ - 'api', - ...restCacheArgs, - `repos/${prOwnerRepo.owner}/${prOwnerRepo.repo}/pulls?per_page=${limit}&state=open&sort=updated&direction=desc` - ], - ghOptions - ) + ? ghExecFileAsync(prRequest.args, ghOptions) : requiresExplicitRepo ? Promise.resolve({ stdout: '[]' }) - : ghCwdResolvedExec( - repoContext, - [ - 'pr', - 'list', - '--limit', - String(limit), - '--state', - 'open', - '--json', - WORK_ITEM_PR_LIST_JSON_FIELDS - ], - ghOptions - ) + : ghCwdResolvedExec(repoContext, prRequest.args, ghOptions) ]) let issues: MainWorkItem[] = [] let issuesError: ClassifiedError | undefined if (issuesSettled.status === 'fulfilled') { issues = (JSON.parse(issuesSettled.value.stdout) as Record[]) - // Why: the GitHub issues REST endpoint also returns pull requests with a - // `pull_request` marker. The new-workspace task picker needs distinct - // issue vs PR buckets, so drop PR-shaped issue rows here before merging. + // Why: the GitHub search/issues endpoint may still return PRs with a + // pull_request marker even when queried with is:issue. Filter them out + // here to keep the issue and PR buckets clean. .filter((item) => !('pull_request' in item)) .map(mapIssueWorkItem) } else { @@ -1190,9 +1184,9 @@ async function listRecentWorkItems( let prs: MainWorkItem[] = [] if (prsSettled.status === 'fulfilled') { - prs = (JSON.parse(prsSettled.value.stdout) as Record[]).map((item) => - mapPullRequestWorkItem(item, prOwnerRepo) - ) + prs = (JSON.parse(prsSettled.value.stdout) as Record[]) + .slice(prRequest.offset, prRequest.offset + limit) + .map((item) => mapPullRequestWorkItem(item, prOwnerRepo)) prs = await hydrateWorkItemRepositoryMergeMetadata(prs, prOwnerRepo, ghOptions) } else { // Why: PR-side failures must preserve the pre-diff behavior of @@ -1215,7 +1209,7 @@ async function listRecentWorkItems( } return { - items: sortWorkItemsByUpdatedAt([...issues, ...prs]).slice(0, limit), + items: sortWorkItemsByNumber([...issues, ...prs]).slice(0, limit), issuesError } } @@ -1228,45 +1222,19 @@ async function listRecentWorkItems( // effectively unusable for the feature — reject-all matches reality. If // non-GitHub remotes ever grow source metadata, revisit this symmetry. const [issuesResult, prsResult] = await Promise.all([ - ghCwdResolvedExec( - repoContext, - [ - 'issue', - 'list', - '--limit', - String(limit), - '--state', - 'open', - '--json', - WORK_ITEM_ISSUE_LIST_JSON_FIELDS - ], - ghOptions - ), - ghCwdResolvedExec( - repoContext, - [ - 'pr', - 'list', - '--limit', - String(limit), - '--state', - 'open', - '--json', - WORK_ITEM_PR_LIST_JSON_FIELDS - ], - ghOptions - ) + ghCwdResolvedExec(repoContext, issueRequest.args, ghOptions), + ghCwdResolvedExec(repoContext, prRequest.args, ghOptions) ]) - const issues = (JSON.parse(issuesResult.stdout) as Record[]).map( - mapIssueWorkItem - ) - const prs = (JSON.parse(prsResult.stdout) as Record[]).map((item) => - mapPullRequestWorkItem(item, null) - ) + const issues = (JSON.parse(issuesResult.stdout) as Record[]) + .filter((item) => !('pull_request' in item)) + .map(mapIssueWorkItem) + const prs = (JSON.parse(prsResult.stdout) as Record[]) + .slice(prRequest.offset, prRequest.offset + limit) + .map((item) => mapPullRequestWorkItem(item, null)) return { - items: sortWorkItemsByUpdatedAt([...issues, ...prs]).slice(0, limit) + items: sortWorkItemsByNumber([...issues, ...prs]).slice(0, limit) } } @@ -1276,7 +1244,7 @@ async function listQueriedWorkItems( prOwnerRepo: OwnerRepo | null, query: ParsedTaskQuery, limit: number, - before?: string, + page?: number, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { @@ -1302,19 +1270,21 @@ async function listQueriedWorkItems( if (requiresExplicitRepo && !issueOwnerRepo) { return { items: [] } } - const args = buildWorkItemListArgs({ + const request = buildWorkItemListRequest({ kind: 'issue', ownerRepo: issueOwnerRepo, limit, query, - before + page: page ?? 1 }) try { const { stdout } = issueOwnerRepo - ? await ghExecFileAsync(args, ghOptions) - : await ghCwdResolvedExec(repoContext, args, ghOptions) + ? await ghExecFileAsync(request.args, ghOptions) + : await ghCwdResolvedExec(repoContext, request.args, ghOptions) return { - items: (JSON.parse(stdout) as Record[]).map(mapIssueWorkItem) + items: (JSON.parse(stdout) as Record[]) + .filter((item) => !('pull_request' in item)) + .map(mapIssueWorkItem) } } catch (err) { const stderr = err instanceof Error ? err.message : String(err) @@ -1329,20 +1299,20 @@ async function listQueriedWorkItems( if (requiresExplicitRepo && !prOwnerRepo) { return [] } - const args = buildWorkItemListArgs({ + const request = buildWorkItemListRequest({ kind: 'pr', ownerRepo: prOwnerRepo, limit, query, - before + page: page ?? 1 }) try { const { stdout } = prOwnerRepo - ? await ghExecFileAsync(args, ghOptions) - : await ghCwdResolvedExec(repoContext, args, ghOptions) - const mapped = (JSON.parse(stdout) as Record[]).map((item) => - mapPullRequestWorkItem(item, prOwnerRepo) - ) + ? await ghExecFileAsync(request.args, ghOptions) + : await ghCwdResolvedExec(repoContext, request.args, ghOptions) + const mapped = (JSON.parse(stdout) as Record[]) + .slice(request.offset, request.offset + limit) + .map((item) => mapPullRequestWorkItem(item, prOwnerRepo)) const hydrated = await hydrateWorkItemRepositoryMergeMetadata(mapped, prOwnerRepo, ghOptions) if (query.state === 'closed') { return hydrated.filter((item) => item.state !== 'merged') @@ -1356,7 +1326,7 @@ async function listQueriedWorkItems( const [issueResult, prItems] = await Promise.all([issueFetch, prFetch]) return { - items: sortWorkItemsByUpdatedAt([...issueResult.items, ...prItems]).slice(0, limit), + items: sortWorkItemsByNumber([...issueResult.items, ...prItems]).slice(0, limit), issuesError: issueResult.issuesError } } @@ -1365,13 +1335,14 @@ export async function listWorkItems( repoPath: string, limit = 24, query?: string, - before?: string, + page?: number, preference?: IssueSourcePreference, connectionId?: string | null, noCache?: boolean, localGitOptions: LocalGitExecOptions = {} ): Promise> { const trimmedQuery = query?.trim() ?? '' + const requestedPage = normalizeWorkItemPage(page) if (isGitHubWorkItemsQueryTooLarge(trimmedQuery)) { return { items: [], @@ -1401,6 +1372,7 @@ export async function listWorkItems( issueOwnerRepo, prOwnerRepo, limit, + requestedPage, connectionId, noCache, localGitOptions @@ -1411,7 +1383,7 @@ export async function listWorkItems( prOwnerRepo, parseTaskQuery(trimmedQuery), limit, - before, + requestedPage, connectionId, localGitOptions ) @@ -1480,7 +1452,7 @@ function buildSearchQueryString( } function quoteGitHubSearchValue(value: string): string { - return /\s/.test(value) ? `"${value.replaceAll('"', '\\"')}"` : value + return /[\s"]/.test(value) ? `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"` : value } async function countWorkItemsForQuery( diff --git a/src/main/ipc/github.test.ts b/src/main/ipc/github.test.ts index fef0cf7aace..71f5d286624 100644 --- a/src/main/ipc/github.test.ts +++ b/src/main/ipc/github.test.ts @@ -668,7 +668,7 @@ describe('registerGitHubHandlers', () => { repoPath: '/workspace/repo', limit: 10, query: 'is:open', - before: 'cursor-1', + page: 2, noCache: true }) @@ -676,7 +676,7 @@ describe('registerGitHubHandlers', () => { '/workspace/repo', 10, 'is:open', - 'cursor-1', + 2, 'origin', null, true @@ -732,7 +732,7 @@ describe('registerGitHubHandlers', () => { repoPath: '/workspace/repo', limit: 10, query: 'is:open', - before: 'cursor-1', + page: 2, noCache: true }) await handlers['gh:countWorkItems'](null, { @@ -789,7 +789,7 @@ describe('registerGitHubHandlers', () => { '/workspace/repo', 10, 'is:open', - 'cursor-1', + 2, undefined, null, true, diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index a95fb887e9c..84afec20c3b 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -433,7 +433,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi repoId?: string limit?: number query?: string - before?: string + page?: number noCache?: boolean } ) => { @@ -442,7 +442,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi repo.path, args.limit, args.query, - args.before, + args.page, repo.issueSourcePreference, repoConnectionId(repo), args.noCache, diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 2545980a8eb..f9e2aed305c 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -5185,7 +5185,7 @@ describe('OrcaRuntimeService', () => { listGitHubLabelsMock.mockResolvedValueOnce([]) listGitHubAssignableUsersMock.mockResolvedValueOnce([]) - await runtime.listRepoWorkItems('id:repo-1', 7, 'is:open', 'cursor', true) + await runtime.listRepoWorkItems('id:repo-1', 7, 'is:open', 1, true) await runtime.countRepoWorkItems('id:repo-1', 'is:issue') await runtime.listRepoIssues('id:repo-1', 5) await runtime.getRepoIssue('id:repo-1', 12) @@ -5199,7 +5199,7 @@ describe('OrcaRuntimeService', () => { TEST_REPO_PATH, 7, 'is:open', - 'cursor', + 1, undefined, null, true, diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 8e9be4cbb85..778d5feeb0e 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -403,7 +403,8 @@ import { addPRReviewComment, addPRReviewCommentReply, listLabels, - listAssignableUsers + listAssignableUsers, + type MainWorkItem } from '../github/client' import type { GitHubPRBranchLookupOptions } from '../github/client' import { resolveGitHubPrStartPoint } from '../github/pr-start-point' @@ -452,6 +453,7 @@ import type { GitLabMRInlineCommentInput, GitLabProjectRef, GitLabWorkItem, + ListWorkItemsResult, MRListState } from '../../shared/types' import { inspectSetupScriptImportCandidates } from '../../shared/setup-script-imports' @@ -13185,15 +13187,15 @@ export class OrcaRuntimeService { repoSelector: string, limit?: number, query?: string, - before?: string, + page?: number, noCache?: boolean - ): Promise>> { + ): Promise> { const repo = await this.resolveRepoSelector(repoSelector) return listWorkItems( repo.path, limit, query, - before, + page, repo.issueSourcePreference, repo.connectionId ?? null, noCache, diff --git a/src/main/runtime/rpc/methods/github.ts b/src/main/runtime/rpc/methods/github.ts index c1419af1651..44375181da0 100644 --- a/src/main/runtime/rpc/methods/github.ts +++ b/src/main/runtime/rpc/methods/github.ts @@ -10,7 +10,7 @@ const RepoSelector = z.object({ const WorkItemsList = RepoSelector.extend({ limit: OptionalFiniteNumber, query: OptionalString, - before: OptionalString, + page: z.number().int().positive().optional(), noCache: z.boolean().optional() }) @@ -306,7 +306,7 @@ export const GITHUB_METHODS: RpcMethod[] = [ params.repo, params.limit, params.query, - params.before, + params.page, params.noCache ) }), diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 92ba7bd92d9..0200555af39 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1533,7 +1533,7 @@ export type PreloadApi = { repoId?: string limit?: number query?: string - before?: string + page?: number noCache?: boolean }) => Promise>> prChecks: ( diff --git a/src/preload/index.ts b/src/preload/index.ts index 05e1d62dafe..42d02aec1ba 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1319,7 +1319,7 @@ const api = { repoId?: string limit?: number query?: string - before?: string + page?: number noCache?: boolean }): Promise>> => ipcRenderer.invoke('gh:listWorkItems', args), diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 99fc42d7111..0ade42f2811 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -205,7 +205,11 @@ import { type TaskPageRepoSourceState } from '@/components/task-page-cache-selectors' import { shouldHideTaskPageListChrome } from '@/components/task-page-list-chrome-visibility' -import { accumulateWorkItemPages } from '@/components/task-page-work-item-pagination' +import { + getTaskPagePerRepoLimit, + taskPageToGitHubApiPage +} from '@/components/task-page-work-item-pagination' +import { sortWorkItemsByNumber } from '../../../shared/work-items' import LinearIssueAttributeFilterDropdowns from '@/components/linear-issue-attribute-filter-dropdowns' import { resolveLinearIssueAttributeFilterPrimaryTeam } from '@/components/linear-issue-attribute-filter-primary-team' import { @@ -3801,15 +3805,23 @@ export default function TaskPage(): React.JSX.Element { // once, but the result is reconciled into existing rows to avoid a full // table shuffle when only status/key fields changed. const landingGitHubRefreshKeysRef = useRef>(new Set()) - // Why: pages holds all fetched pages of work items. Page 0 is seeded from - // cache for instant first paint; subsequent pages are loaded via date cursors. - const [pages, setPages] = useState(() => { + // Why: divide the display budget between repos so one provider page maps to + // one UI page without truncating rows that later provider pages cannot return. + const githubPerRepoPageLimit = getTaskPagePerRepoLimit( + selectedRepos.length, + PER_REPO_FETCH_LIMIT, + CROSS_REPO_DISPLAY_LIMIT + ) + const githubPageSize = githubPerRepoPageLimit * Math.max(1, selectedRepos.length) + // Why: null entries are pages not fetched yet. Numbered provider pages let a + // high-page click load that page directly without rate-limiting intermediate reads. + const [pages, setPages] = useState<(GitHubWorkItem[] | null)[]>(() => { const trimmed = initialTaskQuery.trim() const merged: GitHubWorkItem[] = [] for (const r of selectedRepos) { const cached = getCachedWorkItems( r.id, - PER_REPO_FETCH_LIMIT, + githubPerRepoPageLimit, trimmed, r.path, getTaskPageRepoSourceContext(r, 'github') @@ -3821,15 +3833,13 @@ export default function TaskPage(): React.JSX.Element { if (merged.length === 0) { return [[]] } - const page0 = [...merged] - .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()) - .slice(0, CROSS_REPO_DISPLAY_LIMIT) + const page0 = sortWorkItemsByNumber(merged).slice(0, githubPageSize) return [page0] }) const [currentPage, setCurrentPage] = useState(0) const [paginationLoading, setPaginationLoading] = useState(false) const [loadingTargetPage, setLoadingTargetPage] = useState(null) - const [totalItemCount, setTotalItemCount] = useState(null) + const [countedTotalPages, setCountedTotalPages] = useState(null) const fetchWorkItemsNextPage = useAppStore((s) => s.fetchWorkItemsNextPage) const countWorkItemsAcrossRepos = useAppStore((s) => s.countWorkItemsAcrossRepos) @@ -3859,7 +3869,7 @@ export default function TaskPage(): React.JSX.Element { selectTaskPageWorkItemsCacheEntries( s.workItemsCache, selectedRepos.map(getTaskPageRepoCacheInput), - PER_REPO_FETCH_LIMIT, + githubPerRepoPageLimit, appliedWorkItemsCacheQuery ) ) @@ -3982,6 +3992,9 @@ export default function TaskPage(): React.JSX.Element { setPages((current) => { let changed = false const nextPages = current.map((page) => { + if (!page) { + return page + } let pageChanged = false const nextPage = page.map((item) => { if (item.id !== itemKey.id || item.repoId !== itemKey.repoId) { @@ -6156,6 +6169,9 @@ export default function TaskPage(): React.JSX.Element { const seen = new Set() const logins: string[] = [] for (const page of pages) { + if (!page) { + continue + } for (const item of page) { if ( !item.author || @@ -6229,45 +6245,31 @@ export default function TaskPage(): React.JSX.Element { } }, [ensurePRChecksLoaded, filteredWorkItems, githubMode, showPRManagementColumns, taskSource]) - // Why: each page is bounded by both the per-repo fetch budget (so a single - // repo can yield at most PER_REPO_FETCH_LIMIT rows per page) and the - // post-merge display cap. Dividing the total by CROSS_REPO_DISPLAY_LIMIT - // alone under-counts pages when the per-repo budget is the tighter bound — - // e.g. one repo with 60 open PRs yielded 36 rows on page 0 and ceil(60/100) - // = 1 total pages, hiding the pager entirely. - const effectivePageSize = Math.max( - 1, - Math.min(PER_REPO_FETCH_LIMIT * Math.max(1, selectedRepos.length), CROSS_REPO_DISPLAY_LIMIT) - ) - // Why: if the search-API count is unavailable (rate-limited, transient - // failure — `countWorkItemsAcrossRepos` swallows errors and returns 0), - // infer there's at least one more page whenever the last loaded page - // filled to the per-page capacity. Otherwise pagination would silently - // hide and trap the user on page 0 with no way to advance. - const lastPageFull = (pages.at(-1)?.length ?? 0) >= effectivePageSize + let lastLoadedPageIndex = 0 + for (let index = 0; index < pages.length; index += 1) { + if (pages[index] !== null) { + lastLoadedPageIndex = index + } + } + // Why: when counts fail, a full loaded page is enough evidence to expose one + // more page without pretending unfetched sparse entries are empty results. + const lastLoadedPageFull = + (pages[lastLoadedPageIndex]?.length ?? 0) >= Math.max(1, githubPageSize) + const fallbackTotalPages = lastLoadedPageFull + ? Math.max(pages.length, lastLoadedPageIndex + 2) + : Math.max(1, pages.length) const totalPages = - totalItemCount && totalItemCount > 0 - ? Math.max(pages.length, Math.ceil(totalItemCount / effectivePageSize)) - : lastPageFull - ? pages.length + 1 - : pages.length + countedTotalPages && countedTotalPages > 0 + ? Math.max(pages.length, countedTotalPages) + : fallbackTotalPages - // Why: loads the next page using the oldest item's updatedAt as a cursor. - // When targetPage is provided (from clicking a numbered page beyond loaded - // pages), it chains fetches until that page is loaded. + // Why: numbered provider pages support random access. Load only the clicked + // page so a high-page jump does not exhaust GitHub's Search API rate bucket. const handleLoadNextPage = useCallback( async (targetPage?: number) => { if (paginationLoading || selectedRepos.length === 0) { return } - const lastPage = pages.at(-1) - if (!lastPage || lastPage.length === 0) { - return - } - const oldestItem = lastPage.at(-1) - if (!oldestItem?.updatedAt) { - return - } const q = stripRepoQualifiers(appliedTaskSearch.trim()) const repoArgs = selectedRepos.map((r) => ({ repoId: r.id, @@ -6277,37 +6279,32 @@ export default function TaskPage(): React.JSX.Element { })) const requestGeneration = paginationGenerationRef.current - const target = targetPage ?? pages.length + const target = targetPage ?? currentPage + 1 setPaginationLoading(true) setLoadingTargetPage(target) try { - const result = await accumulateWorkItemPages({ - existingPages: pages, - initialCursor: oldestItem.updatedAt, - targetPage: target, - // Why: uniform page size keeps deduped pages from shrinking below the - // size `totalPages` (count ÷ effectivePageSize) assumes — otherwise - // the per-page overlap loss would strand the tail items. - pageSize: effectivePageSize, - isCancelled: () => paginationGenerationRef.current !== requestGeneration, - fetchPage: async (cursor) => { - const { items } = await fetchWorkItemsNextPage( - repoArgs, - PER_REPO_FETCH_LIMIT, - CROSS_REPO_DISPLAY_LIMIT, - q, - cursor - ) - return { items } - } - }) - if (result.cancelled) { + const { items } = await fetchWorkItemsNextPage( + repoArgs, + githubPerRepoPageLimit, + githubPageSize, + q, + taskPageToGitHubApiPage(target) + ) + if (paginationGenerationRef.current !== requestGeneration) { return } - if (result.newPages.length > 0) { - setPages((prev) => [...prev, ...result.newPages]) - setCurrentPage(target < result.loadedPages ? target : result.loadedPages - 1) + if (items.length === 0) { + return } + setPages((previous) => { + const next = [...previous] + while (next.length <= target) { + next.push(null) + } + next[target] = items + return next + }) + setCurrentPage(target) } catch (err) { console.error('Failed to load next page:', err) } finally { @@ -6320,10 +6317,11 @@ export default function TaskPage(): React.JSX.Element { [ paginationLoading, selectedRepos, - pages, + currentPage, appliedTaskSearch, fetchWorkItemsNextPage, - effectivePageSize + githubPageSize, + githubPerRepoPageLimit ] ) @@ -6399,7 +6397,7 @@ export default function TaskPage(): React.JSX.Element { for (const r of selectedRepos) { const cached = getCachedWorkItems( r.id, - PER_REPO_FETCH_LIMIT, + githubPerRepoPageLimit, q, r.path, getTaskPageRepoSourceContext(r, 'github') @@ -6415,14 +6413,10 @@ export default function TaskPage(): React.JSX.Element { // no repo has a cache entry for it), we clear the previous query's rows // rather than leaving them on screen under the spinner. const page0 = - preMerged.length > 0 - ? [...preMerged] - .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()) - .slice(0, CROSS_REPO_DISPLAY_LIMIT) - : [] + preMerged.length > 0 ? sortWorkItemsByNumber(preMerged).slice(0, githubPageSize) : [] setPages([page0]) setCurrentPage(0) - setTotalItemCount(null) + setCountedTotalPages(null) setTasksError(null) setFailedCount(0) // reset so a prior failure banner doesn't linger setTasksLoading(anyUncached) @@ -6464,7 +6458,7 @@ export default function TaskPage(): React.JSX.Element { // newer retry's source from the set. Clearing only the keys captured // when this effect dispatched preserves later additions. const dispatchedRetrySourceKeys = retryingSourceKeys - void fetchWorkItemsAcrossRepos(repoArgs, PER_REPO_FETCH_LIMIT, CROSS_REPO_DISPLAY_LIMIT, q, { + void fetchWorkItemsAcrossRepos(repoArgs, githubPerRepoPageLimit, githubPageSize, q, { ...deriveTaskPageGitHubWorkItemsFetchOptions(forcedFetch, shouldProbeOnLanding) }) .then(({ items, failedCount: failed }) => { @@ -6542,10 +6536,11 @@ export default function TaskPage(): React.JSX.Element { executionHostId: r.executionHostId, sourceContext: getTaskPageRepoSourceContext(r, 'github') })), - q - ).then((count) => { + q, + githubPerRepoPageLimit + ).then(({ totalPages: countedPages }) => { if (!cancelled) { - setTotalItemCount(count) + setCountedTotalPages(countedPages) } }) @@ -9834,7 +9829,7 @@ export default function TaskPage(): React.JSX.Element { totalPages={totalPages} loadingTarget={loadingTargetPage} onPageChange={(page) => { - if (page < pages.length) { + if (pages[page] !== null && pages[page] !== undefined) { setCurrentPage(page) } else { void handleLoadNextPage(page) diff --git a/src/renderer/src/components/task-page-cache-selectors.test.ts b/src/renderer/src/components/task-page-cache-selectors.test.ts index 98703dd2339..71b85b8adae 100644 --- a/src/renderer/src/components/task-page-cache-selectors.test.ts +++ b/src/renderer/src/components/task-page-cache-selectors.test.ts @@ -146,8 +146,8 @@ describe('task page cache selectors', () => { entry([patched]) ]) - expect(nextPages[0][0]).toBe(patched) - expect(nextPages[0][1]).toBe(otherRepoSameId) + expect(nextPages[0]?.[0]).toBe(patched) + expect(nextPages[0]?.[1]).toBe(otherRepoSameId) }) it('merges landing refresh status changes without reordering GitHub rows', () => { diff --git a/src/renderer/src/components/task-page-cache-selectors.ts b/src/renderer/src/components/task-page-cache-selectors.ts index 490db04d916..67b3a7c1c8c 100644 --- a/src/renderer/src/components/task-page-cache-selectors.ts +++ b/src/renderer/src/components/task-page-cache-selectors.ts @@ -37,6 +37,7 @@ type WorkItemsCache = Record> type LinearIssueCache = Record> type LinearSearchCache = Record> type LinearListCache = Record>> +export type TaskPageWorkItemPages = readonly (GitHubWorkItem[] | null)[] export function deriveTaskPageGitHubWorkItemsFetchOptions( forcedFetch: boolean, @@ -83,9 +84,9 @@ function taskPageWorkItemCacheKey(item: GitHubWorkItem): string { } export function reconcileTaskPagePagesWithWorkItemsCache( - pages: readonly GitHubWorkItem[][], + pages: TaskPageWorkItemPages, entries: readonly (CacheEntry | undefined)[] -): GitHubWorkItem[][] { +): (GitHubWorkItem[] | null)[] { const cachedItems = new Map() for (const entry of entries) { for (const item of entry?.data ?? []) { @@ -95,6 +96,9 @@ export function reconcileTaskPagePagesWithWorkItemsCache( let changed = false const nextPages = pages.map((page) => { + if (!page) { + return null + } let pageChanged = false const nextPage = page.map((item) => { const cached = cachedItems.get(taskPageWorkItemCacheKey(item)) @@ -108,7 +112,7 @@ export function reconcileTaskPagePagesWithWorkItemsCache( return pageChanged ? nextPage : page }) - return changed ? nextPages : (pages as GitHubWorkItem[][]) + return changed ? nextPages : (pages as (GitHubWorkItem[] | null)[]) } function taskPageWorkItemKey(item: GitHubWorkItem): string { @@ -221,16 +225,16 @@ export function shouldResetTaskPagePaginationAfterLandingRefresh( } export function reconcileTaskPagePagesAfterLandingRefresh( - pages: readonly GitHubWorkItem[][], + pages: TaskPageWorkItemPages, refreshedItems: readonly GitHubWorkItem[] -): GitHubWorkItem[][] { +): (GitHubWorkItem[] | null)[] { const firstPage = pages[0] ?? [] if (shouldResetTaskPagePaginationAfterLandingRefresh(firstPage, refreshedItems)) { return [[...refreshedItems]] } const nextFirstPage = reconcileTaskPageItemsAfterLandingRefresh(firstPage, refreshedItems) if (nextFirstPage === firstPage) { - return pages as GitHubWorkItem[][] + return pages as (GitHubWorkItem[] | null)[] } return [nextFirstPage, ...pages.slice(1)] } diff --git a/src/renderer/src/components/task-page-work-item-pagination.test.ts b/src/renderer/src/components/task-page-work-item-pagination.test.ts index b3bc3301d94..22ad6bf6cde 100644 --- a/src/renderer/src/components/task-page-work-item-pagination.test.ts +++ b/src/renderer/src/components/task-page-work-item-pagination.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it, vi } from 'vitest' import type { GitHubWorkItem } from '../../../shared/types' -import { accumulateWorkItemPages, workItemIdentity } from './task-page-work-item-pagination' +import { + accumulateWorkItemPages, + getTaskPagePerRepoLimit, + taskPageToGitHubApiPage, + workItemIdentity +} from './task-page-work-item-pagination' function item(repoId: string, id: string, updatedAt: string): GitHubWorkItem { return { @@ -39,6 +44,21 @@ describe('workItemIdentity', () => { }) }) +describe('numbered GitHub pagination', () => { + it('converts zero-indexed task pages to one-indexed API pages', () => { + expect(taskPageToGitHubApiPage(0)).toBe(1) + expect(taskPageToGitHubApiPage(1)).toBe(2) + expect(taskPageToGitHubApiPage(15)).toBe(16) + }) + + it('divides the display budget before per-repo fetches can overflow it', () => { + expect(getTaskPagePerRepoLimit(1, 36, 100)).toBe(36) + expect(getTaskPagePerRepoLimit(2, 36, 100)).toBe(36) + expect(getTaskPagePerRepoLimit(3, 36, 100)).toBe(33) + expect(getTaskPagePerRepoLimit(90, 36, 100)).toBe(1) + }) +}) + describe('accumulateWorkItemPages', () => { it('drops the re-fetched boundary row that shares the previous page cursor', async () => { const boundary = item('r', 'issue:2', '2026-07-02') diff --git a/src/renderer/src/components/task-page-work-item-pagination.ts b/src/renderer/src/components/task-page-work-item-pagination.ts index a332fe675e2..e8f0a8749e3 100644 --- a/src/renderer/src/components/task-page-work-item-pagination.ts +++ b/src/renderer/src/components/task-page-work-item-pagination.ts @@ -31,6 +31,21 @@ export function workItemIdentity(item: Pick): s return `${item.repoId}:${item.id}` } +export function taskPageToGitHubApiPage(taskPage: number): number { + return Math.max(0, Math.floor(taskPage)) + 1 +} + +// Why: provider pages cannot spill truncated rows into the next page. Divide +// the display budget up front so every fetched row remains reachable. +export function getTaskPagePerRepoLimit( + repoCount: number, + maxPerRepo: number, + displayLimit: number +): number { + const normalizedRepoCount = Math.max(1, Math.floor(repoCount)) + return Math.max(1, Math.min(maxPerRepo, Math.floor(displayLimit / normalizedRepoCount))) +} + export type WorkItemPageFetchResult = { items: GitHubWorkItem[] } export type AccumulateWorkItemPagesArgs = { diff --git a/src/renderer/src/store/slices/github.test.ts b/src/renderer/src/store/slices/github.test.ts index 730a9105e54..6ddba03caec 100644 --- a/src/renderer/src/store/slices/github.test.ts +++ b/src/renderer/src/store/slices/github.test.ts @@ -6331,7 +6331,7 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { 24, 100, '', - '2026-05-21T00:00:00Z' + 1 ) expect(result.failedCount).toBe(0) @@ -6377,7 +6377,7 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { 24, 100, 'is:open', - '2026-05-22T00:00:00Z' + 1 ) expect(mockApi.gh.listWorkItems).not.toHaveBeenCalled() @@ -6388,7 +6388,7 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { repo: 'runtime-repo-id', limit: 24, query: 'is:open', - before: '2026-05-22T00:00:00Z' + page: 1 }, timeoutMs: 30_000 }) @@ -6413,9 +6413,13 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { const result = await store .getState() - .countWorkItemsAcrossRepos([{ repoId: 'caller-repo-id', path: '/server/repo' }], 'is:open') + .countWorkItemsAcrossRepos( + [{ repoId: 'caller-repo-id', path: '/server/repo' }], + 'is:open', + 10 + ) - expect(result).toBe(12) + expect(result).toEqual({ totalCount: 12, totalPages: 2 }) expect(mockApi.gh.countWorkItems).not.toHaveBeenCalled() expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ selector: 'env-1', @@ -6434,9 +6438,9 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { const result = await store .getState() - .countWorkItemsAcrossRepos([{ repoId: 'repo-id', path: '/local/repo' }], '') + .countWorkItemsAcrossRepos([{ repoId: 'repo-id', path: '/local/repo' }], '', 10) - expect(result).toBe(7) + expect(result).toEqual({ totalCount: 7, totalPages: 1 }) expect(runtimeEnvironmentCall).not.toHaveBeenCalled() expect(mockApi.gh.countWorkItems).toHaveBeenCalledWith({ repoPath: '/local/repo', @@ -6445,6 +6449,22 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { }) }) + it('derives page count from the repo with the most results', async () => { + const store = createTestStore() + mockApi.gh.countWorkItems.mockResolvedValueOnce(100).mockResolvedValueOnce(1) + + const result = await store.getState().countWorkItemsAcrossRepos( + [ + { repoId: 'large-repo', path: '/local/large' }, + { repoId: 'small-repo', path: '/local/small' } + ], + 'is:issue', + 36 + ) + + expect(result).toEqual({ totalCount: 101, totalPages: 3 }) + }) + it('rejects oversized work-item queries before cache keys or provider calls', async () => { const store = createTestStore() const secret = 'github-work-items-secret' @@ -6471,14 +6491,14 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { 24, 24, oversizedQuery, - 'cursor' + 1 ) ).resolves.toEqual({ items: [], failedCount: 0 }) await expect( store .getState() - .countWorkItemsAcrossRepos([{ repoId: 'repo-id', path: '/local/repo' }], oversizedQuery) - ).resolves.toBe(0) + .countWorkItemsAcrossRepos([{ repoId: 'repo-id', path: '/local/repo' }], oversizedQuery, 24) + ).resolves.toEqual({ totalCount: 0, totalPages: 0 }) store.getState().prefetchWorkItems('repo-id', '/local/repo', 24, oversizedQuery) expect(store.getState().getCachedWorkItems('repo-id', 24, oversizedQuery, '/local/repo')).toBe( diff --git a/src/renderer/src/store/slices/github.ts b/src/renderer/src/store/slices/github.ts index 0a24e24c08f..ef5e67111a5 100644 --- a/src/renderer/src/store/slices/github.ts +++ b/src/renderer/src/store/slices/github.ts @@ -34,7 +34,7 @@ import type { } from '../../../../shared/github-project-types' import { isGitHubWorkItemsSshRemoteRequiredError, - sortWorkItemsByUpdatedAt, + sortWorkItemsByNumber, PER_REPO_FETCH_LIMIT } from '../../../../shared/work-items' import { deriveCheckStatusFromChecks, syncPRChecksStatus } from './github-checks' @@ -206,7 +206,7 @@ type GitHubWorkItemRequestTarget = type GitHubWorkItemsListArgs = { limit: number query?: string - before?: string + page?: number noCache?: true } @@ -2043,10 +2043,7 @@ export type GitHubSlice = { query: string, options?: FetchOptions ) => Promise<{ items: GitHubWorkItem[]; failedCount: number }> - /** - * Fetch the next page of work items using a date cursor. Does not cache — - * pagination pages are ephemeral and managed by TaskPage state. - */ + /** Fetch one numbered provider page. Pagination pages remain renderer-local. */ fetchWorkItemsNextPage: ( repos: { repoId: string @@ -2057,12 +2054,9 @@ export type GitHubSlice = { perRepoLimit: number, displayLimit: number, query: string, - before: string + page: number ) => Promise<{ items: GitHubWorkItem[]; failedCount: number }> - /** - * Count total work items across repos using GitHub's search API. - * Returns the sum of per-repo counts for the given query. - */ + /** Count items and derive pages from the largest per-repo result set. */ countWorkItemsAcrossRepos: ( repos: { repoId: string @@ -2070,8 +2064,9 @@ export type GitHubSlice = { executionHostId?: string | null sourceContext?: TaskSourceContext | null }[], - query: string - ) => Promise + query: string, + perRepoLimit: number + ) => Promise<{ totalCount: number; totalPages: number }> /** * Fire-and-forget prefetch used by UI entry points (hover/focus of the * "new workspace" buttons) to warm the cache before the page mounts. @@ -2896,11 +2891,11 @@ export const createGitHubSlice: StateCreator = (s } }) ) - const merged = sortWorkItemsByUpdatedAt(perProjectResults.flat()).slice(0, displayLimit) + const merged = sortWorkItemsByNumber(perProjectResults.flat()).slice(0, displayLimit) return { items: merged, failedCount } }, - fetchWorkItemsNextPage: async (repos, perRepoLimit, displayLimit, query, before) => { + fetchWorkItemsNextPage: async (repos, perRepoLimit, displayLimit, query, page) => { if (isGitHubWorkItemsQueryTooLarge(query)) { return { items: [], failedCount: 0 } } @@ -2926,7 +2921,7 @@ export const createGitHubSlice: StateCreator = (s const envelope = await listGitHubWorkItemsForRepo(requestContext, { limit: perRepoLimit, query: query || undefined, - before + page }) // Why: page-N partial failures don't participate in the cache's per-repo // error banner (which is keyed on the initial-fetch cache entry). Log the @@ -2954,14 +2949,15 @@ export const createGitHubSlice: StateCreator = (s } }) ) - const merged = sortWorkItemsByUpdatedAt(perProjectResults.flat()).slice(0, displayLimit) + const merged = sortWorkItemsByNumber(perProjectResults.flat()).slice(0, displayLimit) return { items: merged, failedCount } }, - countWorkItemsAcrossRepos: async (repos, query) => { + countWorkItemsAcrossRepos: async (repos, query, perRepoLimit) => { if (isGitHubWorkItemsQueryTooLarge(query)) { - return 0 + return { totalCount: 0, totalPages: 0 } } + const normalizedLimit = Math.max(1, Math.floor(perRepoLimit)) const counts = await Promise.all( repos.map(async (r) => { // Why: same stampede cap as the item-fetch paths — without a slot, @@ -2991,7 +2987,15 @@ export const createGitHubSlice: StateCreator = (s } }) ) - return counts.reduce((sum, c) => sum + c, 0) + return { + totalCount: counts.reduce((sum, count) => sum + count, 0), + // Why: each repo advances independently by the same numbered page. A sum + // divided by page width undercounts when one repo owns most results. + totalPages: counts.reduce( + (maxPages, count) => Math.max(maxPages, Math.ceil(count / normalizedLimit)), + 0 + ) + } }, prefetchWorkItems: (repoId, repoPath, limit = PER_REPO_FETCH_LIMIT, query = '', options) => { diff --git a/src/renderer/src/web/web-preload-api.test.ts b/src/renderer/src/web/web-preload-api.test.ts index 749eab542f0..bb77e236218 100644 --- a/src/renderer/src/web/web-preload-api.test.ts +++ b/src/renderer/src/web/web-preload-api.test.ts @@ -2521,13 +2521,13 @@ describe('web GitHub preload API', () => { }, { key: 'listWorkItems', - args: { repoPath, limit: 20, query: 'is:pr', before: 'cursor', noCache: true }, + args: { repoPath, limit: 20, query: 'is:pr', page: 2, noCache: true }, expectedMethod: 'github.listWorkItems', expectedParams: withRepo({ repoPath, limit: 20, query: 'is:pr', - before: 'cursor', + page: 2, noCache: true }) }, diff --git a/src/shared/work-items.ts b/src/shared/work-items.ts index 848cde95dae..0a770058cc4 100644 --- a/src/shared/work-items.ts +++ b/src/shared/work-items.ts @@ -26,6 +26,13 @@ export function isGitHubWorkItemsSshRemoteRequiredError(error: unknown): boolean return message.includes(GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE) } +// Why: generic over item shape for the same cross-caller reasons as +// sortWorkItemsByUpdatedAt. Sorting by number descending matches GitHub's +// default Issues view (newest issue number first). +export function sortWorkItemsByNumber(items: T[]): T[] { + return [...items].sort((left, right) => right.number - left.number) +} + // Why: generic over the item shape because main-process callers emit items // without repoId (stamped by the renderer after IPC), while renderer callers // carry the full GitHubWorkItem. Both share only the updatedAt field needed From 49e2ac6f59a9cbedeb36b8a48f52878e178d9266 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:06:37 -0700 Subject: [PATCH 05/38] fix(cmd-j): show cross-project dialog on paste + restore focus on Escape (#8931) --- .../src/components/WorktreeJumpPalette.tsx | 132 ++++++------------ .../palette-focus-restore-target.test.ts | 60 ++++++++ .../cmd-j/palette-focus-restore-target.ts | 19 +++ ...mp-palette-source-context-boundary.test.ts | 17 ++- 4 files changed, 134 insertions(+), 94 deletions(-) create mode 100644 src/renderer/src/components/cmd-j/palette-focus-restore-target.test.ts create mode 100644 src/renderer/src/components/cmd-j/palette-focus-restore-target.ts diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index 9b6fcef5b29..e1bb431c76e 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -105,16 +105,14 @@ import { CREATE_WORKSPACE_QUICK_ACTION_ID } from '@/components/cmd-j/quick-actions' import { buildWorktreeChecksReviewIndex } from '@/components/cmd-j/worktree-checks-review-index' +import { resolvePaletteFocusRestoreTarget } from '@/components/cmd-j/palette-focus-restore-target' import { selectWorktreePaletteCacheInputs } from '@/components/cmd-j/worktree-palette-cache-inputs' import { getRepoHostIdentity } from '@/store/slices/repo-host-identity' import { getComposerEligibleRepos, resolveComposerGitRepoId } from '@/lib/new-workspace-composer-repo' -import { - lookupGitHubWorkItemByOwnerRepoForSource, - lookupGitHubWorkItemForSource -} from '@/lib/github-work-item-source-lookup' +import { lookupGitHubWorkItemForSource } from '@/lib/github-work-item-source-lookup' import type { SettingsNavTarget } from '@/lib/settings-navigation-types' import { getHostDisplayLabelOverrides } from '../../../shared/host-setting-overrides' import { isRuntimeOwnedSshTargetId } from '../../../shared/execution-host' @@ -433,6 +431,11 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { ) const previousBrowserPageIdRef = useRef(null) const previousBrowserFocusTargetRef = useRef<'webview' | 'address-bar'>('webview') + // Why: the exact element focused before Cmd+J opened (e.g. the terminal + // textarea the user was typing in) so Escape restores it precisely instead + // of the first matching surface in the DOM, which may be a background + // worktree's mounted-but-hidden terminal. + const previousFocusElementRef = useRef(null) const activeGroupSnapshotRef = useRef(null) const wasVisibleRef = useRef(false) const skipRestoreFocusRef = useRef(false) @@ -1177,6 +1180,13 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { document.activeElement.closest('[data-orca-browser-address-bar="true"]') ? 'address-bar' : 'webview' + // Why: same timing constraint — capture the pre-dialog focus target now + // so Escape can return focus to the exact input the user left (excluding + // document.body, which isn't a meaningful restore target). + previousFocusElementRef.current = + document.activeElement instanceof HTMLElement && document.activeElement !== document.body + ? document.activeElement + : null skipRestoreFocusRef.current = false setQuery('') setSelectedItemId('') @@ -1243,24 +1253,19 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { useEffect(() => cancelFallbackFocusFrames, [cancelFallbackFocusFrames]) - const focusFallbackSurface = useCallback(() => { - cancelFallbackFocusFrames() - fallbackFocusOuterFrameRef.current = requestAnimationFrame(() => { - fallbackFocusOuterFrameRef.current = null - fallbackFocusInnerFrameRef.current = requestAnimationFrame(() => { - fallbackFocusInnerFrameRef.current = null - const xterm = document.querySelector('.xterm-helper-textarea') as HTMLElement | null - if (xterm) { - xterm.focus() - return - } - const monaco = document.querySelector('.monaco-editor textarea') as HTMLElement | null - if (monaco) { - monaco.focus() - } + const focusFallbackSurface = useCallback( + (preferredTarget?: HTMLElement | null) => { + cancelFallbackFocusFrames() + fallbackFocusOuterFrameRef.current = requestAnimationFrame(() => { + fallbackFocusOuterFrameRef.current = null + fallbackFocusInnerFrameRef.current = requestAnimationFrame(() => { + fallbackFocusInnerFrameRef.current = null + resolvePaletteFocusRestoreTarget(preferredTarget ?? null)?.focus({ preventScroll: true }) + }) }) - }) - }, [cancelFallbackFocusFrames]) + }, + [cancelFallbackFocusFrames] + ) const requestBrowserFocus = useCallback( (detail: { pageId: string; target: 'webview' | 'address-bar' }) => { @@ -1294,7 +1299,10 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { return } if (previousWorktreeIdRef.current) { - focusFallbackSurface() + // Why: dismissing Cmd+J should return to whatever the user was doing — + // restore the exact previously-focused surface (e.g. the terminal they + // were typing in) rather than an arbitrary first match. + focusFallbackSurface(previousFocusElementRef.current) } }, [closeModal, focusFallbackSurface, requestBrowserFocus] @@ -1535,13 +1543,11 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { // Case 1: user pasted a GH issue/PR URL. if (ghLink) { - const { slug, number } = ghLink + const { number } = ghLink const state = useAppStore.getState() // Why: the existing-worktree check only needs the issue/PR number, which - // is repo-agnostic on the worktree meta side. We don't currently cache a - // repo-slug map, so slug-matching against a specific repo happens - // implicitly when we pick a repo for the `gh workItem` lookup below. + // is repo-agnostic on the worktree meta side. const matches = allWorktrees.filter( (w) => !w.isArchived && (w.linkedIssue === number || w.linkedPR === number) ) @@ -1553,73 +1559,21 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { return } - // Resolve via gh.workItem: prefer the active repo, else the first eligible. + // Why: hand the raw URL to the composer's name field so it runs the same + // cross-project detection as Cmd+N — surfacing the "Switch project?" + // dialog when the URL targets a different project. Pre-resolving here + // against an arbitrary repo silently linked cross-project items to the + // wrong project and skipped that prompt. Seed the active repo so the + // field compares the URL against the project the user is currently in. const eligibleRepos = state.repos.filter((r) => isGitRepoKind(r)) const repoForLookup = (state.activeRepoId && eligibleRepos.find((r) => r.id === state.activeRepoId)) || eligibleRepos[0] - if (!repoForLookup) { - openComposer({ prefilledName: trimmed }) - return - } - - prefetchCreateWorkspaceBaseForComposer(repoForLookup.id) - const sourceContext = buildTaskSourceContextFromRepo({ - provider: 'github', - projectId: repoForLookup.id, - repo: repoForLookup - }) - // Why: awaiting inside the user gesture would leave the palette open - // indefinitely on slow networks. Close immediately and populate the - // composer once the lookup returns. - const lookupToken = createLookupGuard.start() - preserveCreateLookupOnCloseRef.current = true - recordFeatureInteraction('cmd-j-create-workspace') - closeModal() - void lookupGitHubWorkItemByOwnerRepoForSource({ - repoPath: repoForLookup.path, - repoId: repoForLookup.id, - sourceContext, - owner: slug.owner, - repo: slug.repo, - number, - type: ghLink.type - }) - .then((item) => { - if (!createLookupGuard.isCurrent(lookupToken)) { - return - } - const data: Record = { initialRepoId: repoForLookup.id } - if (item) { - const linkedWorkItem: LinkedWorkItemSummary = { - type: item.type, - number: item.number, - title: item.title, - url: item.url - } - data.linkedWorkItem = linkedWorkItem - data.prefilledName = - getLinkedWorkItemWorkspaceName(linkedWorkItem)?.seedName ?? - getLinkedWorkItemSuggestedName({ title: item.title }) - } else { - // Fallback: we couldn't resolve the URL, just seed the name. - data.prefilledName = `${slug.owner}-${slug.repo}-${number}` - } - queueMicrotask(() => - openModal('new-workspace-composer', { ...data, telemetrySource: 'command_palette' }) - ) - }) - .catch(() => { - if (!createLookupGuard.isCurrent(lookupToken)) { - return - } - queueMicrotask(() => - openModal('new-workspace-composer', { - initialRepoId: repoForLookup.id, - telemetrySource: 'command_palette' - }) - ) - }) + openComposer( + repoForLookup + ? { prefilledName: trimmed, initialRepoId: repoForLookup.id } + : { prefilledName: trimmed } + ) return } diff --git a/src/renderer/src/components/cmd-j/palette-focus-restore-target.test.ts b/src/renderer/src/components/cmd-j/palette-focus-restore-target.test.ts new file mode 100644 index 00000000000..76b4f28eb77 --- /dev/null +++ b/src/renderer/src/components/cmd-j/palette-focus-restore-target.test.ts @@ -0,0 +1,60 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it } from 'vitest' +import { resolvePaletteFocusRestoreTarget } from './palette-focus-restore-target' + +afterEach(() => { + document.body.innerHTML = '' +}) + +function addTerminal(id: string): HTMLTextAreaElement { + const textarea = document.createElement('textarea') + textarea.className = 'xterm-helper-textarea' + textarea.dataset.terminal = id + document.body.appendChild(textarea) + return textarea +} + +describe('resolvePaletteFocusRestoreTarget', () => { + it('returns the exact previously-focused element when it is still connected', () => { + // Two terminals mounted (e.g. a background worktree comes first in the DOM); + // the user was typing in the second one before opening Cmd+J. + addTerminal('background') + const active = addTerminal('active') + + expect(resolvePaletteFocusRestoreTarget(active)).toBe(active) + }) + + it('falls back to the first terminal when the previous element is gone', () => { + const first = addTerminal('first') + const detached = document.createElement('textarea') + detached.className = 'xterm-helper-textarea' + // Never appended → not connected (e.g. its pane unmounted while Cmd+J was open). + + expect(detached.isConnected).toBe(false) + expect(resolvePaletteFocusRestoreTarget(detached)).toBe(first) + }) + + it('falls back to the editor textarea when no preferred target and no terminal exist', () => { + const editor = document.createElement('div') + editor.className = 'monaco-editor' + const textarea = document.createElement('textarea') + editor.appendChild(textarea) + document.body.appendChild(editor) + + expect(resolvePaletteFocusRestoreTarget(null)).toBe(textarea) + }) + + it('prefers the terminal over the editor when both are present and nothing was captured', () => { + const terminal = addTerminal('only') + const editor = document.createElement('div') + editor.className = 'monaco-editor' + editor.appendChild(document.createElement('textarea')) + document.body.appendChild(editor) + + expect(resolvePaletteFocusRestoreTarget(null)).toBe(terminal) + }) + + it('returns null when nothing is focusable', () => { + expect(resolvePaletteFocusRestoreTarget(null)).toBeNull() + }) +}) diff --git a/src/renderer/src/components/cmd-j/palette-focus-restore-target.ts b/src/renderer/src/components/cmd-j/palette-focus-restore-target.ts new file mode 100644 index 00000000000..3d739d8b022 --- /dev/null +++ b/src/renderer/src/components/cmd-j/palette-focus-restore-target.ts @@ -0,0 +1,19 @@ +// Why: when Cmd+J closes it must hand focus back to whatever the user was +// doing. Prefer the exact element focused before the palette opened (e.g. the +// specific terminal textarea they were typing in); the querySelector fallbacks +// grab the first match in the DOM, which can be a background worktree's +// mounted-but-hidden terminal rather than the visible one. +export function resolvePaletteFocusRestoreTarget( + preferredTarget: HTMLElement | null, + doc: Document = document +): HTMLElement | null { + if (preferredTarget && preferredTarget.isConnected) { + return preferredTarget + } + const xterm = doc.querySelector('.xterm-helper-textarea') + if (xterm instanceof HTMLElement) { + return xterm + } + const monaco = doc.querySelector('.monaco-editor textarea') + return monaco instanceof HTMLElement ? monaco : null +} diff --git a/src/renderer/src/components/worktree-jump-palette-source-context-boundary.test.ts b/src/renderer/src/components/worktree-jump-palette-source-context-boundary.test.ts index ac60d86d2eb..31bdaf65d5e 100644 --- a/src/renderer/src/components/worktree-jump-palette-source-context-boundary.test.ts +++ b/src/renderer/src/components/worktree-jump-palette-source-context-boundary.test.ts @@ -13,14 +13,21 @@ function sourceBetween(startPattern: string, endPattern: string): string { } describe('WorktreeJumpPalette source-context boundaries', () => { - it('resolves typed GitHub issue/PR entries through the lookup repo source host', () => { - expect(source).toContain('buildTaskSourceContextFromRepo') - + it('defers pasted GitHub URL resolution to the composer so cross-project detection runs', () => { + // Why: pasting a cross-project URL must surface the same "Switch project?" + // prompt as Cmd+N. The palette hands the raw URL to the composer's name + // field instead of pre-resolving it against an arbitrary repo, which + // silently linked cross-project items to the wrong project. const githubLinkSection = sourceBetween( - 'void lookupGitHubWorkItemByOwnerRepoForSource({', + '// Case 1: user pasted a GH issue/PR URL.', '// Case 2: user typed a raw issue number.' ) - expect(githubLinkSection).toContain('sourceContext') + expect(githubLinkSection).toContain('prefilledName: trimmed') + expect(githubLinkSection).not.toContain('lookupGitHubWorkItemByOwnerRepoForSource') + }) + + it('resolves typed raw issue/PR numbers through the lookup repo source host', () => { + expect(source).toContain('buildTaskSourceContextFromRepo') const rawNumberSection = sourceBetween( 'void lookupGitHubWorkItemForSource({', From 891a456b69f4bfa63745b7464ff7c05cbb697b95 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:07:49 -0700 Subject: [PATCH 06/38] fix(sidebar): don't scroll to an unfocused worktree when pinning/unpinning it (#8930) Co-authored-by: Orca --- .../src/components/sidebar/WorktreeList.tsx | 18 +---- .../src/store/slices/worktrees.test.ts | 77 +++++++++++++++++-- src/renderer/src/store/slices/worktrees.ts | 18 ++++- src/shared/workspace-scope.ts | 16 ++++ 4 files changed, 102 insertions(+), 27 deletions(-) diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 73726d09ae0..6c3dcad2831 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -269,7 +269,7 @@ import { buildSidebarHostOptions } from './sidebar-host-options' import { HostSectionHeaderMenu } from './HostSectionHeaderMenu' import { ProjectHeaderActions } from './ProjectHeaderActions' import { translate } from '@/i18n/i18n' -import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../shared/workspace-scope' +import { folderWorkspaceKey, getActiveSidebarWorkspaceId } from '../../../../shared/workspace-scope' import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides' import { isConfirmedStaleFolderPathStatus, @@ -407,22 +407,6 @@ function getWorktreeOptionId(rowKey: string): string { return `worktree-list-option-${encodeURIComponent(rowKey)}` } -// Why: folder workspaces are tracked by the scoped active key, while older -// worktree-only paths still read activeWorktreeId. -function getActiveSidebarWorkspaceId( - activeWorkspaceKey: string | null, - activeWorktreeId: string | null -): string | null { - const scope = activeWorkspaceKey ? parseWorkspaceKey(activeWorkspaceKey) : null - if (scope?.type === 'folder') { - return folderWorkspaceKey(scope.folderWorkspaceId) - } - if (scope?.type === 'worktree') { - return scope.worktreeId - } - return activeWorktreeId -} - function getMountedWorktreeOptions(worktreeId: string, root?: ParentNode | null): HTMLElement[] { const scope = root ?? document const result: HTMLElement[] = [] diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index e43a5700cca..3b148562442 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -6686,12 +6686,13 @@ describe('setWorktreesPinnedAndReveal', () => { resetRemoteRuntimeMocks() }) - it('pins a worktree and reveals it so the viewport follows it into the Pinned section', () => { + it('pins the focused worktree and reveals it so the viewport follows it into the Pinned section', () => { const store = createTestStore() const wt = makeWorktree({ id: 'repo1::/a', repoId: 'repo1', path: '/a', isPinned: false }) const reveal = vi.fn() store.setState({ worktreesByRepo: { repo1: [wt] }, + activeWorktreeId: wt.id, revealWorktreeInSidebar: reveal } as Partial) @@ -6701,12 +6702,13 @@ describe('setWorktreesPinnedAndReveal', () => { expect(reveal).toHaveBeenCalledWith(wt.id, { behavior: 'smooth', highlight: true }) }) - it('reveals on unpin so the viewport follows the row back to its status group', () => { + it('reveals on unpin of the focused worktree so the viewport follows it back to its status group', () => { const store = createTestStore() const wt = makeWorktree({ id: 'repo1::/a', repoId: 'repo1', path: '/a', isPinned: true }) const reveal = vi.fn() store.setState({ worktreesByRepo: { repo1: [wt] }, + activeWorktreeId: wt.id, revealWorktreeInSidebar: reveal } as Partial) @@ -6716,6 +6718,41 @@ describe('setWorktreesPinnedAndReveal', () => { expect(reveal).toHaveBeenCalledWith(wt.id, { behavior: 'smooth', highlight: true }) }) + it('does not scroll when unpinning an unfocused worktree, but still unpins it', () => { + const store = createTestStore() + const focused = makeWorktree({ id: 'repo1::/a', repoId: 'repo1', path: '/a', isPinned: false }) + const pinned = makeWorktree({ id: 'repo1::/b', repoId: 'repo1', path: '/b', isPinned: true }) + const reveal = vi.fn() + store.setState({ + worktreesByRepo: { repo1: [focused, pinned] }, + activeWorktreeId: focused.id, + revealWorktreeInSidebar: reveal + } as Partial) + + store.getState().setWorktreesPinnedAndReveal([pinned.id], false) + + // The row unpins, but the viewport stays put because it isn't focused. + expect(store.getState().worktreesByRepo.repo1[1].isPinned).toBe(false) + expect(reveal).not.toHaveBeenCalled() + }) + + it('does not scroll when pinning an unfocused worktree, but still pins it', () => { + const store = createTestStore() + const focused = makeWorktree({ id: 'repo1::/a', repoId: 'repo1', path: '/a', isPinned: false }) + const other = makeWorktree({ id: 'repo1::/b', repoId: 'repo1', path: '/b', isPinned: false }) + const reveal = vi.fn() + store.setState({ + worktreesByRepo: { repo1: [focused, other] }, + activeWorktreeId: focused.id, + revealWorktreeInSidebar: reveal + } as Partial) + + store.getState().setWorktreesPinnedAndReveal([other.id], true) + + expect(store.getState().worktreesByRepo.repo1[1].isPinned).toBe(true) + expect(reveal).not.toHaveBeenCalled() + }) + it('skips a no-op toggle without requesting a reveal', () => { const store = createTestStore() const wt = makeWorktree({ id: 'repo1::/a', repoId: 'repo1', path: '/a', isPinned: true }) @@ -6759,7 +6796,7 @@ describe('setWorktreesPinnedAndReveal', () => { expect(reveal).not.toHaveBeenCalled() }) - it('reveals only the first newly-pinned worktree when pinning several at once', () => { + it('pins several at once and reveals the focused row even when it is not first', () => { const store = createTestStore() const alreadyPinned = makeWorktree({ id: 'repo1::/a', @@ -6768,23 +6805,49 @@ describe('setWorktreesPinnedAndReveal', () => { isPinned: true }) const first = makeWorktree({ id: 'repo1::/b', repoId: 'repo1', path: '/b', isPinned: false }) - const second = makeWorktree({ id: 'repo1::/c', repoId: 'repo1', path: '/c', isPinned: false }) + const focused = makeWorktree({ id: 'repo1::/c', repoId: 'repo1', path: '/c', isPinned: false }) const reveal = vi.fn() store.setState({ - worktreesByRepo: { repo1: [alreadyPinned, first, second] }, + worktreesByRepo: { repo1: [alreadyPinned, first, focused] }, + activeWorktreeId: focused.id, revealWorktreeInSidebar: reveal } as Partial) - store.getState().setWorktreesPinnedAndReveal([alreadyPinned.id, first.id, second.id], true) + store.getState().setWorktreesPinnedAndReveal([alreadyPinned.id, first.id, focused.id], true) + // Only the focused row is revealed, not the first-changed one. expect(reveal).toHaveBeenCalledTimes(1) - expect(reveal).toHaveBeenCalledWith(first.id, { behavior: 'smooth', highlight: true }) + expect(reveal).toHaveBeenCalledWith(focused.id, { behavior: 'smooth', highlight: true }) // Every targeted row is pinned, not just the revealed one, and the // already-pinned row is left untouched. expect(store.getState().worktreesByRepo.repo1[0].isPinned).toBe(true) expect(store.getState().worktreesByRepo.repo1[1].isPinned).toBe(true) expect(store.getState().worktreesByRepo.repo1[2].isPinned).toBe(true) }) + + it('pins several at once without scrolling when none of them are focused', () => { + const store = createTestStore() + const first = makeWorktree({ id: 'repo1::/b', repoId: 'repo1', path: '/b', isPinned: false }) + const second = makeWorktree({ id: 'repo1::/c', repoId: 'repo1', path: '/c', isPinned: false }) + const elsewhere = makeWorktree({ + id: 'repo1::/z', + repoId: 'repo1', + path: '/z', + isPinned: false + }) + const reveal = vi.fn() + store.setState({ + worktreesByRepo: { repo1: [first, second, elsewhere] }, + activeWorktreeId: elsewhere.id, + revealWorktreeInSidebar: reveal + } as Partial) + + store.getState().setWorktreesPinnedAndReveal([first.id, second.id], true) + + expect(reveal).not.toHaveBeenCalled() + expect(store.getState().worktreesByRepo.repo1[0].isPinned).toBe(true) + expect(store.getState().worktreesByRepo.repo1[1].isPinned).toBe(true) + }) }) describe('migrateWorktreeIdentity', () => { diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index aca7efcd33f..5541ad0ef92 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -73,6 +73,7 @@ import { import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { folderWorkspaceKey, + getActiveSidebarWorkspaceId, isWorkspaceKey, parseWorkspaceKey, worktreeWorkspaceKey @@ -4128,33 +4129,44 @@ export const createWorktreeSlice: StateCreator }, setWorktreesPinnedAndReveal: (worktreeIds, isPinned) => { + // Only follow a toggled row with the viewport when it's the focused + // worktree; pinning/unpinning an unfocused card shouldn't yank the user's + // scroll to a row they aren't looking at. + const activeSidebarWorktreeId = getActiveSidebarWorkspaceId( + get().activeWorkspaceKey, + get().activeWorktreeId + ) // Skip worktrees already in the target state so a no-op toggle doesn't // scroll the viewport away from where the user is. const updates = new Map>() + let didChange = false let revealWorktreeId: string | null = null for (const worktreeId of worktreeIds) { const current = get().getKnownWorktreeById(worktreeId) if (!current || current.isPinned === isPinned) { continue } + didChange = true const workspaceScope = parseWorkspaceKey(worktreeId) if (workspaceScope?.type === 'folder') { void get().updateWorktreeMeta(worktreeId, { isPinned }) } else { updates.set(worktreeId, { isPinned }) } - if (revealWorktreeId === null) { + if (revealWorktreeId === null && worktreeId === activeSidebarWorktreeId) { revealWorktreeId = worktreeId } } - if (revealWorktreeId === null) { + if (!didChange) { return } // updateWorktreesMeta applies its store update synchronously (only the // persistence is async), so the reveal below resolves against a render // where the shortcut row already exists. void get().updateWorktreesMeta(updates) - get().revealWorktreeInSidebar(revealWorktreeId, { behavior: 'smooth', highlight: true }) + if (revealWorktreeId !== null) { + get().revealWorktreeInSidebar(revealWorktreeId, { behavior: 'smooth', highlight: true }) + } }, markWorktreeUnread: (worktreeId) => { diff --git a/src/shared/workspace-scope.ts b/src/shared/workspace-scope.ts index 28965722671..6aed11b6e2d 100644 --- a/src/shared/workspace-scope.ts +++ b/src/shared/workspace-scope.ts @@ -29,3 +29,19 @@ export function parseWorkspaceKey(value: string): WorkspaceScope | null { export function isWorkspaceKey(value: string): value is WorkspaceKey { return parseWorkspaceKey(value) !== null } + +// Why: folder workspaces are tracked by the scoped active key, while older +// worktree-only paths still read activeWorktreeId. +export function getActiveSidebarWorkspaceId( + activeWorkspaceKey: string | null, + activeWorktreeId: string | null +): string | null { + const scope = activeWorkspaceKey ? parseWorkspaceKey(activeWorkspaceKey) : null + if (scope?.type === 'folder') { + return folderWorkspaceKey(scope.folderWorkspaceId) + } + if (scope?.type === 'worktree') { + return scope.worktreeId + } + return activeWorktreeId +} From f1d2fe5c65226e415848164088df724bdc1125f3 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:09:14 -0700 Subject: [PATCH 07/38] perf(git-status): duty-cycle, cancel, and cache status polling to cut idle git load (#8922) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(git-status): batch, cancel, and cache git status polling to cut idl - Add a single duty-cycled refresh scheduler (activity debounce + 60s safety timer) replacing multiple overlapping intervals, so status polling no longer runs near-continuously on large repos (#7983). - Let safety refreshes reuse cached numstat line counts instead of re-running diff --numstat every cycle, invalidated by head change, known mutations, and a bounded TTL. - Thread AbortSignal/request-token cancellation through IPC, RPC, and relay layers so a superseded or backgrounded git:status call is killed instead of finishing wastefully. - Fix automatic upstream/status apply ordering so a slow, older refresh can no longer clobber a newer result, and so an earlier refresh still applies when a later one fails. * Fix aborted git status scans being mistaken for completed empty results - An aborted scan/numstat pass now always rejects instead of silently resolving, so a cancelled request can't look like a valid empty status. - Stop clearing the line-stats cache key on abort, since an aborted pass never wrote to it — clearing was evicting a concurrent scan's healthy snapshot and forcing a redundant numstat recompute. * Fix aborted git status scans resolving as completed results Cancelled scans could still resolve with partial or stale data instead of rejecting, letting callers treat an aborted refresh as a valid status. Also stop counting aborted scan duration toward catch-up refresh pacing, which was stretching the next refresh interval by the full length of a cancelled (often slow) scan. * Add cancellable, generation-aware git status polling to cut stale scans - Route git.status through an abortable subscription per requestToken so cancelStatus can actually abort the remote scan instead of being a no-op, preventing wasted work and stale responses overwriting fresher state. - Bump the git status polling generation on push-target changes so an in-flight refresh against the old remote/branch can't apply stale upstream data to the new one. - Guard the stale-conflict poller against writes after unmount. - Retire pre-purge line-stat scans in the cache so an older in-flight scan can't repopulate a key after a token-scoped purge. --- src/main/git/status.test.ts | 110 ++++++ src/main/git/status.ts | 86 +++- src/main/ipc/filesystem.test.ts | 75 +++- src/main/ipc/filesystem.ts | 63 +-- .../ipc/sender-scoped-request-cancellation.ts | 50 +++ .../providers/git-provider-status-options.ts | 7 + src/main/providers/ssh-git-provider.test.ts | 16 + src/main/providers/ssh-git-provider.ts | 4 +- src/main/providers/types.ts | 7 +- src/main/runtime/rpc/methods/git-params.ts | 3 +- src/main/runtime/rpc/methods/git.test.ts | 22 ++ src/main/runtime/rpc/methods/git.ts | 10 +- src/preload/api-types.ts | 3 + src/preload/index.ts | 4 + src/relay/git-handler-ops.ts | 8 +- src/relay/git-handler-status-ops.test.ts | 47 +++ src/relay/git-handler-status-ops.ts | 82 +++- src/relay/git-handler.ts | 8 +- .../markup/useMarkupPointerHandlers.ts | 13 +- .../right-sidebar/coalesced-poll-runner.ts | 18 +- .../git-status-refresh-ordering.ts | 100 +++++ .../git-status-refresh-scheduler.test.ts | 274 +++++++++++++ .../git-status-refresh-scheduler.ts | 214 ++++++++++ .../right-sidebar/git-status-refresh.test.ts | 91 +++++ .../right-sidebar/git-status-refresh.ts | 114 +++--- .../stale-conflict-operation-poll.ts | 121 ++++++ .../useGitStatusPolling.rerender.test.ts | 143 ++++++- .../right-sidebar/useGitStatusPolling.test.ts | 24 +- .../right-sidebar/useGitStatusPolling.ts | 371 +++++++++--------- .../abortable-runtime-environment-call.ts | 65 +++ .../src/runtime/runtime-git-client.test.ts | 90 +++++ .../src/runtime/runtime-git-client.ts | 74 +++- .../src/runtime/runtime-rpc-client.test.ts | 50 ++- .../src/runtime/runtime-rpc-client.ts | 55 +-- src/renderer/src/web/web-preload-api.ts | 59 ++- ...b-runtime-client-file-watch-replay.test.ts | 158 ++++++++ .../src/web/web-runtime-client.test.ts | 182 ++------- src/renderer/src/web/web-runtime-client.ts | 32 +- .../git-status-line-stats-cache.test.ts | 202 ++++++++++ src/shared/git-status-line-stats-cache.ts | 256 ++++++++++++ src/shared/git-uncommitted-line-stats.test.ts | 9 + src/shared/git-uncommitted-line-stats.ts | 18 +- 42 files changed, 2761 insertions(+), 577 deletions(-) create mode 100644 src/main/ipc/sender-scoped-request-cancellation.ts create mode 100644 src/main/providers/git-provider-status-options.ts create mode 100644 src/renderer/src/components/right-sidebar/git-status-refresh-ordering.ts create mode 100644 src/renderer/src/components/right-sidebar/git-status-refresh-scheduler.test.ts create mode 100644 src/renderer/src/components/right-sidebar/git-status-refresh-scheduler.ts create mode 100644 src/renderer/src/components/right-sidebar/stale-conflict-operation-poll.ts create mode 100644 src/renderer/src/runtime/abortable-runtime-environment-call.ts create mode 100644 src/shared/git-status-line-stats-cache.test.ts create mode 100644 src/shared/git-status-line-stats-cache.ts diff --git a/src/main/git/status.test.ts b/src/main/git/status.test.ts index 3f7cbfd9f3a..8af536a31a6 100644 --- a/src/main/git/status.test.ts +++ b/src/main/git/status.test.ts @@ -1414,6 +1414,116 @@ describe('getStatus', () => { ]) }) + it('reuses unchanged line stats only when the safety hint is present', async () => { + readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') + existsSyncMock.mockReturnValue(false) + gitExecFileAsyncMock.mockImplementation((args: string[]) => { + if (args.includes('status')) { + return Promise.resolve({ + stdout: + '# branch.oid head-1\n' + '1 .M N... 100644 100644 100644 aaaa aaaa src/unstaged.ts\n' + }) + } + if (args.includes('--numstat')) { + return Promise.resolve({ stdout: '3\t4\tsrc/unstaged.ts\n' }) + } + return Promise.resolve({ stdout: '' }) + }) + + await getStatus('/repo') + const reused = await getStatus('/repo', { reuseLineStats: true }) + await getStatus('/repo') + + expect(reused.entries).toEqual([ + { path: 'src/unstaged.ts', status: 'modified', area: 'unstaged', added: 3, removed: 4 } + ]) + expect( + gitExecFileAsyncMock.mock.calls.filter(([args]) => args.includes('--numstat')) + ).toHaveLength(2) + }) + + it('recomputes after a scan whose numstat failed instead of pinning missing counts', async () => { + readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') + existsSyncMock.mockReturnValue(false) + let failNumstat = true + gitExecFileAsyncMock.mockImplementation((args: string[]) => { + if (args.includes('status')) { + return Promise.resolve({ + stdout: + '# branch.oid head-1\n' + '1 .M N... 100644 100644 100644 aaaa aaaa src/unstaged.ts\n' + }) + } + if (args.includes('--numstat')) { + if (failNumstat) { + failNumstat = false + return Promise.reject(new Error('transient index.lock')) + } + return Promise.resolve({ stdout: '3\t4\tsrc/unstaged.ts\n' }) + } + return Promise.resolve({ stdout: '' }) + }) + + const failed = await getStatus('/repo') + const reused = await getStatus('/repo', { reuseLineStats: true }) + + expect(failed.entries[0]?.added).toBeUndefined() + expect(reused.entries).toEqual([ + { path: 'src/unstaged.ts', status: 'modified', area: 'unstaged', added: 3, removed: 4 } + ]) + }) + + it('invalidates safety reuse for a new head and for known mutations', async () => { + readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') + existsSyncMock.mockReturnValue(false) + let head = 'head-1' + gitExecFileAsyncMock.mockImplementation((args: string[]) => { + if (args.includes('status')) { + return Promise.resolve({ + stdout: + `# branch.oid ${head}\n` + '1 .M N... 100644 100644 100644 aaaa aaaa src/unstaged.ts\n' + }) + } + if (args.includes('--numstat')) { + return Promise.resolve({ stdout: '3\t4\tsrc/unstaged.ts\n' }) + } + return Promise.resolve({ stdout: '' }) + }) + + await getStatus('/repo') + head = 'head-2' + await getStatus('/repo', { reuseLineStats: true }) + await stageFile('/repo', 'src/unstaged.ts') + await getStatus('/repo', { reuseLineStats: true }) + + expect( + gitExecFileAsyncMock.mock.calls.filter(([args]) => args.includes('--numstat')) + ).toHaveLength(3) + }) + + it('isolates line-stat reuse between WSL distributions', async () => { + readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') + existsSyncMock.mockReturnValue(false) + gitExecFileAsyncMock.mockImplementation((args: string[]) => { + if (args.includes('status')) { + return Promise.resolve({ + stdout: '1 .M N... 100644 100644 100644 aaaa aaaa src/unstaged.ts\n' + }) + } + if (args.includes('--numstat')) { + return Promise.resolve({ stdout: '3\t4\tsrc/unstaged.ts\n' }) + } + return Promise.resolve({ stdout: '' }) + }) + + await getStatus('/repo', { wslDistro: 'ubuntu' }) + await getStatus('/repo', { wslDistro: 'debian', reuseLineStats: true }) + await getStatus('/repo', { wslDistro: 'ubuntu', reuseLineStats: true }) + + expect( + gitExecFileAsyncMock.mock.calls.filter(([args]) => args.includes('--numstat')) + ).toHaveLength(2) + }) + it('attaches numstat counts for literal paths containing rename markers', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') existsSyncMock.mockReturnValue(false) diff --git a/src/main/git/status.ts b/src/main/git/status.ts index ddbdd1b2ca4..a0c103b744c 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -51,6 +51,12 @@ import { InFlightPromiseDedupe, stableInFlightKey } from '../../shared/in-flight import type { GitRuntimeOptions } from './git-runtime-options' import { gitOptionsForWorktree } from './git-runtime-options' import { parseGitRevListFirstParentOid } from '../../shared/git-rev-list-output' +import { + beginGitStatusLineStatsCacheWrite, + clearGitStatusLineStatsCache, + clearGitStatusLineStatsCacheKey, + reuseOrRecomputeGitStatusLineStats +} from '../../shared/git-status-line-stats-cache' const MAX_GIT_SHOW_BYTES = 10 * 1024 * 1024 const MAX_STAGED_COMMIT_CONTEXT_BYTES = MAX_GIT_SHOW_BYTES @@ -97,6 +103,7 @@ const statusReadsInFlight = new Map>() export function invalidateGitReadCaches(): void { gitDiffReadDedupe.clear() statusReadsInFlight.clear() + clearGitStatusLineStatsCache() clearSubmodulePathsCache() resolvedUpstreamNameCache.clear() } @@ -195,6 +202,7 @@ export function getEffectiveUpstreamStatusGenerationCountForTests(): number { export type GetStatusOptions = GitRuntimeOptions & { includeIgnored?: boolean + reuseLineStats?: boolean /** * Max changed-file entries before git is stopped and the result is marked * `didHitLimit`. Defaults to DEFAULT_GIT_STATUS_LIMIT; 0 disables the cap. @@ -243,6 +251,7 @@ function getStatusReadKey(worktreePath: string, options: GetStatusOptions): stri worktreePath, options.wslDistro ?? '', options.includeIgnored === true, + options.reuseLineStats === true, options.bypassEffectiveUpstreamNegativeCache === true, limit ].join('\0') @@ -252,6 +261,8 @@ async function runGetStatus( worktreePath: string, options: GetStatusOptions = {} ): Promise { + const lineStatsCacheKey = getStatusLineStatsCacheKey(worktreePath, options) + const lineStatsWriteToken = beginGitStatusLineStatsCacheWrite(lineStatsCacheKey) let effectiveUpstreamStatus: GitUpstreamStatus | undefined let statusSucceeded = false // Why: a negative/fractional/NaN limit would trigger spurious early-stop or @@ -303,7 +314,12 @@ async function runGetStatus( } didHitLimit = stoppedEarly statusSucceeded = true - } catch { + } catch (error) { + // Why: an aborted scan must reject, not resolve — swallowing here would let + // a cancelled request be mistaken for a completed (empty) status result. + if (options.signal?.aborted) { + throw error + } // Not a git repo or git not available } @@ -334,11 +350,17 @@ async function runGetStatus( options ) try { + // Why: the probe promise and its name/negative caches are shared by + // concurrent status reads, so one caller's abort must not reject the + // shared probe or evict warm cache state for the others. The probe is + // small and its cached result stays useful, so run it unbound from + // this request's signal. + const { signal: _requestSignal, ...sharedProbeOptions } = options effectiveUpstreamStatus = await readOrProbeEffectiveUpstreamStatus( cacheKey, worktreePath, branchName, - options, + sharedProbeOptions, options.bypassEffectiveUpstreamNegativeCache === true ) } catch { @@ -355,7 +377,25 @@ async function runGetStatus( // running numstat over a huge change set would reintroduce the cost the limit // exists to avoid, matching how a "huge" repo disables extra git features. if (!didHitLimit) { - await attachLineStats(worktreePath, entries, options) + await reuseOrRecomputeGitStatusLineStats({ + cacheKey: lineStatsCacheKey, + head, + entries, + writeToken: lineStatsWriteToken, + reuse: options.reuseLineStats === true, + isAborted: () => options.signal?.aborted === true, + recompute: () => attachLineStats(worktreePath, entries, options) + }) + } else { + clearGitStatusLineStatsCacheKey(lineStatsCacheKey, lineStatsWriteToken) + } + + // Why: abort after the stream (e.g. during unmerged/upstream/line-stats work) + // must still reject — never resolve a cancelled scan as a completed result. + if (options.signal?.aborted) { + const error = new Error('The operation was aborted.') + error.name = 'AbortError' + throw error } return { @@ -382,6 +422,12 @@ async function runGetStatus( } } +function getStatusLineStatsCacheKey(worktreePath: string, options: GitRuntimeOptions = {}): string { + // Why: identical path strings can address different Linux filesystems in + // different WSL distros, so derived stats must follow Git's execution host. + return `${options.wslDistro ?? 'native'}\0${worktreePath}` +} + /** * Resolve a submodule's own worktree path from a parent worktree + relative * submodule path, rejecting anything that escapes the parent. Shared by the @@ -505,7 +551,7 @@ async function runNumstat( worktreePath: string, cached: boolean, options: GitRuntimeOptions = {} -): Promise> { +): Promise | null> { try { const { stdout } = await gitExecFileAsync( [ @@ -520,20 +566,28 @@ async function runNumstat( { ...gitOptionsForWorktree(worktreePath, options), env: gitOptionalLocksDisabledEnv() } ) return parseNumstat(stdout) - } catch { + } catch (error) { + // Why: an aborted pass must reject so a cancelled scan is never treated as + // a completed one; only a genuine (non-abort) numstat failure degrades to + // uncounted rows below. + if (options.signal?.aborted) { + throw error + } // Why: a numstat failure (e.g. transient lock) should leave rows without - // counts rather than break the whole status refresh. - return new Map() + // counts rather than break the whole status refresh. Null (vs an empty + // map) tells the caller the pass is incomplete and must not be cached. + return null } } +/** Returns false when a numstat pass failed, so callers skip caching it. */ async function attachLineStats( worktreePath: string, entries: GitStatusEntry[], options: GitRuntimeOptions = {} -): Promise { +): Promise { if (entries.length === 0) { - return + return true } const hasStaged = entries.some((entry) => entry.area === 'staged') const hasUnstaged = entries.some((entry) => entry.area === 'unstaged') @@ -544,18 +598,19 @@ async function attachLineStats( const [stagedStats, unstagedStats, untrackedStats] = await Promise.all([ hasStaged ? runNumstat(worktreePath, true, options) : Promise.resolve(emptyStats), hasUnstaged ? runNumstat(worktreePath, false, options) : Promise.resolve(emptyStats), - collectUntrackedAdditions(worktreePath, untrackedPaths) + collectUntrackedAdditions(worktreePath, untrackedPaths, options.signal) ]) for (const entry of entries) { applyLineStats( entry, entry.area === 'staged' - ? stagedStats.get(entry.path) + ? (stagedStats ?? emptyStats).get(entry.path) : entry.area === 'unstaged' - ? unstagedStats.get(entry.path) + ? (unstagedStats ?? emptyStats).get(entry.path) : untrackedStats.get(entry.path) ) } + return stagedStats !== null && unstagedStats !== null } function getShortBranchName(branch: string | undefined): string | null { @@ -753,7 +808,12 @@ async function probeOrRevalidateEffectiveUpstreamStatus( cached.upstreamName ) return { status, probedSameNameOriginRef: false } - } catch { + } catch (error) { + // Why: an aborted probe says nothing about the ref; evicting the warm + // name cache here would force a pointless full re-resolve next scan. + if (options.signal?.aborted) { + throw error + } // Ref deleted or repo state changed — fall through to a full re-resolve. resolvedUpstreamNameCache.delete(cacheKey) } diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index 2e9d1843988..c1c319c55f1 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -1148,6 +1148,72 @@ describe('registerFilesystemHandlers', () => { }) }) + it('forwards line-stat reuse through local and SSH git status IPC', async () => { + registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH]) + getStatusMock.mockResolvedValue({ entries: [], conflictOperation: 'unknown' }) + const sshProvider = { + getStatus: vi.fn().mockResolvedValue({ entries: [], conflictOperation: 'unknown' }) + } + getSshGitProviderMock.mockReturnValue(sshProvider) + registerFilesystemHandlers(store as never) + + await handlers.get('git:status')!(null, { + worktreePath: WORKTREE_FEATURE_PATH, + reuseLineStats: true + }) + await handlers.get('git:status')!(null, { + worktreePath: '/remote/repo', + connectionId: 'ssh-1', + reuseLineStats: true + }) + + expect(getStatusMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, { + includeIgnored: false, + reuseLineStats: true + }) + expect(sshProvider.getStatus).toHaveBeenCalledWith('/remote/repo', { + includeIgnored: false, + reuseLineStats: true + }) + }) + + it('aborts tokenized local status without crossing renderer boundaries', async () => { + registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH]) + const statusSignals: AbortSignal[] = [] + getStatusMock.mockImplementation( + (_worktreePath: string, options: { signal?: AbortSignal }) => + new Promise((_resolve, reject) => { + if (options.signal) { + statusSignals.push(options.signal) + options.signal.addEventListener('abort', () => reject(new Error('aborted')), { + once: true + }) + } + }) + ) + registerFilesystemHandlers(store as never) + + const firstEvent = { sender: { id: 7 } } + const secondEvent = { sender: { id: 8 } } + const firstRequest = handlers.get('git:status')!(firstEvent, { + worktreePath: WORKTREE_FEATURE_PATH, + requestToken: 'status-1' + }) as Promise + const secondRequest = handlers.get('git:status')!(secondEvent, { + worktreePath: WORKTREE_FEATURE_PATH, + requestToken: 'status-1' + }) as Promise + await vi.waitFor(() => expect(statusSignals).toHaveLength(2)) + await handlers.get('git:cancelStatus')!(firstEvent, { requestToken: 'status-1' }) + + expect(statusSignals[0]?.aborted).toBe(true) + expect(statusSignals[1]?.aborted).toBe(false) + await expect(firstRequest).rejects.toThrow('aborted') + + await handlers.get('git:cancelStatus')!(secondEvent, { requestToken: 'status-1' }) + await expect(secondRequest).rejects.toThrow('aborted') + }) + it('checks ignored paths through local and SSH git providers', async () => { registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH]) checkIgnoredPathsMock.mockResolvedValue(['dist/bundle.js']) @@ -2364,20 +2430,23 @@ describe('registerFilesystemHandlers', () => { registerFilesystemHandlers(store as never) - const pending = handlers.get('fs:listFiles')!(null, { + // Why: cancellation keys are scoped to the issuing webContents, so the + // cancel must come from the same sender as the listing request. + const senderEvent = { sender: { id: 7 } } + const pending = handlers.get('fs:listFiles')!(senderEvent, { rootPath: '/home/user/repo', connectionId: 'conn-1', requestToken: 'token-1' }) as Promise expect(capturedSignal?.aborted).toBe(false) - await handlers.get('fs:cancelListFiles')!(null, { requestToken: 'token-1' }) + await handlers.get('fs:cancelListFiles')!(senderEvent, { requestToken: 'token-1' }) expect(capturedSignal?.aborted).toBe(true) await expect(pending).rejects.toThrow('listing cancelled') // Unknown or already-settled tokens are a no-op, not an error. expect(() => - handlers.get('fs:cancelListFiles')!(null, { requestToken: 'unknown' }) + handlers.get('fs:cancelListFiles')!(senderEvent, { requestToken: 'unknown' }) ).not.toThrow() }) }) diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 3918f8d3ea4..931c28c401f 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -122,6 +122,7 @@ import { getRuntimePathBasename } from '../../shared/cross-platform-path' import type { LocalProjectWorktreeGitOptions } from '../project-runtime-git-options' import { registerLocalLogTailHandlers } from './local-log-tail' import { localLogFileIdentity } from '../ai-vault/local-log-tail-reader' +import { createSenderScopedRequestCancellations } from './sender-scoped-request-cancellation' // Why: Monaco has large-file optimizations like VS Code; blocking at 5MB makes // ordinary JSON/log files inaccessible before the editor can degrade features. @@ -1072,11 +1073,11 @@ export function registerFilesystemHandlers( // Why #7721: keyed by renderer-generated token so a workspace switch can // abort the previous workspace's full-tree scan (SSH relays otherwise stack // scans that starve interactive fs.readDir/fs.stat past their 30s timeout). - const listFilesCancellations = new Map() + const listFilesCancellations = createSenderScopedRequestCancellations() ipcMain.handle( 'fs:listFiles', async ( - _event, + event, args: { rootPath: string connectionId?: string @@ -1084,10 +1085,7 @@ export function registerFilesystemHandlers( requestToken?: string } ): Promise => { - const controller = args.requestToken ? new AbortController() : null - if (controller && args.requestToken) { - listFilesCancellations.set(args.requestToken, controller) - } + const controller = listFilesCancellations.begin(event, args.requestToken) try { if (args.connectionId) { const provider = getSshFilesystemProvider(args.connectionId) @@ -1108,53 +1106,66 @@ export function registerFilesystemHandlers( } return await listQuickOpenFiles(args.rootPath, store, args.excludePaths, controller?.signal) } finally { - if (args.requestToken) { - listFilesCancellations.delete(args.requestToken) - } + listFilesCancellations.finish(event, args.requestToken, controller) } } ) - ipcMain.handle('fs:cancelListFiles', (_event, args: { requestToken: string }): void => { - // Why: best-effort — the entry is gone once the listing settles. - listFilesCancellations.get(args.requestToken)?.abort() + ipcMain.handle('fs:cancelListFiles', (event, args: { requestToken: string }): void => { + listFilesCancellations.cancel(event, args.requestToken) }) // ─── Git operations ───────────────────────────────────── + const gitStatusCancellations = createSenderScopedRequestCancellations() ipcMain.handle( 'git:status', async ( - _event, + event, args: { worktreePath: string connectionId?: string includeIgnored?: boolean bypassEffectiveUpstreamNegativeCache?: boolean + reuseLineStats?: boolean + requestToken?: string } ): Promise => { + const controller = gitStatusCancellations.begin(event, args.requestToken) const options = { includeIgnored: args.includeIgnored ?? false, + ...(args.reuseLineStats === true ? { reuseLineStats: true } : {}), ...(args.bypassEffectiveUpstreamNegativeCache === true ? { bypassEffectiveUpstreamNegativeCache: true } - : {}) + : {}), + ...(controller ? { signal: controller.signal } : {}) } - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + try { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + // Why: awaiting here keeps the cancellation token registered until + // the remote request settles instead of running finally immediately. + return await provider.getStatus(args.worktreePath, options) } - return provider.getStatus(args.worktreePath, options) + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return await getStatus(worktreePath, { ...options, ...gitOptions }) + } finally { + gitStatusCancellations.finish(event, args.requestToken, controller) } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - return getStatus(worktreePath, { ...options, ...gitOptions }) } ) + ipcMain.handle('git:cancelStatus', (event, args: { requestToken: string }): void => { + gitStatusCancellations.cancel(event, args.requestToken) + }) + // Why: the parent status only reports one gitlink row per submodule. When the // user expands a dirty submodule, this fetches the inner per-file changes by // running a plain status inside the submodule's own worktree (read-only). diff --git a/src/main/ipc/sender-scoped-request-cancellation.ts b/src/main/ipc/sender-scoped-request-cancellation.ts new file mode 100644 index 00000000000..9700be9c3da --- /dev/null +++ b/src/main/ipc/sender-scoped-request-cancellation.ts @@ -0,0 +1,50 @@ +import type { IpcMainInvokeEvent } from 'electron' + +export type SenderScopedRequestCancellations = { + /** Registers a cancellable request; aborts any previous request that reused the token. */ + begin: (event: IpcMainInvokeEvent, requestToken: string | undefined) => AbortController | null + /** Removes the registration once the request settles (no-op if it was replaced). */ + finish: ( + event: IpcMainInvokeEvent, + requestToken: string | undefined, + controller: AbortController | null + ) => void + /** Best-effort abort from the issuing webContents; a settled request is gone. */ + cancel: (event: IpcMainInvokeEvent, requestToken: string) => void +} + +/** + * Registry for renderer-cancellable IPC requests. Keys are scoped to the + * issuing webContents so one window's token can never cancel another window's + * request, and reusing a token aborts the previous request before the new one + * registers. + */ +export function createSenderScopedRequestCancellations(): SenderScopedRequestCancellations { + const controllers = new Map() + const keyFor = (event: IpcMainInvokeEvent, requestToken: string): string => + `${event.sender.id}\0${requestToken}` + return { + begin: (event, requestToken) => { + if (!requestToken) { + return null + } + const key = keyFor(event, requestToken) + controllers.get(key)?.abort() + const controller = new AbortController() + controllers.set(key, controller) + return controller + }, + finish: (event, requestToken, controller) => { + if (!requestToken || !controller) { + return + } + const key = keyFor(event, requestToken) + if (controllers.get(key) === controller) { + controllers.delete(key) + } + }, + cancel: (event, requestToken) => { + controllers.get(keyFor(event, requestToken))?.abort() + } + } +} diff --git a/src/main/providers/git-provider-status-options.ts b/src/main/providers/git-provider-status-options.ts new file mode 100644 index 00000000000..3d03ca6a269 --- /dev/null +++ b/src/main/providers/git-provider-status-options.ts @@ -0,0 +1,7 @@ +// Kept separate so the central cross-provider contract stays within its enforced size limit. +export type GitProviderStatusOptions = { + includeIgnored?: boolean + bypassEffectiveUpstreamNegativeCache?: boolean + reuseLineStats?: boolean + signal?: AbortSignal +} diff --git a/src/main/providers/ssh-git-provider.test.ts b/src/main/providers/ssh-git-provider.test.ts index eb8383a3f60..d4a275a89ba 100644 --- a/src/main/providers/ssh-git-provider.test.ts +++ b/src/main/providers/ssh-git-provider.test.ts @@ -97,6 +97,22 @@ describe('SshGitProvider', () => { }) }) + it('getStatus forwards line-stat reuse and cancellation to the relay', async () => { + const controller = new AbortController() + mux.request.mockResolvedValue({ entries: [], conflictOperation: 'unknown' }) + + await provider.getStatus('/home/user/repo', { + reuseLineStats: true, + signal: controller.signal + }) + + expect(mux.request).toHaveBeenCalledWith( + 'git.status', + { worktreePath: '/home/user/repo', reuseLineStats: true }, + { signal: controller.signal } + ) + }) + it('getSubmoduleStatus sends git.submoduleStatus request', async () => { const statusResult = { entries: [], conflictOperation: 'unknown' } mux.request.mockResolvedValue(statusResult) diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts index 4bca707a0fb..1aa1cd271a2 100644 --- a/src/main/providers/ssh-git-provider.ts +++ b/src/main/providers/ssh-git-provider.ts @@ -106,10 +106,12 @@ export class SshGitProvider implements IGitProvider { const upstreamCacheBypassArgs = options?.bypassEffectiveUpstreamNegativeCache ? { bypassEffectiveUpstreamNegativeCache: true } : {} + const lineStatsReuseArgs = options?.reuseLineStats ? { reuseLineStats: true } : {} const request = { worktreePath, ...includeIgnoredArgs, - ...upstreamCacheBypassArgs + ...upstreamCacheBypassArgs, + ...lineStatsReuseArgs } return (await (options?.signal ? this.mux.request('git.status', request, { signal: options.signal }) diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index dc38e108daa..fa694433616 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -23,6 +23,7 @@ import type { WorkspaceSpaceDirectoryScanResult } from '../../shared/workspace-s import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' import type { TerminalGitHubPRLink } from '../../shared/terminal-github-pr-link-detector' +import type { GitProviderStatusOptions } from './git-provider-status-options' // ─── PTY Provider ─────────────────────────────────────────────────── @@ -316,11 +317,7 @@ export type TerminalArtifactAccessOptions = { // ─── Git Provider ─────────────────────────────────────────────────── -export type GitProviderStatusOptions = { - includeIgnored?: boolean - bypassEffectiveUpstreamNegativeCache?: boolean - signal?: AbortSignal -} +export type { GitProviderStatusOptions } from './git-provider-status-options' export type IGitProvider = { getStatus(worktreePath: string, options?: GitProviderStatusOptions): Promise diff --git a/src/main/runtime/rpc/methods/git-params.ts b/src/main/runtime/rpc/methods/git-params.ts index 8f9d511dfca..7e13e584c42 100644 --- a/src/main/runtime/rpc/methods/git-params.ts +++ b/src/main/runtime/rpc/methods/git-params.ts @@ -9,7 +9,8 @@ export const WorktreeSelector = z.object({ export const GitStatusParams = WorktreeSelector.extend({ includeIgnored: z.boolean().optional(), - bypassEffectiveUpstreamNegativeCache: z.boolean().optional() + bypassEffectiveUpstreamNegativeCache: z.boolean().optional(), + reuseLineStats: z.boolean().optional() }) export const GitCheckIgnored = WorktreeSelector.extend({ diff --git a/src/main/runtime/rpc/methods/git.test.ts b/src/main/runtime/rpc/methods/git.test.ts index bb69dfc5958..1655bb0b3d5 100644 --- a/src/main/runtime/rpc/methods/git.test.ts +++ b/src/main/runtime/rpc/methods/git.test.ts @@ -80,6 +80,28 @@ describe('git RPC methods', () => { }) }) + it('forwards line-stat reuse and request cancellation for status requests', async () => { + const controller = new AbortController() + const runtime = { + getRuntimeId: () => 'test-runtime', + getRuntimeGitStatus: vi.fn().mockResolvedValue({ + entries: [], + conflictOperation: 'unknown' + }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS }) + + await dispatcher.dispatch( + makeRequest('git.status', { worktree: 'id:wt-1', reuseLineStats: true }), + { signal: controller.signal } + ) + + expect(runtime.getRuntimeGitStatus).toHaveBeenCalledWith('id:wt-1', { + reuseLineStats: true, + signal: controller.signal + }) + }) + it('returns ignored paths for selected explorer rows', async () => { const runtime = { getRuntimeId: () => 'test-runtime', diff --git a/src/main/runtime/rpc/methods/git.ts b/src/main/runtime/rpc/methods/git.ts index 1e4923f672a..c4ccdf071b6 100644 --- a/src/main/runtime/rpc/methods/git.ts +++ b/src/main/runtime/rpc/methods/git.ts @@ -88,10 +88,12 @@ export const GIT_METHODS: RpcMethod[] = [ defineMethod({ name: 'git.status', params: GitStatusParams, - handler: async (params, { runtime }) => { + handler: async (params, { runtime, signal }) => { const options = params.includeIgnored === undefined && - params.bypassEffectiveUpstreamNegativeCache === undefined + params.bypassEffectiveUpstreamNegativeCache === undefined && + params.reuseLineStats === undefined && + signal === undefined ? undefined : { ...(params.includeIgnored === undefined @@ -99,7 +101,9 @@ export const GIT_METHODS: RpcMethod[] = [ : { includeIgnored: params.includeIgnored }), ...(params.bypassEffectiveUpstreamNegativeCache === true ? { bypassEffectiveUpstreamNegativeCache: true } - : {}) + : {}), + ...(params.reuseLineStats === true ? { reuseLineStats: true } : {}), + ...(signal ? { signal } : {}) } return options === undefined ? runtime.getRuntimeGitStatus(params.worktree) diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 0200555af39..8b2e112a775 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -2538,7 +2538,10 @@ export type PreloadApi = { connectionId?: string includeIgnored?: boolean bypassEffectiveUpstreamNegativeCache?: boolean + reuseLineStats?: boolean + requestToken?: string }) => Promise + cancelStatus: (args: { requestToken: string }) => Promise submoduleStatus: (args: { worktreePath: string submodulePath: string diff --git a/src/preload/index.ts b/src/preload/index.ts index 42d02aec1ba..2e7dd1234e7 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2962,7 +2962,11 @@ const api = { connectionId?: string includeIgnored?: boolean bypassEffectiveUpstreamNegativeCache?: boolean + reuseLineStats?: boolean + requestToken?: string }): Promise => ipcRenderer.invoke('git:status', args), + cancelStatus: (args: { requestToken: string }): Promise => + ipcRenderer.invoke('git:cancelStatus', args), submoduleStatus: (args: { worktreePath: string submodulePath: string diff --git a/src/relay/git-handler-ops.ts b/src/relay/git-handler-ops.ts index 0bb862fbcea..395e31badb3 100644 --- a/src/relay/git-handler-ops.ts +++ b/src/relay/git-handler-ops.ts @@ -16,7 +16,13 @@ import { readWorkingDiffFile } from './git-working-file-read' export type GitExec = ( args: string[], cwd: string, - opts?: { maxBuffer?: number; disableOptionalLocks?: boolean; stdin?: string; timeout?: number } + opts?: { + maxBuffer?: number + disableOptionalLocks?: boolean + signal?: AbortSignal + stdin?: string + timeout?: number + } ) => Promise<{ stdout: string; stderr: string }> export type GitBufferExec = (args: string[], cwd: string) => Promise diff --git a/src/relay/git-handler-status-ops.test.ts b/src/relay/git-handler-status-ops.test.ts index 044fa82bf6a..4a7494d0956 100644 --- a/src/relay/git-handler-status-ops.test.ts +++ b/src/relay/git-handler-status-ops.test.ts @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { GitExec } from './git-handler-ops' import { getStatusOp } from './git-handler-status-ops' import { clearNoEffectiveUpstreamStatusCache } from './git-status-upstream-negative-cache' +import { clearGitStatusLineStatsCache } from '../shared/git-status-line-stats-cache' const LARGE_STATUS_ENTRY_COUNT = 150_000 @@ -26,6 +27,7 @@ describe('getStatusOp', () => { beforeEach(() => { clearNoEffectiveUpstreamStatusCache() + clearGitStatusLineStatsCache() tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-git-status-')) }) @@ -79,6 +81,51 @@ describe('getStatusOp', () => { expect(result.entries).toHaveLength(5) }) + it('reuses unchanged line stats only for hinted safety reads', async () => { + const statusOutput = `${buildBranchStatusOutput('head-1', '(detached)')}\n1 .M N... 100644 100644 100644 aaaa aaaa src/a.ts` + const git = vi.fn(async (args) => { + if (args.includes('status')) { + return { stdout: statusOutput, stderr: '' } + } + if (args.includes('diff')) { + return { stdout: '3\t2\tsrc/a.ts\n', stderr: '' } + } + throw new Error(`Unexpected git command: ${args.join(' ')}`) + }) + + await getStatusOp(git, { worktreePath: tmpDir }) + const reused = await getStatusOp(git, { worktreePath: tmpDir, reuseLineStats: true }) + await getStatusOp(git, { worktreePath: tmpDir }) + + expect(reused.entries).toContainEqual( + expect.objectContaining({ path: 'src/a.ts', added: 3, removed: 2 }) + ) + expect(git.mock.calls.filter(([args]) => args.includes('diff'))).toHaveLength(2) + }) + + it('forwards the request abort signal to status and numstat subprocesses', async () => { + const controller = new AbortController() + const git = vi.fn(async (args) => { + if (args.includes('status')) { + return { + stdout: '1 .M N... 100644 100644 100644 aaaa aaaa src/a.ts', + stderr: '' + } + } + if (args.includes('diff')) { + return { stdout: '1\t0\tsrc/a.ts\n', stderr: '' } + } + throw new Error(`Unexpected git command: ${args.join(' ')}`) + }) + + await getStatusOp(git, { worktreePath: tmpDir }, { signal: controller.signal }) + + expect(git.mock.calls).not.toHaveLength(0) + for (const [, , options] of git.mock.calls) { + expect(options?.signal).toBe(controller.signal) + } + }) + it('caches no-effective-upstream probes across status polls for the same head', async () => { const git = vi.fn(async (args) => { if (args.includes('status')) { diff --git a/src/relay/git-handler-status-ops.ts b/src/relay/git-handler-status-ops.ts index 4f48ec9b6da..81d0a4eb83b 100644 --- a/src/relay/git-handler-status-ops.ts +++ b/src/relay/git-handler-status-ops.ts @@ -20,6 +20,11 @@ import { type GitLineStats } from '../shared/git-uncommitted-line-stats' import { DEFAULT_GIT_STATUS_LIMIT } from '../shared/git-status-limit' +import { + beginGitStatusLineStatsCacheWrite, + clearGitStatusLineStatsCacheKey, + reuseOrRecomputeGitStatusLineStats +} from '../shared/git-status-line-stats-cache' export async function resolveGitDir(worktreePath: string): Promise { const dotGitPath = path.join(worktreePath, '.git') @@ -58,7 +63,8 @@ export async function detectConflictOperation(worktreePath: string): Promise + params: Record, + options: { signal?: AbortSignal } = {} ): Promise<{ entries: Record[] conflictOperation: string @@ -70,6 +76,8 @@ export async function getStatusOp( statusLength?: number }> { const worktreePath = params.worktreePath as string + const lineStatsCacheKey = `relay\0${worktreePath}` + const lineStatsWriteToken = beginGitStatusLineStatsCacheWrite(lineStatsCacheKey) const includeIgnored = params.includeIgnored === true // Why: reject non-finite/negative limits so the cap guard stays reliable // (NaN would silently disable capping; negatives would over-truncate). @@ -106,7 +114,8 @@ export async function getStatusOp( const { stdout } = await git(statusArgs, worktreePath, { // Why: status polling is read-like; avoid refreshing the index and racing // terminal Git commands on `.git/worktrees/*/index.lock`. - disableOptionalLocks: true + disableOptionalLocks: true, + signal: options.signal }) const parsed = parseStatusOutput(stdout) head = parsed.head @@ -134,6 +143,9 @@ export async function getStatusOp( const branchName = getShortBranchName(branch) if (branchName) { try { + // Why: this probe coalesces across concurrent status reads, so one + // request's abort must not reject the shared in-flight promise for + // the others; the probe is small and its cached result stays useful. upstreamStatus = await readOrProbeNoEffectiveUpstreamStatus( { worktreePath, branchName, upstreamName: upstreamStatus?.upstreamName }, (args) => git(args, worktreePath), @@ -155,7 +167,12 @@ export async function getStatusOp( } } } - } catch { + } catch (error) { + // Why: an aborted scan must reject, not resolve — swallowing here would let + // a cancelled request be mistaken for a completed (empty) status result. + if (options.signal?.aborted) { + throw error + } // not a git repo or git not available } @@ -165,7 +182,25 @@ export async function getStatusOp( // running numstat over a huge change set would reintroduce the cost the limit // exists to avoid. if (!didHitLimit) { - await attachLineStats(git, worktreePath, entries) + await reuseOrRecomputeGitStatusLineStats({ + cacheKey: lineStatsCacheKey, + head, + entries, + writeToken: lineStatsWriteToken, + reuse: params.reuseLineStats === true, + isAborted: () => options.signal?.aborted === true, + recompute: () => attachLineStats(git, worktreePath, entries, options.signal) + }) + } else { + clearGitStatusLineStatsCacheKey(lineStatsCacheKey, lineStatsWriteToken) + } + + // Why: abort after the porcelain read (e.g. during unmerged/upstream/line-stats + // work) must still reject — never resolve a cancelled scan as completed. + if (options.signal?.aborted) { + const error = new Error('The operation was aborted.') + error.name = 'AbortError' + throw error } return { @@ -182,29 +217,39 @@ export async function getStatusOp( async function runNumstat( git: GitExec, worktreePath: string, - cached: boolean -): Promise> { + cached: boolean, + signal?: AbortSignal +): Promise | null> { try { const { stdout } = await git( ['-c', 'core.quotePath=false', 'diff', ...(cached ? ['--cached'] : []), '--numstat', '-M'], worktreePath, - { disableOptionalLocks: true } + { disableOptionalLocks: true, signal } ) return parseNumstat(stdout) - } catch { + } catch (error) { + // Why: an aborted pass must reject so a cancelled scan is never treated as + // a completed one; only a genuine (non-abort) numstat failure degrades to + // uncounted rows below. + if (signal?.aborted) { + throw error + } // Why: a numstat failure should leave rows without counts rather than break - // the whole status refresh. - return new Map() + // the whole status refresh. Null (vs an empty map) tells the caller the + // pass is incomplete and must not be cached. + return null } } +/** Returns false when a numstat pass failed, so callers skip caching it. */ async function attachLineStats( git: GitExec, worktreePath: string, - entries: Record[] -): Promise { + entries: Record[], + signal?: AbortSignal +): Promise { if (entries.length === 0) { - return + return true } const hasStaged = entries.some((entry) => entry.area === 'staged') const hasUnstaged = entries.some((entry) => entry.area === 'unstaged') @@ -213,21 +258,22 @@ async function attachLineStats( .map((entry) => entry.path as string) const emptyStats = new Map() const [stagedStats, unstagedStats, untrackedStats] = await Promise.all([ - hasStaged ? runNumstat(git, worktreePath, true) : Promise.resolve(emptyStats), - hasUnstaged ? runNumstat(git, worktreePath, false) : Promise.resolve(emptyStats), - collectUntrackedAdditions(worktreePath, untrackedPaths) + hasStaged ? runNumstat(git, worktreePath, true, signal) : Promise.resolve(emptyStats), + hasUnstaged ? runNumstat(git, worktreePath, false, signal) : Promise.resolve(emptyStats), + collectUntrackedAdditions(worktreePath, untrackedPaths, signal) ]) for (const entry of entries) { const filePath = entry.path as string applyLineStats( entry as { added?: number; removed?: number }, entry.area === 'staged' - ? stagedStats.get(filePath) + ? (stagedStats ?? emptyStats).get(filePath) : entry.area === 'unstaged' - ? unstagedStats.get(filePath) + ? (unstagedStats ?? emptyStats).get(filePath) : untrackedStats.get(filePath) ) } + return stagedStats !== null && unstagedStats !== null } function getShortBranchName(branch: string | undefined): string | null { diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index 6872c5f5bbd..66b5eb2198c 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -78,6 +78,7 @@ import { import { GitResponseStreamRegistry } from './git-response-stream' import { GIT_RESPONSE_STREAM_THRESHOLD } from './protocol' import { endSubprocessStdin } from '../shared/subprocess-stdin-write' +import { clearGitStatusLineStatsCache } from '../shared/git-status-line-stats-cache' const execFileAsync = promisify(execFile) const MAX_GIT_BUFFER = 10 * 1024 * 1024 @@ -198,7 +199,7 @@ export class GitHandler { } private registerHandlers(): void { - this.dispatcher.onRequest('git.status', (p) => this.getStatus(p)) + this.dispatcher.onRequest('git.status', (p, context) => this.getStatus(p, context)) this.dispatcher.onRequest('git.submoduleStatus', (p) => this.getSubmoduleStatus(p)) this.dispatcher.onRequest('git.checkIgnored', (p) => this.checkIgnored(p)) this.dispatcher.onRequest('git.history', (p) => this.history(p)) @@ -287,6 +288,7 @@ export class GitHandler { private clearGitMutationReadCaches(): void { this.gitDiffReadDedupe.clear() + clearGitStatusLineStatsCache() clearSubmodulePathsCache(this.submodulePathsCache) } @@ -342,9 +344,9 @@ export class GitHandler { return stdout } - private async getStatus(params: Record) { + private async getStatus(params: Record, context: RequestContext) { this.gitDiffReadDedupe.clear() - return getStatusOp(this.git.bind(this), params) + return getStatusOp(this.git.bind(this), params, { signal: context.signal }) } // Why: the parent status only lists a single gitlink row per submodule. The diff --git a/src/renderer/src/components/browser-pane/markup/useMarkupPointerHandlers.ts b/src/renderer/src/components/browser-pane/markup/useMarkupPointerHandlers.ts index 4c6257155a1..6ca25b3cabf 100644 --- a/src/renderer/src/components/browser-pane/markup/useMarkupPointerHandlers.ts +++ b/src/renderer/src/components/browser-pane/markup/useMarkupPointerHandlers.ts @@ -25,8 +25,17 @@ export type MarkupPointerParams = { // 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 { + busy, + tool, + color, + width, + pendingText, + canvasRef, + setInProgress, + setPendingText, + setDoc + } = params const pointFromEvent = useCallback( (event: { clientX: number; clientY: number }): MarkupPoint => { diff --git a/src/renderer/src/components/right-sidebar/coalesced-poll-runner.ts b/src/renderer/src/components/right-sidebar/coalesced-poll-runner.ts index 1040ed279e2..5c2c443997f 100644 --- a/src/renderer/src/components/right-sidebar/coalesced-poll-runner.ts +++ b/src/renderer/src/components/right-sidebar/coalesced-poll-runner.ts @@ -25,6 +25,17 @@ export type SlowTaskBackoffOptions = { // so a fixed idle gap makes polling effectively continuous (#7983). Scale the // gap with the previous run's duration — aggressively for evidence-free timer // ticks, mildly for change signals — capped so results never go minutes-stale. +// Shared with the git-status refresh scheduler so both pacing machines keep +// the same guarantee: sustained triggers can never run the task back-to-back. +export function slowTaskRequiredIdleMs( + lastRunDurationMs: number, + multiplier: number, + minIntervalMs: number, + maxIntervalMs: number +): number { + return Math.max(minIntervalMs, Math.min(lastRunDurationMs * multiplier, maxIntervalMs)) +} + export function createCoalescedPollRunner( task: () => Promise, options?: { @@ -49,7 +60,12 @@ export function createCoalescedPollRunner( const multiplier = trigger?.changeSignal ? backoff.changeSignalMultiplier : backoff.idleMultiplier - return Math.max(minIntervalMs, Math.min(lastRunDurationMs * multiplier, backoff.maxIntervalMs)) + return slowTaskRequiredIdleMs( + lastRunDurationMs, + multiplier, + minIntervalMs, + backoff.maxIntervalMs + ) } const clearScheduledRun = (): void => { diff --git a/src/renderer/src/components/right-sidebar/git-status-refresh-ordering.ts b/src/renderer/src/components/right-sidebar/git-status-refresh-ordering.ts new file mode 100644 index 00000000000..9f4c45e0c17 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/git-status-refresh-ordering.ts @@ -0,0 +1,100 @@ +const MAX_REFRESH_ORDERING_WORKTREES = 1024 +const strictUpstreamRefreshGenerationByWorktree = new Map() +const automaticRefreshGenerationByWorktree = new Map() +// Why: automatic ordering is latest-APPLIED-wins, not latest-started-wins. A +// later-started refresh that fails (or never applies) must not veto an earlier +// refresh's good result, so the fence only advances when a result is applied. +const lastAppliedAutomaticGenerationByWorktree = new Map() +const automaticUpstreamRefreshInFlightByWorktree = new Map() + +export type AutomaticRefreshOrder = { + strictGeneration: number + automaticGeneration: number +} + +function trimRefreshOrderingState(): void { + for (const worktreeId of strictUpstreamRefreshGenerationByWorktree.keys()) { + if (strictUpstreamRefreshGenerationByWorktree.size <= MAX_REFRESH_ORDERING_WORKTREES) { + break + } + if (automaticUpstreamRefreshInFlightByWorktree.has(worktreeId)) { + continue + } + strictUpstreamRefreshGenerationByWorktree.delete(worktreeId) + } +} + +export function beginAutomaticUpstreamRefresh(worktreeId: string): AutomaticRefreshOrder { + automaticUpstreamRefreshInFlightByWorktree.set( + worktreeId, + (automaticUpstreamRefreshInFlightByWorktree.get(worktreeId) ?? 0) + 1 + ) + const automaticGeneration = (automaticRefreshGenerationByWorktree.get(worktreeId) ?? 0) + 1 + automaticRefreshGenerationByWorktree.set(worktreeId, automaticGeneration) + return { + strictGeneration: strictUpstreamRefreshGenerationByWorktree.get(worktreeId) ?? 0, + automaticGeneration + } +} + +export function finishAutomaticUpstreamRefresh(worktreeId: string): void { + const count = automaticUpstreamRefreshInFlightByWorktree.get(worktreeId) ?? 0 + if (count <= 1) { + automaticUpstreamRefreshInFlightByWorktree.delete(worktreeId) + automaticRefreshGenerationByWorktree.delete(worktreeId) + lastAppliedAutomaticGenerationByWorktree.delete(worktreeId) + } else { + automaticUpstreamRefreshInFlightByWorktree.set(worktreeId, count - 1) + } + trimRefreshOrderingState() +} + +export function shouldApplyAutomaticUpstreamRefresh( + worktreeId: string, + order: AutomaticRefreshOrder, + shouldApply?: () => boolean +): boolean { + return ( + (strictUpstreamRefreshGenerationByWorktree.get(worktreeId) ?? 0) === order.strictGeneration && + order.automaticGeneration >= (lastAppliedAutomaticGenerationByWorktree.get(worktreeId) ?? 0) && + (shouldApply?.() ?? true) + ) +} + +/** + * Check-and-claim variant used immediately before applying state. On success + * it advances the applied fence so an older concurrent refresh that settles + * later cannot overwrite this refresh's newer result. + */ +export function claimAutomaticUpstreamRefreshApply( + worktreeId: string, + order: AutomaticRefreshOrder, + shouldApply?: () => boolean +): boolean { + if (!shouldApplyAutomaticUpstreamRefresh(worktreeId, order, shouldApply)) { + return false + } + lastAppliedAutomaticGenerationByWorktree.set( + worktreeId, + Math.max( + lastAppliedAutomaticGenerationByWorktree.get(worktreeId) ?? 0, + order.automaticGeneration + ) + ) + return true +} + +export function beginStrictUpstreamRefresh(worktreeId: string): void { + strictUpstreamRefreshGenerationByWorktree.set( + worktreeId, + (strictUpstreamRefreshGenerationByWorktree.get(worktreeId) ?? 0) + 1 + ) + trimRefreshOrderingState() +} + +export function clearGitStatusRefreshOrderingStateForTests(): void { + strictUpstreamRefreshGenerationByWorktree.clear() + automaticRefreshGenerationByWorktree.clear() + lastAppliedAutomaticGenerationByWorktree.clear() + automaticUpstreamRefreshInFlightByWorktree.clear() +} diff --git a/src/renderer/src/components/right-sidebar/git-status-refresh-scheduler.test.ts b/src/renderer/src/components/right-sidebar/git-status-refresh-scheduler.test.ts new file mode 100644 index 00000000000..8421e4b56d6 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/git-status-refresh-scheduler.test.ts @@ -0,0 +1,274 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + createGitStatusRefreshScheduler, + type GitStatusRefreshReason +} from './git-status-refresh-scheduler' + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve = (): void => {} + const promise = new Promise((settle) => { + resolve = settle + }) + return { promise, resolve } +} + +async function flushMicrotasks(): Promise { + for (let index = 0; index < 5; index += 1) { + await Promise.resolve() + } +} + +function createScheduler( + task: (request: { reason: GitStatusRefreshReason; signal: AbortSignal }) => Promise +) { + return createGitStatusRefreshScheduler(task, { + safetyIntervalMs: 60_000, + activityDebounceMs: 125, + activityMinGapMs: 3000, + slowTaskBackoff: { + idleMultiplier: 5, + changeSignalMultiplier: 1, + maxIntervalMs: 5 * 60_000 + } + }) +} + +describe('createGitStatusRefreshScheduler', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('refreshes immediately on resume and uses one 60-second safety timeout', async () => { + vi.useFakeTimers() + const reasons: GitStatusRefreshReason[] = [] + const task = vi.fn(async ({ reason }: { reason: GitStatusRefreshReason }) => { + reasons.push(reason) + }) + const scheduler = createScheduler(task) + + scheduler.resumeSafety() + expect(task).toHaveBeenCalledTimes(1) + await flushMicrotasks() + + await vi.advanceTimersByTimeAsync(59_999) + expect(task).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + + expect(task).toHaveBeenCalledTimes(2) + expect(reasons).toEqual(['activity', 'safety']) + }) + + it('coalesces activity signals and restarts the safety horizon', async () => { + vi.useFakeTimers() + const task = vi.fn(async () => {}) + const scheduler = createScheduler(task) + + scheduler.resumeSafety() + await flushMicrotasks() + await vi.advanceTimersByTimeAsync(59_000) + + scheduler.signal() + scheduler.signal() + scheduler.signal() + await vi.advanceTimersByTimeAsync(124) + expect(task).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + expect(task).toHaveBeenCalledTimes(2) + await flushMicrotasks() + + await vi.advanceTimersByTimeAsync(59_999) + expect(task).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(1) + expect(task).toHaveBeenCalledTimes(3) + }) + + it('serializes in-flight work and retains one trailing activity refresh', async () => { + vi.useFakeTimers() + const calls: ReturnType[] = [] + let active = 0 + let maxActive = 0 + const task = vi.fn(() => { + const call = deferred() + calls.push(call) + active += 1 + maxActive = Math.max(maxActive, active) + return call.promise.finally(() => { + active -= 1 + }) + }) + const scheduler = createScheduler(task) + + scheduler.resumeSafety() + scheduler.signal() + scheduler.signal() + scheduler.refreshNow() + expect(task).toHaveBeenCalledTimes(1) + + calls[0]?.resolve() + await flushMicrotasks() + // Why: the trailing refresh respects the 3s activity floor from run end. + expect(task).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(3000) + expect(task).toHaveBeenCalledTimes(2) + expect(maxActive).toBe(1) + + calls[1]?.resolve() + await flushMicrotasks() + await vi.advanceTimersByTimeAsync(60_000) + expect(task).toHaveBeenCalledTimes(3) + }) + + it('spaces sustained signal bursts by the activity floor instead of running back-to-back', async () => { + vi.useFakeTimers() + const calls: ReturnType[] = [] + const task = vi.fn(() => { + const call = deferred() + calls.push(call) + return call.promise + }) + const scheduler = createScheduler(task) + + scheduler.resumeSafety() + scheduler.signal() + calls[0]?.resolve() + await flushMicrotasks() + expect(task).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(2999) + expect(task).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + expect(task).toHaveBeenCalledTimes(2) + + // A signal mid-flight plus immediate settle still waits out the floor. + scheduler.signal() + calls[1]?.resolve() + await flushMicrotasks() + expect(task).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(3000) + expect(task).toHaveBeenCalledTimes(3) + }) + + it('stretches activity and safety pacing after a slow scan', async () => { + vi.useFakeTimers() + const calls: ReturnType[] = [] + const task = vi.fn(() => { + const call = deferred() + calls.push(call) + return call.promise + }) + const scheduler = createScheduler(task) + + scheduler.resumeSafety() + // The first scan takes 30 seconds; a signal arrives while it is running. + scheduler.signal() + await vi.advanceTimersByTimeAsync(30_000) + calls[0]?.resolve() + await flushMicrotasks() + expect(task).toHaveBeenCalledTimes(1) + + // Trailing activity waits max(3s floor, 1x scan duration) = 30s. + await vi.advanceTimersByTimeAsync(29_999) + expect(task).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + expect(task).toHaveBeenCalledTimes(2) + + // The second scan is also slow; the next safety waits max(60s, 5x) = 150s. + await vi.advanceTimersByTimeAsync(30_000) + calls[1]?.resolve() + await flushMicrotasks() + await vi.advanceTimersByTimeAsync(149_999) + expect(task).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(1) + expect(task).toHaveBeenCalledTimes(3) + }) + + it('aborts on pause and catches up after the obsolete request settles', async () => { + vi.useFakeTimers() + const calls: ReturnType[] = [] + const signals: AbortSignal[] = [] + const task = vi.fn(({ signal }: { signal: AbortSignal }) => { + const call = deferred() + calls.push(call) + signals.push(signal) + return call.promise + }) + const scheduler = createScheduler(task) + + scheduler.resumeSafety() + scheduler.pause() + expect(signals[0]?.aborted).toBe(true) + + scheduler.resumeSafety() + expect(task).toHaveBeenCalledTimes(1) + calls[0]?.resolve() + await flushMicrotasks() + + // Why: the reveal catch-up rides the paced activity lane (3s floor). + await vi.advanceTimersByTimeAsync(3000) + expect(task).toHaveBeenCalledTimes(2) + expect(signals[1]?.aborted).toBe(false) + calls[1]?.resolve() + await flushMicrotasks() + }) + + it('does not stretch catch-up pacing with an aborted slow scan duration', async () => { + vi.useFakeTimers() + const calls: ReturnType[] = [] + const task = vi.fn(() => { + const call = deferred() + calls.push(call) + return call.promise + }) + const scheduler = createScheduler(task) + + scheduler.resumeSafety() + // A long scan is aborted by pause (window hide). Its wall time must not + // become the next activity idle gap, or reveal catch-up waits tens of + // seconds for work that never applied. + await vi.advanceTimersByTimeAsync(30_000) + scheduler.pause() + scheduler.resumeSafety() + calls[0]?.resolve() + await flushMicrotasks() + + await vi.advanceTimersByTimeAsync(2999) + expect(task).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + expect(task).toHaveBeenCalledTimes(2) + calls[1]?.resolve() + await flushMicrotasks() + }) + + it('keeps huge-status signal mode active without a safety timeout', async () => { + vi.useFakeTimers() + const task = vi.fn(async () => {}) + const scheduler = createScheduler(task) + + scheduler.resumeSafety() + await flushMicrotasks() + scheduler.suspendSafety() + await vi.advanceTimersByTimeAsync(120_000) + expect(task).toHaveBeenCalledTimes(1) + + scheduler.signal() + await vi.advanceTimersByTimeAsync(125) + expect(task).toHaveBeenCalledTimes(2) + await flushMicrotasks() + await vi.advanceTimersByTimeAsync(120_000) + expect(task).toHaveBeenCalledTimes(2) + }) + + it('cleans up debounce and safety timers on dispose', async () => { + vi.useFakeTimers() + const task = vi.fn(async () => {}) + const scheduler = createScheduler(task) + + scheduler.resumeSafety() + await flushMicrotasks() + scheduler.signal() + scheduler.dispose() + await vi.advanceTimersByTimeAsync(120_000) + + expect(task).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/git-status-refresh-scheduler.ts b/src/renderer/src/components/right-sidebar/git-status-refresh-scheduler.ts new file mode 100644 index 00000000000..f33a6d7afc7 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/git-status-refresh-scheduler.ts @@ -0,0 +1,214 @@ +import { slowTaskRequiredIdleMs, type SlowTaskBackoffOptions } from './coalesced-poll-runner' + +export type GitStatusRefreshReason = 'activity' | 'safety' + +export type GitStatusRefreshScheduler = { + resumeSafety: () => void + pause: () => void + suspendSafety: () => void + signal: () => void + refreshNow: () => void + dispose: () => void +} + +type RefreshTask = (request: { + reason: GitStatusRefreshReason + signal: AbortSignal +}) => Promise + +export function createGitStatusRefreshScheduler( + task: RefreshTask, + options: { + safetyIntervalMs: number + activityDebounceMs: number + // Why: sustained signal streams (busy agent terminals, file churn) must + // never run status scans back-to-back (#7983). Activity runs keep this + // minimum idle gap since the previous run's end, scaled up by the slow + // task backoff when the previous scan itself was slow. + activityMinGapMs: number + slowTaskBackoff: SlowTaskBackoffOptions + } +): GitStatusRefreshScheduler { + let disposed = false + let safetyEnabled = false + let inFlight = false + let pendingActivity = false + let activityTimer: ReturnType | null = null + let activityTimerFiresAt = Infinity + let safetyTimer: ReturnType | null = null + let activeController: AbortController | null = null + let lastRunEndedAt = -Infinity + let lastRunDurationMs = 0 + + const clearActivityTimer = (): void => { + if (activityTimer !== null) { + clearTimeout(activityTimer) + activityTimer = null + activityTimerFiresAt = Infinity + } + } + const clearSafetyTimer = (): void => { + if (safetyTimer !== null) { + clearTimeout(safetyTimer) + safetyTimer = null + } + } + + const requiredActivityIdleMs = (): number => + slowTaskRequiredIdleMs( + lastRunDurationMs, + options.slowTaskBackoff.changeSignalMultiplier, + options.activityMinGapMs, + options.slowTaskBackoff.maxIntervalMs + ) + + const scheduleSafetyRun = (): void => { + // Why: slow scans stretch the safety horizon (idle-lane backoff) so a + // tens-of-seconds status scan never approaches a continuous git duty cycle. + const delay = Math.max( + options.safetyIntervalMs, + slowTaskRequiredIdleMs( + lastRunDurationMs, + options.slowTaskBackoff.idleMultiplier, + 0, + options.slowTaskBackoff.maxIntervalMs + ) + ) + safetyTimer = setTimeout(() => { + safetyTimer = null + startRun('safety') + }, delay) + } + + const scheduleActivityRun = (minDelayMs: number): void => { + if (disposed) { + return + } + if (inFlight) { + // Why: the trigger may describe a mutation after the running Git + // snapshot, so one trailing refresh is required for freshness. + pendingActivity = true + return + } + const now = Date.now() + const delay = Math.max(minDelayMs, lastRunEndedAt + requiredActivityIdleMs() - now) + if (delay <= 0) { + startRun('activity') + return + } + const firesAt = now + delay + if (activityTimer !== null) { + // Why: an earlier-eligible trigger may pull a pending run forward, but a + // later debounce window must never push an already-scheduled run back. + if (firesAt >= activityTimerFiresAt) { + return + } + clearActivityTimer() + } + activityTimerFiresAt = firesAt + activityTimer = setTimeout(() => { + activityTimer = null + activityTimerFiresAt = Infinity + startRun('activity') + }, delay) + } + + const startRun = (reason: GitStatusRefreshReason): void => { + if (disposed || inFlight) { + return + } + clearActivityTimer() + clearSafetyTimer() + inFlight = true + const startedAt = Date.now() + const controller = new AbortController() + activeController = controller + let result: Promise + try { + result = task({ reason, signal: controller.signal }) + } catch (error) { + result = Promise.reject(error) + } + void result + .catch(() => { + // Status refresh errors are transient; the next signal or safety run retries. + }) + .finally(() => { + lastRunEndedAt = Date.now() + // Why: cancelled scans never delivered a useful result, so their wall + // time must not stretch the next activity/safety gap. Otherwise hide → + // reveal after a slow abort waits out the aborted scan's full duration + // before the catch-up refresh can start. + lastRunDurationMs = controller.signal.aborted ? 0 : Math.max(0, lastRunEndedAt - startedAt) + if (activeController === controller) { + activeController = null + } + inFlight = false + if (disposed) { + return + } + if (pendingActivity) { + pendingActivity = false + scheduleActivityRun(0) + return + } + if (safetyEnabled) { + scheduleSafetyRun() + } + }) + } + + return { + resumeSafety: () => { + if (disposed) { + return + } + const wasEnabled = safetyEnabled + safetyEnabled = true + if (wasEnabled) { + return + } + clearSafetyTimer() + if (inFlight) { + // Why: a request aborted while hidden cannot serve as reveal catch-up; + // retain one fresh run and wait for the old transport to settle. + pendingActivity ||= activeController?.signal.aborted === true + return + } + scheduleActivityRun(0) + }, + pause: () => { + safetyEnabled = false + pendingActivity = false + clearActivityTimer() + clearSafetyTimer() + activeController?.abort() + }, + suspendSafety: () => { + safetyEnabled = false + clearSafetyTimer() + }, + signal: () => { + if (disposed) { + return + } + clearSafetyTimer() + scheduleActivityRun(options.activityDebounceMs) + }, + refreshNow: () => { + if (disposed) { + return + } + clearSafetyTimer() + scheduleActivityRun(0) + }, + dispose: () => { + disposed = true + safetyEnabled = false + pendingActivity = false + clearActivityTimer() + clearSafetyTimer() + activeController?.abort() + } + } +} diff --git a/src/renderer/src/components/right-sidebar/git-status-refresh.test.ts b/src/renderer/src/components/right-sidebar/git-status-refresh.test.ts index 5fe28efa4be..3d9427ac1a4 100644 --- a/src/renderer/src/components/right-sidebar/git-status-refresh.test.ts +++ b/src/renderer/src/components/right-sidebar/git-status-refresh.test.ts @@ -233,6 +233,97 @@ describe('refreshGitStatusForWorktree', () => { expect(deps.setUpstreamStatus).toHaveBeenCalledWith('wt-race', strictStatus.upstreamStatus) }) + it('does not let an older automatic status overwrite the latest automatic result', async () => { + const olderStatus = deferred() + const latestStatus: GitStatusResult = { + entries: [{ path: 'latest.ts', status: 'modified', area: 'unstaged' }], + conflictOperation: 'unknown', + head: 'latest' + } + const gitStatus = vi + .fn() + .mockReturnValueOnce(olderStatus.promise) + .mockResolvedValueOnce(latestStatus) + vi.stubGlobal('window', { api: { git: { status: gitStatus } } }) + const deps = makeDeps() + + const olderRefresh = refreshGitStatusForWorktree({ + worktreeId: 'wt-automatic-race', + worktreePath: '/repo', + deps + }) + await vi.waitFor(() => expect(gitStatus).toHaveBeenCalledTimes(1)) + await refreshGitStatusForWorktree({ + worktreeId: 'wt-automatic-race', + worktreePath: '/repo', + deps + }) + olderStatus.resolve({ + entries: [{ path: 'older.ts', status: 'modified', area: 'unstaged' }], + conflictOperation: 'unknown', + head: 'older' + }) + await olderRefresh + + expect(deps.setGitStatus).toHaveBeenCalledTimes(1) + expect(deps.setGitStatus).toHaveBeenCalledWith('wt-automatic-race', latestStatus) + }) + + it('applies an earlier automatic result when a later automatic refresh fails', async () => { + const olderStatus = deferred() + const gitStatus = vi + .fn() + .mockReturnValueOnce(olderStatus.promise) + .mockRejectedValueOnce(new Error('transient index.lock')) + vi.stubGlobal('window', { api: { git: { status: gitStatus } } }) + const deps = makeDeps() + + const olderRefresh = refreshGitStatusForWorktree({ + worktreeId: 'wt-failed-veto', + worktreePath: '/repo', + deps + }) + await vi.waitFor(() => expect(gitStatus).toHaveBeenCalledTimes(1)) + await expect( + refreshGitStatusForWorktree({ + worktreeId: 'wt-failed-veto', + worktreePath: '/repo', + deps + }) + ).rejects.toThrow('transient index.lock') + + const freshStatus: GitStatusResult = { + entries: [{ path: 'fresh.ts', status: 'modified', area: 'unstaged' }], + conflictOperation: 'unknown', + head: 'fresh' + } + olderStatus.resolve(freshStatus) + await olderRefresh + + expect(deps.setGitStatus).toHaveBeenCalledWith('wt-failed-veto', freshStatus) + }) + + it('does not apply a status result after its liveness guard expires', async () => { + const status: GitStatusResult = { + entries: [], + conflictOperation: 'unknown', + head: 'stale' + } + vi.stubGlobal('window', { api: { git: { status: vi.fn().mockResolvedValue(status) } } }) + const deps = makeDeps() + + await refreshGitStatusForWorktree({ + worktreeId: 'wt-stale', + worktreePath: '/repo', + deps, + request: { shouldApply: () => false } + }) + + expect(deps.setGitStatus).not.toHaveBeenCalled() + expect(deps.updateWorktreeGitIdentity).not.toHaveBeenCalled() + expect(deps.fetchUpstreamStatus).not.toHaveBeenCalled() + }) + it('does not let an older automatic explicit upstream fetch overwrite a strict result', async () => { const automaticFetch = deferred() const strictStatus: GitStatusResult = { diff --git a/src/renderer/src/components/right-sidebar/git-status-refresh.ts b/src/renderer/src/components/right-sidebar/git-status-refresh.ts index d8dd54e5764..27e8fa30c35 100644 --- a/src/renderer/src/components/right-sidebar/git-status-refresh.ts +++ b/src/renderer/src/components/right-sidebar/git-status-refresh.ts @@ -11,6 +11,15 @@ import type { GitUpstreamStatus, GlobalSettings } from '../../../../shared/types' +import { + beginAutomaticUpstreamRefresh, + beginStrictUpstreamRefresh, + claimAutomaticUpstreamRefreshApply, + clearGitStatusRefreshOrderingStateForTests, + finishAutomaticUpstreamRefresh, + shouldApplyAutomaticUpstreamRefresh, + type AutomaticRefreshOrder +} from './git-status-refresh-ordering' export type GitStatusRefreshDeps = { setGitStatus: (worktreeId: string, status: GitStatusResult) => void @@ -31,44 +40,6 @@ export type GitStatusRefreshDeps = { ) => Promise } -const MAX_REFRESH_ORDERING_WORKTREES = 1024 -const strictUpstreamRefreshGenerationByWorktree = new Map() -const automaticUpstreamRefreshInFlightByWorktree = new Map() - -function trimRefreshOrderingState(): void { - for (const worktreeId of strictUpstreamRefreshGenerationByWorktree.keys()) { - if (strictUpstreamRefreshGenerationByWorktree.size <= MAX_REFRESH_ORDERING_WORKTREES) { - break - } - if (automaticUpstreamRefreshInFlightByWorktree.has(worktreeId)) { - continue - } - strictUpstreamRefreshGenerationByWorktree.delete(worktreeId) - } -} - -function beginAutomaticUpstreamRefresh(worktreeId: string): number { - automaticUpstreamRefreshInFlightByWorktree.set( - worktreeId, - (automaticUpstreamRefreshInFlightByWorktree.get(worktreeId) ?? 0) + 1 - ) - return strictUpstreamRefreshGenerationByWorktree.get(worktreeId) ?? 0 -} - -function finishAutomaticUpstreamRefresh(worktreeId: string): void { - const count = automaticUpstreamRefreshInFlightByWorktree.get(worktreeId) ?? 0 - if (count <= 1) { - automaticUpstreamRefreshInFlightByWorktree.delete(worktreeId) - } else { - automaticUpstreamRefreshInFlightByWorktree.set(worktreeId, count - 1) - } - trimRefreshOrderingState() -} - -function shouldApplyAutomaticUpstreamRefresh(worktreeId: string, startGeneration: number): boolean { - return (strictUpstreamRefreshGenerationByWorktree.get(worktreeId) ?? 0) === startGeneration -} - async function fetchAndApplyAutomaticUpstreamStatus({ settings, worktreeId, @@ -76,7 +47,8 @@ async function fetchAndApplyAutomaticUpstreamStatus({ connectionId, pushTarget, deps, - startGeneration + order, + shouldApply }: { settings?: Pick | null worktreeId: string @@ -84,8 +56,12 @@ async function fetchAndApplyAutomaticUpstreamStatus({ connectionId?: string pushTarget?: GitPushTarget deps: GitStatusRefreshDeps - startGeneration: number + order: AutomaticRefreshOrder + shouldApply?: () => boolean }): Promise { + if (!shouldApplyAutomaticUpstreamRefresh(worktreeId, order, shouldApply)) { + return null + } const upstreamStatus = await deps.fetchUpstreamStatus( worktreeId, worktreePath, @@ -110,24 +86,15 @@ async function fetchAndApplyAutomaticUpstreamStatus({ } return null } - if (!shouldApplyAutomaticUpstreamRefresh(worktreeId, startGeneration)) { + if (!claimAutomaticUpstreamRefreshApply(worktreeId, order, shouldApply)) { return null } deps.setUpstreamStatus(worktreeId, upstreamStatus) return upstreamStatus } -function beginStrictUpstreamRefresh(worktreeId: string): void { - strictUpstreamRefreshGenerationByWorktree.set( - worktreeId, - (strictUpstreamRefreshGenerationByWorktree.get(worktreeId) ?? 0) + 1 - ) - trimRefreshOrderingState() -} - export function clearGitStatusRefreshOrderingForTests(): void { - strictUpstreamRefreshGenerationByWorktree.clear() - automaticUpstreamRefreshInFlightByWorktree.clear() + clearGitStatusRefreshOrderingStateForTests() clearAutomaticPushTargetUpstreamStatusCache() } @@ -137,7 +104,8 @@ export async function refreshGitStatusForWorktree({ worktreePath, connectionId, pushTarget, - deps + deps, + request }: { settings?: Pick | null worktreeId: string @@ -145,17 +113,30 @@ export async function refreshGitStatusForWorktree({ connectionId?: string pushTarget?: GitPushTarget deps: GitStatusRefreshDeps + request?: { + reuseLineStats?: boolean + signal?: AbortSignal + shouldApply?: () => boolean + } }): Promise { - const upstreamStartGeneration = beginAutomaticUpstreamRefresh(worktreeId) + const refreshOrder = beginAutomaticUpstreamRefresh(worktreeId) try { - const status = (await getRuntimeGitStatus({ - settings, - worktreeId, - worktreePath, - connectionId - })) as GitStatusResult + const status = (await getRuntimeGitStatus( + { + settings, + worktreeId, + worktreePath, + connectionId + }, + request + ? { + ...(request.reuseLineStats === true ? { reuseLineStats: true } : {}), + ...(request.signal ? { signal: request.signal } : {}) + } + : undefined + )) as GitStatusResult - if (!shouldApplyAutomaticUpstreamRefresh(worktreeId, upstreamStartGeneration)) { + if (!claimAutomaticUpstreamRefreshApply(worktreeId, refreshOrder, request?.shouldApply)) { return } @@ -192,7 +173,8 @@ export async function refreshGitStatusForWorktree({ connectionId, pushTarget, deps, - startGeneration: upstreamStartGeneration + order: refreshOrder, + shouldApply: request?.shouldApply }) if (upstreamStatus) { // Why: explicit publish-target comparison can spawn several git @@ -219,11 +201,14 @@ export async function refreshGitStatusForWorktree({ worktreePath, connectionId, deps, - startGeneration: upstreamStartGeneration + order: refreshOrder, + shouldApply: request?.shouldApply }) return } - deps.setUpstreamStatus(worktreeId, status.upstreamStatus) + if (claimAutomaticUpstreamRefreshApply(worktreeId, refreshOrder, request?.shouldApply)) { + deps.setUpstreamStatus(worktreeId, status.upstreamStatus) + } return } await fetchAndApplyAutomaticUpstreamStatus({ @@ -233,7 +218,8 @@ export async function refreshGitStatusForWorktree({ connectionId, pushTarget, deps, - startGeneration: upstreamStartGeneration + order: refreshOrder, + shouldApply: request?.shouldApply }) } finally { finishAutomaticUpstreamRefresh(worktreeId) diff --git a/src/renderer/src/components/right-sidebar/stale-conflict-operation-poll.ts b/src/renderer/src/components/right-sidebar/stale-conflict-operation-poll.ts new file mode 100644 index 00000000000..eaf61d70be7 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/stale-conflict-operation-poll.ts @@ -0,0 +1,121 @@ +import { useEffect, useMemo } from 'react' +import type { GitConflictOperation, Repo } from '../../../../shared/types' +import { isGitRepoKind } from '../../../../shared/repo-kind' +import { getConnectionId } from '@/lib/connection-context' +import { getRuntimeGitConflictOperation } from '@/runtime/runtime-git-client' +import { createCoalescedPollRunner, type SlowTaskBackoffOptions } from './coalesced-poll-runner' +import { installWindowVisibilityInterval, isWindowVisible } from '@/lib/window-visibility-interval' +import { getRightSidebarWorktreeRuntimeSettings } from './file-explorer-runtime-owner' + +const CONFLICT_POLL_INTERVAL_MS = 3000 + +/** + * Polls the conflict operation of non-active worktrees whose sidebar badge + * still shows merge/rebase/cherry-pick, so the badge clears when the operation + * finishes. This is a lightweight fs-only check (no git status), so it stays + * cheap even with many worktrees. + */ +export function useStaleConflictOperationPolling(args: { + enabled: boolean + activeWorktreeId: string | null + allWorktrees: { id: string; path: string; repoId: string }[] + repoMap: Map> + conflictOperationByWorktree: Record + setConflictOperation: (worktreeId: string, operation: GitConflictOperation) => void + isConnectionReady: (connectionId: string | null | undefined) => boolean + slowTaskBackoff: SlowTaskBackoffOptions +}): void { + const { + enabled, + activeWorktreeId, + allWorktrees, + repoMap, + conflictOperationByWorktree, + setConflictOperation, + isConnectionReady, + slowTaskBackoff + } = args + // Why: only non-active worktrees with a known conflict operation need this + // probe — the full git status refresh already covers the active worktree. + const staleConflictWorktrees = useMemo(() => { + const result: { id: string; path: string }[] = [] + for (const [worktreeId, op] of Object.entries(conflictOperationByWorktree)) { + if (worktreeId === activeWorktreeId || op === 'unknown') { + continue + } + const worktree = allWorktrees.find((entry) => entry.id === worktreeId) + if (worktree) { + const repo = repoMap.get(worktree.repoId) + if (repo && !isGitRepoKind(repo)) { + continue + } + result.push({ id: worktree.id, path: worktree.path }) + } + } + return result + }, [allWorktrees, conflictOperationByWorktree, activeWorktreeId, repoMap]) + useEffect(() => { + if (!enabled) { + return + } + if (staleConflictWorktrees.length === 0) { + return + } + + // Why: dispose() cannot interrupt an in-flight probe; without this guard a + // request resolving after cleanup would overwrite newer conflict state. + let active = true + + const pollStale = async (): Promise => { + // Why: a backoff-deferred run can fire long after the window hides; skip + // the probe instead of running SSH/RPC work nobody can see. The + // becoming-visible run catches up via the change-signal lane. + if (!isWindowVisible()) { + return + } + for (const { id, path } of staleConflictWorktrees) { + try { + const connectionId = getConnectionId(id) ?? undefined + // Why: after explicit SSH disconnect the provider is intentionally + // gone; keep remote polling quiet until the target reconnects. + if (!isConnectionReady(connectionId)) { + continue + } + const op = (await getRuntimeGitConflictOperation({ + settings: getRightSidebarWorktreeRuntimeSettings(id), + worktreeId: id, + worktreePath: path, + connectionId + })) as GitConflictOperation + if (!active) { + return + } + setConflictOperation(id, op) + } catch { + // ignore — worktree may have been removed + } + } + } + + // Why: remote conflict probes can exceed the 3s interval. Keep one poll in + // flight, coalesce skipped ticks into one trailing pass, and back off after + // slow probe chains so stale badges catch up without stacking SSH/RPC work. + const pollRunner = createCoalescedPollRunner(pollStale, { + slowTaskBackoff + }) + // Why: conflict badges are visible sidebar state; keep them fresh in + // visible unfocused windows, but do not poll disconnected hidden windows. + // The becoming-visible run rides the short-backoff lane so badges catch + // up promptly after a hidden stretch. + const stopVisiblePoll = installWindowVisibilityInterval({ + run: () => pollRunner.run(), + runOnVisible: () => pollRunner.run({ changeSignal: true }), + intervalMs: CONFLICT_POLL_INTERVAL_MS + }) + return () => { + active = false + pollRunner.dispose() + stopVisiblePoll() + } + }, [enabled, staleConflictWorktrees, setConflictOperation, isConnectionReady, slowTaskBackoff]) +} diff --git a/src/renderer/src/components/right-sidebar/useGitStatusPolling.rerender.test.ts b/src/renderer/src/components/right-sidebar/useGitStatusPolling.rerender.test.ts index 1d80a6f8e4a..83595ee7600 100644 --- a/src/renderer/src/components/right-sidebar/useGitStatusPolling.rerender.test.ts +++ b/src/renderer/src/components/right-sidebar/useGitStatusPolling.rerender.test.ts @@ -10,6 +10,8 @@ import { makeOpenFile, makeWorktree, TEST_REPO } from '@/store/slices/store-test import { ORCA_WORKTREE_FILE_CHANGE_EVENT } from '@/hooks/worktree-file-change-event' import { useGitStatusPolling } from './useGitStatusPolling' +globalThis.IS_REACT_ACT_ENVIRONMENT = true + // Mock the refresh boundary so we count invocations precisely without // triggering real IPC cascades (upstream probes, store mutations, etc.). const refreshMock = vi.hoisted(() => vi.fn()) @@ -50,7 +52,7 @@ function HookProbe(): null { return null } -async function renderHook(): Promise { +async function renderHook(): Promise { const container = document.createElement('div') document.body.appendChild(container) const root = createRoot(container) @@ -58,6 +60,7 @@ async function renderHook(): Promise { await act(async () => { root.render(createElement(HookProbe)) }) + return root } async function flushMicrotasks(): Promise { @@ -99,7 +102,7 @@ describe('useGitStatusPolling rerender stability', () => { vi.useRealTimers() }) - it('keeps the poll runner stable when openFiles changes mid-cooldown', async () => { + it('keeps the refresh scheduler stable when openFiles changes mid-signal debounce', async () => { // Spy on addEventListener so we can guard that the file-watch listener // registered before emitting — otherwise the test would prove nothing. const addSpy = vi.spyOn(window, 'addEventListener') @@ -111,10 +114,8 @@ describe('useGitStatusPolling rerender stability', () => { // run() once at install time). expect(refreshMock).toHaveBeenCalledTimes(1) - // Rerender: change openFiles in the store. This gives runFetchStatus a new - // identity (it lists openFiles in its useCallback deps). With the old - // useMemo([runFetchStatus]) the runner would be recreated here, resetting - // lastRunEndedAt to -Infinity and bypassing the cooldown. + // Rerender: change openFiles in the store while the file-watch/scheduler + // debounce is pending. The active-worktree scheduler must survive it. await act(async () => { useAppStore.setState({ openFiles: [makeOpenFile({ id: `${WORKTREE_PATH}/a.ts`, worktreeId: WORKTREE_ID })] @@ -139,18 +140,15 @@ describe('useGitStatusPolling rerender stability', () => { }) ) - // Advance past the 125 ms file-watch debounce. fetchStatus is called, but - // the runner's cooldown (POLL_INTERVAL_MS = 3000 ms from first run end) - // should suppress the second refresh. + // Advance past the 125 ms file-watch debounce. The scheduler coalesces the + // signal and paces it behind the 3 s anti-churn floor from the mount run. await vi.advanceTimersByTimeAsync(200) expect(refreshMock).toHaveBeenCalledTimes(1) - // Mid-cooldown: still no second refresh. - await vi.advanceTimersByTimeAsync(1300) + await vi.advanceTimersByTimeAsync(2799) expect(refreshMock).toHaveBeenCalledTimes(1) - // After the full 3 s cooldown the trailing refresh fires. - await vi.advanceTimersByTimeAsync(1500) + await vi.advanceTimersByTimeAsync(1) await flushMicrotasks() expect(refreshMock).toHaveBeenCalledTimes(2) @@ -175,4 +173,123 @@ describe('useGitStatusPolling rerender stability', () => { // without having to wait for the 3000ms timer. expect(refreshMock).toHaveBeenCalledTimes(2) }) + + it('refreshes immediately when Source Control becomes visible', async () => { + useAppStore.setState({ rightSidebarOpen: false }) + await renderHook() + await flushMicrotasks() + expect(refreshMock).toHaveBeenCalledTimes(1) + + // Let the 3 s anti-churn floor from the mount refresh elapse first. + await vi.advanceTimersByTimeAsync(3000) + await act(async () => { + useAppStore.setState({ rightSidebarOpen: true }) + }) + await flushMicrotasks() + + expect(refreshMock).toHaveBeenCalledTimes(2) + }) + + it('refreshes immediately when the Files tab becomes visible', async () => { + useAppStore.setState({ rightSidebarOpen: false, rightSidebarTab: 'explorer' }) + await renderHook() + await flushMicrotasks() + expect(refreshMock).toHaveBeenCalledTimes(1) + + // Let the 3 s anti-churn floor from the mount refresh elapse first. + await vi.advanceTimersByTimeAsync(3000) + await act(async () => { + useAppStore.setState({ rightSidebarOpen: true }) + }) + await flushMicrotasks() + + expect(refreshMock).toHaveBeenCalledTimes(2) + }) + + it('refreshes immediately when an SSH execution host reconnects', async () => { + useAppStore.setState({ + repos: [{ ...repo, connectionId: 'ssh-1' }], + sshConnectionStates: new Map([ + ['ssh-1', { status: 'disconnected', error: null, reconnectAttempt: 0 }] + ]) + } as Partial) + await renderHook() + await flushMicrotasks() + expect(refreshMock).not.toHaveBeenCalled() + + await act(async () => { + useAppStore.setState({ + sshConnectionStates: new Map([ + ['ssh-1', { status: 'connected', error: null, reconnectAttempt: 0 }] + ]) + } as Partial) + }) + await flushMicrotasks() + + expect(refreshMock).toHaveBeenCalledTimes(1) + }) + + it('marks only the 60-second safety refresh for line-stat reuse', async () => { + await renderHook() + await flushMicrotasks() + + expect(refreshMock.mock.calls[0]?.[0].request.reuseLineStats).toBeUndefined() + await vi.advanceTimersByTimeAsync(60_000) + await flushMicrotasks() + + expect(refreshMock).toHaveBeenCalledTimes(2) + expect(refreshMock.mock.calls[1]?.[0].request.reuseLineStats).toBe(true) + }) + + it('aborts and rejects stale work when the execution host changes', async () => { + let resolveFirst!: () => void + const firstRefresh = new Promise((resolve) => { + resolveFirst = resolve + }) + let firstRequest!: { signal: AbortSignal; shouldApply: () => boolean } + refreshMock.mockImplementationOnce( + (args: { request: { signal: AbortSignal; shouldApply: () => boolean } }) => { + firstRequest = args.request + return firstRefresh + } + ) + await renderHook() + expect(refreshMock).toHaveBeenCalledTimes(1) + + await act(async () => { + useAppStore.setState({ + settings: { activeRuntimeEnvironmentId: 'env-2' } as AppState['settings'] + }) + }) + await flushMicrotasks() + + expect(firstRequest.signal.aborted).toBe(true) + expect(firstRequest.shouldApply()).toBe(false) + expect(refreshMock).toHaveBeenCalledTimes(2) + resolveFirst() + await flushMicrotasks() + }) + + it('aborts and rejects stale work on unmount', async () => { + let resolveFirst!: () => void + const firstRefresh = new Promise((resolve) => { + resolveFirst = resolve + }) + let firstRequest!: { signal: AbortSignal; shouldApply: () => boolean } + refreshMock.mockImplementationOnce( + (args: { request: { signal: AbortSignal; shouldApply: () => boolean } }) => { + firstRequest = args.request + return firstRefresh + } + ) + const root = await renderHook() + + act(() => root.unmount()) + roots.splice(roots.indexOf(root), 1) + + expect(firstRequest.signal.aborted).toBe(true) + expect(firstRequest.shouldApply()).toBe(false) + resolveFirst() + await flushMicrotasks() + }) }) diff --git a/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts b/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts index 44efe18be8b..9b226fb012a 100644 --- a/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts +++ b/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts @@ -238,7 +238,7 @@ describe('useGitStatusPolling', () => { expect(globalThis.setInterval).not.toHaveBeenCalled() }) - it('uses a slower git status cadence when only terminal branch detection needs polling', async () => { + it('uses the safety scheduler instead of an interval for terminal-only branch detection', async () => { const { gitStatus } = await usePollingOnce( { entries: [], @@ -255,7 +255,7 @@ describe('useGitStatusPolling', () => { ) expect(gitStatus).toHaveBeenCalledTimes(1) - expect(globalThis.setInterval).toHaveBeenCalledWith(expect.any(Function), 30_000) + expect(globalThis.setInterval).not.toHaveBeenCalled() }) it('does not install the visible git status poll while disabled', async () => { @@ -411,6 +411,7 @@ describe('useGitStatusPolling', () => { await vi.advanceTimersByTimeAsync(125) expect(gitStatus).toHaveBeenCalledTimes(1) + // Why: evidence refreshes keep the 3s anti-churn floor from the last run. await vi.advanceTimersByTimeAsync(2875) await vi.waitFor(() => expect(gitStatus).toHaveBeenCalledTimes(2)) expect(window.api.fs.watchWorktree).not.toHaveBeenCalled() @@ -652,6 +653,7 @@ describe('useGitStatusPolling', () => { await vi.advanceTimersByTimeAsync(65) expect(gitStatus).toHaveBeenCalledTimes(callsBeforeDebounceFires) + // Why: evidence refreshes keep the 3s anti-churn floor from the last run. await vi.advanceTimersByTimeAsync(2875) await vi.waitFor(() => expect(gitStatus).toHaveBeenCalledTimes(callsBeforeDebounceFires + 1)) @@ -728,7 +730,6 @@ describe('useGitStatusPolling', () => { it('does not overlap slow visible git status polls and runs one trailing refresh', async () => { vi.resetModules() vi.useFakeTimers() - let intervalCallback: (() => void) | null = null let resolveFirst!: (value: GitStatusResult) => void const firstStatus = new Promise((resolve) => { resolveFirst = resolve @@ -804,27 +805,22 @@ describe('useGitStatusPolling', () => { addEventListener: vi.fn(), removeEventListener: vi.fn() }) - vi.stubGlobal( - 'setInterval', - vi.fn((callback: () => void) => { - intervalCallback = callback - return 1 - }) - ) + vi.stubGlobal('setInterval', vi.fn()) vi.stubGlobal('clearInterval', vi.fn()) const { useGitStatusPolling: runPolling } = await import('./useGitStatusPolling') GitStatusPollingHarness({ runPolling }) await vi.waitFor(() => expect(gitStatus).toHaveBeenCalledTimes(1)) - expect(intervalCallback).toBeTypeOf('function') - const tick = intervalCallback as unknown as () => void - tick() - tick() + const onChanged = window.api.worktrees.onChanged as ReturnType + const handleRepoSignal = onChanged.mock.calls[0][0] as (payload: { repoId: string }) => void + handleRepoSignal({ repoId: repo.id }) + handleRepoSignal({ repoId: repo.id }) expect(gitStatus).toHaveBeenCalledTimes(1) resolveFirst(status) await vi.waitFor(() => expect(state.setGitStatus).toHaveBeenCalledTimes(1)) + // Why: the trailing refresh respects the 3s anti-churn floor after settle. await vi.advanceTimersByTimeAsync(3000) await vi.waitFor(() => expect(gitStatus).toHaveBeenCalledTimes(2)) await vi.waitFor(() => expect(state.setGitStatus).toHaveBeenCalledTimes(2)) diff --git a/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts b/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts index acb8d464cbb..8511d796630 100644 --- a/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts +++ b/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts @@ -1,13 +1,11 @@ -import { useCallback, useEffect, useMemo, useRef } from 'react' +import { useCallback, useEffect, useRef } from 'react' import { useAppStore } from '@/store' import { useAllWorktrees, useRepoById, useRepoMap, useWorktreeById } from '@/store/selectors' -import type { GitConflictOperation } from '../../../../shared/types' import { isGitRepoKind } from '../../../../shared/repo-kind' import { getConnectionId } from '@/lib/connection-context' -import { getRuntimeGitConflictOperation } from '@/runtime/runtime-git-client' +import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' import { refreshGitStatusForWorktree } from './git-status-refresh' -import { type CoalescedPollRunner, createCoalescedPollRunner } from './coalesced-poll-runner' -import { installWindowVisibilityInterval, isWindowVisible } from '@/lib/window-visibility-interval' +import { isWindowVisible } from '@/lib/window-visibility-interval' import { hasInteractiveActiveGitStatusConsumer, shouldPollActiveGitStatus @@ -15,18 +13,20 @@ import { import { getRightSidebarWorktreeRuntimeSettings } from './file-explorer-runtime-owner' import { useGitStatusFileWatchRefresh } from './git-status-file-watch-refresh' import { useGitStatusPushSignalRefresh } from './git-status-push-signal-refresh' +import { useStaleConflictOperationPolling } from './stale-conflict-operation-poll' +import { + createGitStatusRefreshScheduler, + type GitStatusRefreshReason, + type GitStatusRefreshScheduler +} from './git-status-refresh-scheduler' -const MIN_STATUS_REFRESH_INTERVAL_MS = 3000 -const INTERACTIVE_STATUS_POLL_INTERVAL_MS = MIN_STATUS_REFRESH_INTERVAL_MS -// Why: file-watch refreshes cover content changes and push signals (repo -// metadata watch, shell command completion) cover branch switches; the -// terminal-only poll is a last-resort backstop for shells without either. -const TERMINAL_ONLY_STATUS_POLL_INTERVAL_MS = 30_000 -// Why: on a large monorepo one status refresh can take tens of seconds, so the -// fixed 3s gap kept a git process running almost continuously while the -// workspace was idle (#7983). Evidence-free ticks wait 5x the previous poll -// duration (~1/6 duty cycle); change signals wait only 1x so real changes in a -// slow repo still surface promptly; the cap bounds worst-case staleness. +const STATUS_SAFETY_INTERVAL_MS = 60_000 +const STATUS_ACTIVITY_DEBOUNCE_MS = 125 +// Why: evidence-driven status refreshes must keep the pre-scheduler floor so +// sustained terminal/file signals can never run git back-to-back (#7983). +const STATUS_ACTIVITY_MIN_GAP_MS = 3000 +// Why: status scans and remote conflict probes can take longer than their +// timers; duration-aware spacing prevents a slow task from running nonstop. const SLOW_GIT_POLL_BACKOFF = { idleMultiplier: 5, changeSignalMultiplier: 1, @@ -37,6 +37,9 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void { const enabled = options.enabled ?? true const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) const activeWorktree = useWorktreeById(activeWorktreeId) + const activeExecutionHostId = useAppStore((s) => + getExecutionHostIdForWorktree(s, activeWorktreeId) + ) const allWorktrees = useAllWorktrees() const updateWorktreeGitIdentity = useAppStore((s) => s.updateWorktreeGitIdentity) const setGitStatus = useAppStore((s) => s.setGitStatus) @@ -87,143 +90,170 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void { canFetchActiveWorktreeGitStatus && !!activeWorktreeId && !gitStatusHugeByWorktree?.[activeWorktreeId] - const activeStatusPollIntervalMs = hasInteractiveActiveGitStatusConsumer( + const activeStatusPollScope = shouldPollActiveWorktreeGitStatus + ? `${activeExecutionHostId}\0${activeWorktreeId}\0${worktreePath}` + : null + // Why: opening any git-status consumer (Source Control, Files, Checks, or an + // editor file) must refresh promptly, matching the pre-scheduler behavior + // where the interactive interval flip re-ran an immediate poll. + const interactiveConsumerVisible = hasInteractiveActiveGitStatusConsumer( activeGitStatusPollingArgs ) - ? INTERACTIVE_STATUS_POLL_INTERVAL_MS - : TERMINAL_ONLY_STATUS_POLL_INTERVAL_MS - const activeStatusPollScope = shouldPollActiveWorktreeGitStatus ? activeWorktreeId : null - // Why: build a list of non-active worktrees that still have a known conflict - // operation (merge/rebase/cherry-pick). These need lightweight polling so - // their sidebar badges clear when the operation finishes — the full git status - // poll only covers the active worktree. - const staleConflictWorktrees = useMemo(() => { - const result: { id: string; path: string }[] = [] - for (const [worktreeId, op] of Object.entries(conflictOperationByWorktree)) { - if (worktreeId === activeWorktreeId || op === 'unknown') { - continue + const runFetchStatus = useCallback( + async (request: { + reason: GitStatusRefreshReason + signal: AbortSignal + shouldApply: () => boolean + }) => { + // Why: eligibility can change between timer dispatch and task start. + // Avoid launching work that its liveness guard would have to discard. + if ( + request.signal.aborted || + !isWindowVisible() || + !canFetchActiveWorktreeGitStatus || + !activeWorktreeId || + !worktreePath + ) { + return } - const worktree = allWorktrees.find((entry) => entry.id === worktreeId) - if (worktree) { - const repo = repoMap.get(worktree.repoId) - if (repo && !isGitRepoKind(repo)) { - continue - } - result.push({ id: worktree.id, path: worktree.path }) + try { + const connectionId = getConnectionId(activeWorktreeId) ?? undefined + await refreshGitStatusForWorktree({ + settings: getRightSidebarWorktreeRuntimeSettings(activeWorktreeId), + worktreeId: activeWorktreeId, + worktreePath, + connectionId, + pushTarget: activePushTarget, + deps: { + setGitStatus, + updateWorktreeGitIdentity, + setUpstreamStatus, + fetchUpstreamStatus + }, + request: { + ...(request.reason === 'safety' ? { reuseLineStats: true } : {}), + signal: request.signal, + shouldApply: request.shouldApply + } + }) + } catch { + // ignore } - } - return result - }, [allWorktrees, conflictOperationByWorktree, activeWorktreeId, repoMap]) + }, + [ + activePushTarget, + activeWorktreeId, + fetchUpstreamStatus, + canFetchActiveWorktreeGitStatus, + worktreePath, + setGitStatus, + setUpstreamStatus, + updateWorktreeGitIdentity + ] + ) - const runFetchStatus = useCallback(async () => { - // Why: a backoff-deferred run can fire long after the window hides; skip - // the scan instead of running tens of seconds of git work nobody can see. - // The becoming-visible run catches up via the change-signal lane. - if (!isWindowVisible()) { - return - } - if (!canFetchActiveWorktreeGitStatus || !activeWorktreeId || !worktreePath) { - return - } - try { - const connectionId = getConnectionId(activeWorktreeId) ?? undefined - await refreshGitStatusForWorktree({ - settings: getRightSidebarWorktreeRuntimeSettings(activeWorktreeId), - worktreeId: activeWorktreeId, - worktreePath, - connectionId, - pushTarget: activePushTarget, - deps: { - setGitStatus, - updateWorktreeGitIdentity, - setUpstreamStatus, - fetchUpstreamStatus - } - }) - } catch { - // ignore - } - }, [ - activePushTarget, - activeWorktreeId, - fetchUpstreamStatus, - canFetchActiveWorktreeGitStatus, - worktreePath, - setGitStatus, - setUpstreamStatus, - updateWorktreeGitIdentity - ]) - - // Why: the runner must survive rerenders so `lastRunEndedAt` and `inFlight` - // are never reset by a UI-state change mid-burst (e.g. openFiles update while - // git is still running). A ref keeps one runner per active-worktree lifetime; - // `runFetchStatusRef` lets the runner always call the latest closure without - // being recreated. The runner is disposed and replaced only when the active - // worktree changes or the hook unmounts. + // Why: the scheduler must survive harmless store rerenders so its in-flight, + // trailing-signal, and safety-horizon state remain authoritative. const runFetchStatusRef = useRef(runFetchStatus) runFetchStatusRef.current = runFetchStatus + const canApplyScheduledStatusRef = useRef(canFetchActiveWorktreeGitStatus) + canApplyScheduledStatusRef.current = canFetchActiveWorktreeGitStatus + const statusRefreshGenerationRef = useRef(0) - const statusPollRunnerRef = useRef(null) + const statusSchedulerRef = useRef(null) useEffect(() => { - const runner = createCoalescedPollRunner(() => runFetchStatusRef.current(), { - minIntervalMs: MIN_STATUS_REFRESH_INTERVAL_MS, - slowTaskBackoff: SLOW_GIT_POLL_BACKOFF - }) - statusPollRunnerRef.current = runner + const generation = ++statusRefreshGenerationRef.current + const scheduler = createGitStatusRefreshScheduler( + ({ reason, signal }) => + runFetchStatusRef.current({ + reason, + signal, + shouldApply: () => + statusRefreshGenerationRef.current === generation && + canApplyScheduledStatusRef.current && + !signal.aborted && + isWindowVisible() + }), + { + safetyIntervalMs: STATUS_SAFETY_INTERVAL_MS, + activityDebounceMs: STATUS_ACTIVITY_DEBOUNCE_MS, + activityMinGapMs: STATUS_ACTIVITY_MIN_GAP_MS, + slowTaskBackoff: SLOW_GIT_POLL_BACKOFF + } + ) + statusSchedulerRef.current = scheduler return () => { - runner.dispose() - statusPollRunnerRef.current = null - } - }, [activeWorktreeId]) - - const fetchStatus = useCallback(() => { - statusPollRunnerRef.current?.run() - }, []) - - // Why: file-watch and push signals carry evidence something changed, so they - // take the runner's short-backoff lane instead of evidence-free tick pacing. - const fetchStatusOnChangeSignal = useCallback(() => { - statusPollRunnerRef.current?.run({ changeSignal: true }) - }, []) - - // Why: while huge, the visibility interval below is not installed, and push - // signals are dropped while hidden — e.g. an agent committing behind a - // minimized window. Catch up on reveal so the huge flag can still clear. - const hugeStatusPauseActive = canFetchActiveWorktreeGitStatus && !activeStatusPollScope - useEffect(() => { - if ( - !hugeStatusPauseActive || - typeof document === 'undefined' || - typeof document.addEventListener !== 'function' - ) { - return - } - const catchUpOnReveal = (): void => { - if (isWindowVisible()) { - fetchStatusOnChangeSignal() + statusRefreshGenerationRef.current += 1 + scheduler.dispose() + if (statusSchedulerRef.current === scheduler) { + statusSchedulerRef.current = null } } - document.addEventListener('visibilitychange', catchUpOnReveal) - return () => { - document.removeEventListener('visibilitychange', catchUpOnReveal) - } - }, [hugeStatusPauseActive, fetchStatusOnChangeSignal]) + // Why: push-target changes must bump the generation so an in-flight refresh + // captured against the old remote/branch can't apply or cache stale upstream + // status for the new one. + }, [activeExecutionHostId, activePushTarget, activeWorktreeId, worktreePath]) useEffect(() => { - if (!activeStatusPollScope) { + const reconcile = (catchUp: boolean): void => { + const scheduler = statusSchedulerRef.current + if (!scheduler) { + return + } + if (!canFetchActiveWorktreeGitStatus || !isWindowVisible()) { + scheduler.pause() + return + } + if (activeStatusPollScope) { + scheduler.resumeSafety() + return + } + scheduler.suspendSafety() + if (catchUp) { + scheduler.refreshNow() + } + } + reconcile(false) + if (typeof document === 'undefined' || typeof document.addEventListener !== 'function') { return } - // Why: this root-level poll should pause while hidden, but visible - // unfocused windows still need fresh status for second-display workflows. - // Change signals are dropped while hidden, so the becoming-visible run - // rides the short-backoff lane to catch up on anything that was missed. - return installWindowVisibilityInterval({ - run: fetchStatus, - runOnVisible: fetchStatusOnChangeSignal, - intervalMs: activeStatusPollIntervalMs - }) - }, [activeStatusPollIntervalMs, activeStatusPollScope, fetchStatus, fetchStatusOnChangeSignal]) + const handleVisibilityChange = (): void => reconcile(isWindowVisible()) + document.addEventListener('visibilitychange', handleVisibilityChange) + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange) + } + }, [activeStatusPollScope, canFetchActiveWorktreeGitStatus]) + + const previousConsumerVisibilityRef = useRef({ + worktreeId: activeWorktreeId, + visible: interactiveConsumerVisible, + canFetch: canFetchActiveWorktreeGitStatus + }) + useEffect(() => { + const previous = previousConsumerVisibilityRef.current + if ( + interactiveConsumerVisible && + previous.worktreeId === activeWorktreeId && + !previous.visible && + previous.canFetch && + canFetchActiveWorktreeGitStatus && + isWindowVisible() + ) { + statusSchedulerRef.current?.refreshNow() + } + previousConsumerVisibilityRef.current = { + worktreeId: activeWorktreeId, + visible: interactiveConsumerVisible, + canFetch: canFetchActiveWorktreeGitStatus + } + }, [activeWorktreeId, canFetchActiveWorktreeGitStatus, interactiveConsumerVisible]) + + // Why: file, terminal, and metadata evidence share one scheduler window so + // a single Git operation cannot fan out into several status subprocesses. + const fetchStatusOnChangeSignal = useCallback(() => { + statusSchedulerRef.current?.signal() + }, []) useGitStatusFileWatchRefresh({ activeConnectionId, @@ -247,63 +277,14 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void { fetchStatus: fetchStatusOnChangeSignal }) - // Why: poll conflict operation for non-active worktrees that have a stale - // non-unknown operation. This is a lightweight fs-only check (no git status) - // so it won't cause performance issues even with many worktrees. - useEffect(() => { - if (!enabled) { - return - } - if (staleConflictWorktrees.length === 0) { - return - } - - const pollStale = async (): Promise => { - // Why: a backoff-deferred run can fire long after the window hides; skip - // the probe instead of running SSH/RPC work nobody can see. The - // becoming-visible run catches up via the change-signal lane. - if (!isWindowVisible()) { - return - } - for (const { id, path } of staleConflictWorktrees) { - try { - const connectionId = getConnectionId(id) ?? undefined - // Why: after explicit SSH disconnect the provider is intentionally - // gone; keep remote polling quiet until the target reconnects. - if (!isConnectionReady(connectionId)) { - continue - } - const op = (await getRuntimeGitConflictOperation({ - settings: getRightSidebarWorktreeRuntimeSettings(id), - worktreeId: id, - worktreePath: path, - connectionId - })) as GitConflictOperation - setConflictOperation(id, op) - } catch { - // ignore — worktree may have been removed - } - } - } - - // Why: remote conflict probes can exceed the 3s interval. Keep one poll in - // flight, coalesce skipped ticks into one trailing pass, and back off after - // slow probe chains so stale badges catch up without stacking SSH/RPC work. - const pollRunner = createCoalescedPollRunner(pollStale, { - slowTaskBackoff: SLOW_GIT_POLL_BACKOFF - }) - // Why: conflict badges are visible sidebar state; keep them fresh in - // visible unfocused windows, but do not poll disconnected hidden windows. - // The becoming-visible run rides the short-backoff lane so badges catch - // up promptly after a hidden stretch. - const stopVisiblePoll = installWindowVisibilityInterval({ - run: () => pollRunner.run(), - runOnVisible: () => pollRunner.run({ changeSignal: true }), - intervalMs: MIN_STATUS_REFRESH_INTERVAL_MS - }) - return () => { - pollRunner.dispose() - stopVisiblePoll() - } - }, [enabled, staleConflictWorktrees, setConflictOperation, isConnectionReady]) + useStaleConflictOperationPolling({ + enabled, + activeWorktreeId, + allWorktrees, + repoMap, + conflictOperationByWorktree, + setConflictOperation, + isConnectionReady, + slowTaskBackoff: SLOW_GIT_POLL_BACKOFF + }) } diff --git a/src/renderer/src/runtime/abortable-runtime-environment-call.ts b/src/renderer/src/runtime/abortable-runtime-environment-call.ts new file mode 100644 index 00000000000..88b9dc9d6fb --- /dev/null +++ b/src/renderer/src/runtime/abortable-runtime-environment-call.ts @@ -0,0 +1,65 @@ +import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' + +export function createRuntimeRpcAbortError(): Error { + const error = new Error('Runtime request aborted') + error.name = 'AbortError' + return error +} + +export async function callAbortableRuntimeEnvironment( + environmentId: string, + method: string, + params: unknown, + timeoutMs: number | undefined, + signal: AbortSignal +): Promise> { + if (signal.aborted) { + throw createRuntimeRpcAbortError() + } + // Why: the one-shot runtime call bridge cannot cancel host work; the + // subscription bridge closes its request context when we unsubscribe. + return new Promise((resolve, reject) => { + let handle: { unsubscribe: () => void } | null = null + let settled = false + // Why: the subscription transport's own timeout only bounds subscription + // start, not the response. Keep the one-shot call path's response deadline + // so a connected-but-unresponsive runtime cannot stall the caller forever. + const deadline = + timeoutMs === undefined + ? null + : setTimeout(() => { + finish(() => reject(new Error(`Runtime request timed out before ${method} completed`))) + }, timeoutMs) + const finish = (complete: () => void): void => { + if (settled) { + return + } + settled = true + if (deadline !== null) { + clearTimeout(deadline) + } + signal.removeEventListener('abort', onAbort) + handle?.unsubscribe() + complete() + } + const onAbort = (): void => finish(() => reject(createRuntimeRpcAbortError())) + signal.addEventListener('abort', onAbort, { once: true }) + void window.api.runtimeEnvironments + .subscribe( + { selector: environmentId, method, params, timeoutMs }, + { + onResponse: (response) => finish(() => resolve(response)), + onError: (error) => finish(() => reject(new Error(error.message))), + onClose: () => + finish(() => reject(new Error(`Runtime request closed before ${method} completed`))) + } + ) + .then((subscription) => { + handle = subscription + if (settled) { + subscription.unsubscribe() + } + }) + .catch((error) => finish(() => reject(error))) + }) +} diff --git a/src/renderer/src/runtime/runtime-git-client.test.ts b/src/renderer/src/runtime/runtime-git-client.test.ts index 64c12946a4b..78ed0853a48 100644 --- a/src/renderer/src/runtime/runtime-git-client.test.ts +++ b/src/renderer/src/runtime/runtime-git-client.test.ts @@ -23,6 +23,7 @@ import { import { clearRuntimeCompatibilityCacheForTests } from './runtime-rpc-client' const gitStatus = vi.fn() +const gitCancelStatus = vi.fn() const gitCheckIgnored = vi.fn() const gitSubmoduleStatus = vi.fn() const gitDiff = vi.fn() @@ -44,6 +45,8 @@ const runtimeCall = vi.fn() beforeEach(() => { clearRuntimeCompatibilityCacheForTests() gitStatus.mockReset() + gitCancelStatus.mockReset() + gitCancelStatus.mockResolvedValue(undefined) gitCheckIgnored.mockReset() gitSubmoduleStatus.mockReset() gitDiff.mockReset() @@ -68,6 +71,7 @@ beforeEach(() => { api: { git: { status: gitStatus, + cancelStatus: gitCancelStatus, checkIgnored: gitCheckIgnored, submoduleStatus: gitSubmoduleStatus, diff: gitDiff, @@ -204,6 +208,41 @@ describe('runtime git client', () => { }) }) + it('forwards line-stat reuse to local status and cancels tokenized work on abort', async () => { + const controller = new AbortController() + let resolveStatus!: (value: { entries: never[]; conflictOperation: string }) => void + gitStatus.mockImplementation( + () => + new Promise((resolve) => { + resolveStatus = resolve + }) + ) + + const request = getRuntimeGitStatus( + { + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: 'wt-1', + worktreePath: '/repo' + }, + { reuseLineStats: true, signal: controller.signal } + ) + await vi.waitFor(() => expect(gitStatus).toHaveBeenCalled()) + const statusArgs = gitStatus.mock.calls[0]?.[0] as { requestToken?: string } + controller.abort() + resolveStatus({ entries: [], conflictOperation: 'unknown' }) + // Why: cancel is best-effort on the main process; even if status still + // settles, the aborted local call must reject rather than look fresh. + await expect(request).rejects.toMatchObject({ name: 'AbortError' }) + + expect(gitStatus).toHaveBeenCalledWith({ + worktreePath: '/repo', + connectionId: undefined, + reuseLineStats: true, + requestToken: expect.any(String) + }) + expect(gitCancelStatus).toHaveBeenCalledWith({ requestToken: statusArgs.requestToken }) + }) + it('checks ignored paths through local git IPC', async () => { gitCheckIgnored.mockResolvedValue(['dist/bundle.js']) @@ -408,6 +447,57 @@ describe('runtime git client', () => { }) }) + it('keeps safety (reuse) refreshes on the pooled call transport even with a signal', async () => { + const controller = new AbortController() + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { entries: [], conflictOperation: 'unknown' }, + _meta: { runtimeId: 'remote-runtime' } + }) + + await getRuntimeGitStatus( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/repo' + }, + { reuseLineStats: true, signal: controller.signal } + ) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'git.status', + params: { worktree: 'id:wt-1', reuseLineStats: true }, + timeoutMs: 15_000 + }) + }) + + it('forwards line-stat reuse through the active runtime environment', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { entries: [], conflictOperation: 'unknown' }, + _meta: { runtimeId: 'remote-runtime' } + }) + + await getRuntimeGitStatus( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/repo' + }, + { reuseLineStats: true } + ) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'git.status', + params: { worktree: 'id:wt-1', reuseLineStats: true }, + timeoutMs: 15_000 + }) + }) + it('checks ignored paths through the active runtime environment', async () => { runtimeEnvironmentCall.mockResolvedValue({ id: 'rpc-1', diff --git a/src/renderer/src/runtime/runtime-git-client.ts b/src/renderer/src/runtime/runtime-git-client.ts index 972e2b7fdcb..019eaef5039 100644 --- a/src/renderer/src/runtime/runtime-git-client.ts +++ b/src/renderer/src/runtime/runtime-git-client.ts @@ -133,20 +133,30 @@ export function getRuntimeGitScope( export async function getRuntimeGitStatus( context: RuntimeGitContext, - options?: { includeIgnored?: boolean; bypassEffectiveUpstreamNegativeCache?: boolean } + options?: { + includeIgnored?: boolean + bypassEffectiveUpstreamNegativeCache?: boolean + reuseLineStats?: boolean + signal?: AbortSignal + } ): Promise { const target = getActiveRuntimeTarget(context.settings) const includeIgnoredArgs = options?.includeIgnored ? { includeIgnored: true } : {} const upstreamCacheBypassArgs = options?.bypassEffectiveUpstreamNegativeCache ? { bypassEffectiveUpstreamNegativeCache: true } : {} + const lineStatsReuseArgs = options?.reuseLineStats ? { reuseLineStats: true } : {} if (target.kind === 'local' || !context.worktreeId) { - return window.api.git.status({ - worktreePath: resolveLocalWorktreePath(context), - connectionId: context.connectionId, - ...includeIgnoredArgs, - ...upstreamCacheBypassArgs - }) + return callLocalGitStatus( + { + worktreePath: resolveLocalWorktreePath(context), + connectionId: context.connectionId, + ...includeIgnoredArgs, + ...upstreamCacheBypassArgs, + ...lineStatsReuseArgs + }, + options?.signal + ) } return callRuntimeRpc( target, @@ -154,12 +164,58 @@ export async function getRuntimeGitStatus( { worktree: toRuntimeWorktreeSelector(context.worktreeId), ...includeIgnoredArgs, - ...upstreamCacheBypassArgs + ...upstreamCacheBypassArgs, + ...lineStatsReuseArgs }, - { timeoutMs: 15_000 } + { + timeoutMs: 15_000, + // Why: an abort signal forces the dedicated per-request subscription + // socket (one WebSocket + E2EE handshake per request). The safety + // refresh is the idle steady state, so it stays on the pooled call + // transport: its stale result is rejected by the caller's liveness + // guard and the 15s timeout bounds it. Activity refreshes keep the + // abortable transport so pause/host switches cancel real remote work. + ...(options?.reuseLineStats ? {} : { signal: options?.signal }) + } ) } +let nextGitStatusRequestToken = 0 + +function createGitStatusAbortError(): Error { + const error = new Error('Git status request aborted') + error.name = 'AbortError' + return error +} + +async function callLocalGitStatus( + args: Parameters[0], + signal?: AbortSignal +): Promise { + if (!signal) { + return window.api.git.status(args) + } + if (signal.aborted) { + throw createGitStatusAbortError() + } + const requestToken = `git-status-${Date.now()}-${++nextGitStatusRequestToken}` + const cancel = (): void => { + void window.api.git.cancelStatus({ requestToken }).catch(() => {}) + } + signal.addEventListener('abort', cancel, { once: true }) + try { + const status = await window.api.git.status({ ...args, requestToken }) + // Why: cancel is best-effort; a scan that finished after abort must still + // reject so callers never treat a cancelled request as a fresh result. + if (signal.aborted) { + throw createGitStatusAbortError() + } + return status + } finally { + signal.removeEventListener('abort', cancel) + } +} + export async function getRuntimeGitSubmoduleStatus( context: RuntimeGitContext, submodulePath: string, diff --git a/src/renderer/src/runtime/runtime-rpc-client.test.ts b/src/renderer/src/runtime/runtime-rpc-client.test.ts index 0bf2ac4f8f0..c9481d2746c 100644 --- a/src/renderer/src/runtime/runtime-rpc-client.test.ts +++ b/src/renderer/src/runtime/runtime-rpc-client.test.ts @@ -20,15 +20,20 @@ import { const runtimeCall = vi.fn() const runtimeEnvironmentCall = vi.fn() +const runtimeEnvironmentSubscribe = vi.fn() beforeEach(() => { clearRuntimeCompatibilityCacheForTests() runtimeCall.mockReset() runtimeEnvironmentCall.mockReset() + runtimeEnvironmentSubscribe.mockReset() vi.stubGlobal('window', { api: { runtime: { call: runtimeCall }, - runtimeEnvironments: { call: runtimeEnvironmentCall } + runtimeEnvironments: { + call: runtimeEnvironmentCall, + subscribe: runtimeEnvironmentSubscribe + } } }) }) @@ -109,6 +114,49 @@ describe('runtime RPC client routing', () => { expect(runtimeCall).not.toHaveBeenCalled() }) + it('uses an abortable subscription for paired-runtime requests', async () => { + const controller = new AbortController() + const unsubscribe = vi.fn() + runtimeEnvironmentSubscribe.mockResolvedValue({ unsubscribe, sendBinary: vi.fn() }) + + const request = callRuntimeRpc( + { kind: 'environment', environmentId: 'env-1' }, + 'status.get', + undefined, + { signal: controller.signal } + ) + await vi.waitFor(() => expect(runtimeEnvironmentSubscribe).toHaveBeenCalled()) + controller.abort() + + await expect(request).rejects.toMatchObject({ name: 'AbortError' }) + await vi.waitFor(() => expect(unsubscribe).toHaveBeenCalledTimes(1)) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('rejects an abortable paired-runtime request at the response deadline', async () => { + vi.useFakeTimers() + try { + const controller = new AbortController() + const unsubscribe = vi.fn() + runtimeEnvironmentSubscribe.mockResolvedValue({ unsubscribe, sendBinary: vi.fn() }) + + const request = callRuntimeRpc( + { kind: 'environment', environmentId: 'env-1' }, + 'status.get', + undefined, + { signal: controller.signal, timeoutMs: 15_000 } + ) + await vi.waitFor(() => expect(runtimeEnvironmentSubscribe).toHaveBeenCalled()) + const rejection = expect(request).rejects.toThrow('timed out before status.get completed') + await vi.advanceTimersByTimeAsync(15_000) + + await rejection + await vi.waitFor(() => expect(unsubscribe).toHaveBeenCalledTimes(1)) + } finally { + vi.useRealTimers() + } + }) + it('preflights remote runtime compatibility before non-status calls', async () => { runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) => { const result = diff --git a/src/renderer/src/runtime/runtime-rpc-client.ts b/src/renderer/src/runtime/runtime-rpc-client.ts index e9b58f9d694..1f024c1a67f 100644 --- a/src/renderer/src/runtime/runtime-rpc-client.ts +++ b/src/renderer/src/runtime/runtime-rpc-client.ts @@ -4,20 +4,22 @@ import type { RuntimeStatus } from '../../../shared/runtime-types' import type { RuntimeCapability } from '../../../shared/protocol-version' import { withBrowserPaneUiRuntimeRpcSource } from '../../../shared/runtime-rpc-feature-interaction-source' import { assertRuntimeStatusCompatible } from './runtime-protocol-compat' +import { + callAbortableRuntimeEnvironment, + createRuntimeRpcAbortError +} from './abortable-runtime-environment-call' export type RuntimeClientTarget = { kind: 'local' } | { kind: 'environment'; environmentId: string } const RUNTIME_COMPATIBILITY_CACHE_MAX = 32 const RECENT_RUNTIME_COMPATIBILITY_FAILURE_TTL_MS = 60_000 -// Why: a saved environment can restart into a different Orca version without -// changing ids; capability verdicts must eventually follow that version change. +// Why: capability verdicts must eventually follow a saved environment's version changes. const RUNTIME_CAPABILITY_STATUS_TTL_MS = 60_000 type RuntimeCompatibilityCacheEntry = { check: Promise failedAt: number | null - // True only once status.get settled and proved compatible. Stays false while - // the probe is in flight, so a recovery clear can drop a doomed pending probe. + // False while probing so recovery can drop a doomed pending compatibility check. provenCompatible: boolean status: RuntimeStatus | null statusCheckedAt: number | null @@ -73,34 +75,38 @@ export async function callRuntimeRpc( timeoutMs?: number suppressFeatureInteraction?: boolean reuseRecentCompatibilityFailure?: boolean + signal?: AbortSignal } = {} ): Promise { if (target.kind === 'environment' && method !== 'status.get') { await ensureRuntimeEnvironmentCompatible(target.environmentId, options) } - const nextParams = addFeatureInteractionSource(params, options) + if (options.signal?.aborted) { + throw createRuntimeRpcAbortError() + } + const nextParams = options.suppressFeatureInteraction + ? withBrowserPaneUiRuntimeRpcSource(params) + : params const response = target.kind === 'local' ? await window.api.runtime.call({ method, params: nextParams }) - : await window.api.runtimeEnvironments.call({ - selector: target.environmentId, - method, - params: nextParams, - timeoutMs: options.timeoutMs - }) + : options.signal + ? await callAbortableRuntimeEnvironment( + target.environmentId, + method, + nextParams, + options.timeoutMs, + options.signal + ) + : await window.api.runtimeEnvironments.call({ + selector: target.environmentId, + method, + params: nextParams, + timeoutMs: options.timeoutMs + }) return unwrapRuntimeRpcResult(response as RuntimeRpcResponse) } -function addFeatureInteractionSource( - params: unknown, - options: { suppressFeatureInteraction?: boolean } -): unknown { - if (!options.suppressFeatureInteraction) { - return params - } - return withBrowserPaneUiRuntimeRpcSource(params) -} - async function ensureRuntimeEnvironmentCompatible( environmentId: string, options: { timeoutMs?: number; reuseRecentCompatibilityFailure?: boolean } = {} @@ -187,11 +193,8 @@ function rememberRuntimeEnvironmentCompatibility( } } -// Why: a live status.get answer proves any cached compatibility verdict that is -// not a settled success is stale. Drop settled failures AND still-pending probes -// (a probe queued on the dropped connection is doomed, and a reachability- -// triggered refresh must not coalesce onto it) so the refresh re-probes. Only -// proven-compatible successes stay cached. +// Why: a live status answer invalidates failures and pending probes from the +// dropped connection; only proven-compatible successes remain reusable. export function clearRecentRuntimeCompatibilityFailure(environmentId: string): void { const trimmed = environmentId.trim() if (!trimmed) { diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index ac19ddce7c8..abd8534c7a1 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -66,6 +66,7 @@ import { type ExecutionHostId } from '../../../shared/execution-host' import { toRuntimeWorktreeSelector } from '../runtime/runtime-worktree-selector' +import { callAbortableRuntimeEnvironment } from '../runtime/abortable-runtime-environment-call' import { normalizeDisabledTuiAgents } from '../../../shared/tui-agent-selection' import { normalizeTuiAgentArgsRecord, @@ -1642,14 +1643,64 @@ function createFileApi(): NonNullable['fs']> { } } +// Why: track the in-flight abortable status request per token so cancelStatus +// can abort the matching subscription and close its remote request context. +const webGitStatusAbortControllers = new Map() + +async function callAbortableRuntimeStatus( + requestToken: string, + params: unknown +): Promise { + const environment = requireActiveEnvironment() + webGitStatusAbortControllers.get(requestToken)?.abort() + const controller = new AbortController() + webGitStatusAbortControllers.set(requestToken, controller) + try { + const response = await callAbortableRuntimeEnvironment( + environment.id, + 'git.status', + params, + undefined, + controller.signal + ) + updateEnvironmentFromResponse(environment, response) + if (!response.ok) { + throw new Error(response.error.message) + } + return response.result as TResult + } finally { + if (webGitStatusAbortControllers.get(requestToken) === controller) { + webGitStatusAbortControllers.delete(requestToken) + } + } +} + function createGitApi(): NonNullable['git']> { return { - status: async ({ worktreePath, includeIgnored }) => { + status: async ({ + worktreePath, + includeIgnored, + bypassEffectiveUpstreamNegativeCache, + reuseLineStats, + requestToken + }) => { const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - return callRuntimeResult('git.status', { + const params = { worktree: toRuntimeWorktreeSelector(worktree.id), - includeIgnored - }) + includeIgnored, + bypassEffectiveUpstreamNegativeCache, + reuseLineStats + } + // Why: without a token there's nothing to cancel, so stay on the pooled + // call transport. With one, route through the subscription bridge so + // cancelStatus can close the request context and abort the remote scan. + if (!requestToken) { + return callRuntimeResult('git.status', params) + } + return callAbortableRuntimeStatus(requestToken, params) + }, + cancelStatus: async ({ requestToken }) => { + webGitStatusAbortControllers.get(requestToken)?.abort() }, submoduleStatus: async ({ worktreePath, submodulePath, area }) => { const worktree = await resolveRuntimeWorktreeByPath(worktreePath) diff --git a/src/renderer/src/web/web-runtime-client-file-watch-replay.test.ts b/src/renderer/src/web/web-runtime-client-file-watch-replay.test.ts index 3b554614bd5..ee522962669 100644 --- a/src/renderer/src/web/web-runtime-client-file-watch-replay.test.ts +++ b/src/renderer/src/web/web-runtime-client-file-watch-replay.test.ts @@ -3,6 +3,7 @@ // setup, so consumers must receive a conservative overflow once it is ready. import { describe, expect, it, vi, afterEach, beforeEach } from 'vitest' import { WebRuntimeClient } from './web-runtime-client' +import { encrypt } from '../../../shared/e2ee-crypto' const fakeSockets: FakeWebSocket[] = [] @@ -108,4 +109,161 @@ describe('WebRuntimeClient file-watch replay resync', () => { }) client.close() }) + + it('replays an active file watch on reconnect and keeps its logical handle', async () => { + const client = new WebRuntimeClient({ + v: 2, + endpoint: 'ws://127.0.0.1:6768', + deviceToken: 'token', + publicKeyB64: Buffer.alloc(32).toString('base64') + }) + const initialSocket = fakeSockets[0]! + initialSocket.readyState = FakeWebSocket.OPEN + const onResponse = vi.fn() + const onClose = vi.fn() + const internals = client as unknown as { + ws: FakeWebSocket | null + sharedKey: Uint8Array | null + state: string + subscriptions: Map< + string, + { + callbacks: { onResponse: typeof onResponse } + needsReplay: boolean + } + > + setState(next: string): void + handleSocketClosed(socket: FakeWebSocket): void + } + internals.sharedKey = new Uint8Array(32) + internals.state = 'connected' + + const handle = await client.subscribe( + 'files.watch', + { worktree: 'wt-1' }, + { onResponse, onClose } + ) + const firstId = Array.from(internals.subscriptions.keys())[0] + expect(initialSocket.send).toHaveBeenCalledTimes(1) + + initialSocket.onclose?.() + + expect(onClose).not.toHaveBeenCalled() + expect(internals.subscriptions.get(firstId!)?.needsReplay).toBe(true) + + const replacementSocket = new FakeWebSocket('ws://127.0.0.1:6768') + replacementSocket.readyState = FakeWebSocket.OPEN + internals.ws = replacementSocket + internals.sharedKey = new Uint8Array(32) + internals.setState('connected') + + const [replacementId, replacement] = Array.from(internals.subscriptions.entries())[0]! + expect(replacementId).not.toBe(firstId) + expect(replacementSocket.send).toHaveBeenCalledTimes(1) + replacement.callbacks.onResponse({ + id: replacementId, + ok: true, + streaming: true, + result: { type: 'changed', worktree: 'wt-1', events: [] }, + _meta: { runtimeId: 'runtime-web-test' } + }) + expect(onResponse).toHaveBeenCalledTimes(1) + + handle.unsubscribe() + internals.handleSocketClosed(replacementSocket) + expect(internals.subscriptions.size).toBe(0) + const secondReplacementSocket = new FakeWebSocket('ws://127.0.0.1:6768') + secondReplacementSocket.readyState = FakeWebSocket.OPEN + internals.ws = secondReplacementSocket + internals.sharedKey = new Uint8Array(32) + internals.setState('connected') + expect(secondReplacementSocket.send).not.toHaveBeenCalled() + client.close() + expect(internals.subscriptions.size).toBe(0) + }) + + it('does not replay a file watch stopped after its socket disconnects', async () => { + const client = new WebRuntimeClient({ + v: 2, + endpoint: 'ws://127.0.0.1:6768', + deviceToken: 'token', + publicKeyB64: Buffer.alloc(32).toString('base64') + }) + const initialSocket = fakeSockets[0]! + initialSocket.readyState = FakeWebSocket.OPEN + const internals = client as unknown as { + ws: FakeWebSocket | null + sharedKey: Uint8Array | null + state: string + subscriptions: Map + setState(next: string): void + } + internals.sharedKey = new Uint8Array(32) + internals.state = 'connected' + const handle = await client.subscribe( + 'files.watch', + { worktree: 'wt-1' }, + { onResponse: vi.fn() } + ) + + initialSocket.onclose?.() + expect(Array.from(internals.subscriptions.values())[0]?.needsReplay).toBe(true) + handle.unsubscribe() + expect(internals.subscriptions.size).toBe(0) + + const replacementSocket = new FakeWebSocket('ws://127.0.0.1:6768') + replacementSocket.readyState = FakeWebSocket.OPEN + internals.ws = replacementSocket + internals.sharedKey = new Uint8Array(32) + internals.setState('connected') + expect(replacementSocket.send).not.toHaveBeenCalled() + client.close() + }) + + it('evicts a failed file-watch setup before reconnect can replay it', async () => { + const client = new WebRuntimeClient({ + v: 2, + endpoint: 'ws://127.0.0.1:6768', + deviceToken: 'token', + publicKeyB64: Buffer.alloc(32).toString('base64') + }) + const initialSocket = fakeSockets[0]! + initialSocket.readyState = FakeWebSocket.OPEN + const sharedKey = new Uint8Array(32) + const onResponse = vi.fn() + const internals = client as unknown as { + ws: FakeWebSocket | null + sharedKey: Uint8Array | null + state: string + subscriptions: Map + handleSocketMessage(rawData: unknown): Promise + setState(next: string): void + } + internals.sharedKey = sharedKey + internals.state = 'connected' + await client.subscribe('files.watch', { worktree: 'wt-1' }, { onResponse }) + const subscriptionId = Array.from(internals.subscriptions.keys())[0]! + + await internals.handleSocketMessage( + encrypt( + JSON.stringify({ + id: subscriptionId, + ok: false, + error: { code: 'watch_failed', message: 'root unavailable' } + }), + sharedKey + ) + ) + + expect(onResponse).toHaveBeenCalledTimes(1) + expect(internals.subscriptions.size).toBe(0) + initialSocket.onclose?.() + const replacementSocket = new FakeWebSocket('ws://127.0.0.1:6768') + replacementSocket.readyState = FakeWebSocket.OPEN + internals.ws = replacementSocket + internals.sharedKey = sharedKey + internals.setState('connected') + expect(replacementSocket.send).not.toHaveBeenCalled() + client.close() + }) }) diff --git a/src/renderer/src/web/web-runtime-client.test.ts b/src/renderer/src/web/web-runtime-client.test.ts index c96bcbe9b68..4a15fcce2ef 100644 --- a/src/renderer/src/web/web-runtime-client.test.ts +++ b/src/renderer/src/web/web-runtime-client.test.ts @@ -205,161 +205,47 @@ describe('WebRuntimeClient', () => { } }) - it('replays an active file watch on reconnect and keeps its logical handle', async () => { + it('delivers plain unary responses to their subscription instead of dropping them', async () => { const client = new WebRuntimeClient({ v: 2, endpoint: 'ws://127.0.0.1:6768', deviceToken: 'token', publicKeyB64: Buffer.alloc(32).toString('base64') }) - const initialSocket = fakeSockets[0]! - initialSocket.readyState = FakeWebSocket.OPEN - const onResponse = vi.fn() - const onClose = vi.fn() - const internals = client as unknown as { - ws: FakeWebSocket | null - sharedKey: Uint8Array | null - state: string - subscriptions: Map< - string, - { - callbacks: { onResponse: typeof onResponse } - needsReplay: boolean - } - > - setState(next: string): void - handleSocketClosed(socket: FakeWebSocket): void + try { + const keyPair = generateKeyPair() + const sharedKey = deriveSharedKey(keyPair.secretKey, keyPair.publicKey) + const internals = client as unknown as { + state: string + sharedKey: Uint8Array | null + subscriptions: Map< + string, + { callbacks: { onResponse: (response: unknown) => void; onClose?: () => void } } + > + handleSocketMessage: (rawData: unknown, sourceWs?: unknown) => Promise + } + internals.state = 'connected' + internals.sharedKey = sharedKey + const onResponse = vi.fn() + internals.subscriptions.set('req-1', { callbacks: { onResponse } }) + + // A unary RPC (e.g. abortable git.status) answered over the subscription + // bridge: not streaming and neither an end nor a scrollback frame. + const response = { + id: 'req-1', + ok: true, + result: { entries: [], conflictOperation: 'unknown' }, + _meta: { runtimeId: 'runtime-1' } + } + await internals.handleSocketMessage(encrypt(JSON.stringify(response), sharedKey)) + + expect(onResponse).toHaveBeenCalledWith(response) + // The caller owns the unsubscribe; a plain reply must not close it. + expect(internals.subscriptions.has('req-1')).toBe(true) + internals.subscriptions.delete('req-1') + } finally { + client.close() } - internals.sharedKey = new Uint8Array(32) - internals.state = 'connected' - - const handle = await client.subscribe( - 'files.watch', - { worktree: 'wt-1' }, - { onResponse, onClose } - ) - const firstId = Array.from(internals.subscriptions.keys())[0] - expect(initialSocket.send).toHaveBeenCalledTimes(1) - - initialSocket.onclose?.() - - expect(onClose).not.toHaveBeenCalled() - expect(internals.subscriptions.get(firstId!)?.needsReplay).toBe(true) - - const replacementSocket = new FakeWebSocket('ws://127.0.0.1:6768') - replacementSocket.readyState = FakeWebSocket.OPEN - internals.ws = replacementSocket - internals.sharedKey = new Uint8Array(32) - internals.setState('connected') - - const [replacementId, replacement] = Array.from(internals.subscriptions.entries())[0]! - expect(replacementId).not.toBe(firstId) - expect(replacementSocket.send).toHaveBeenCalledTimes(1) - replacement.callbacks.onResponse({ - id: replacementId, - ok: true, - streaming: true, - result: { type: 'changed', worktree: 'wt-1', events: [] }, - _meta: { runtimeId: 'runtime-web-test' } - }) - expect(onResponse).toHaveBeenCalledTimes(1) - - handle.unsubscribe() - internals.handleSocketClosed(replacementSocket) - expect(internals.subscriptions.size).toBe(0) - const secondReplacementSocket = new FakeWebSocket('ws://127.0.0.1:6768') - secondReplacementSocket.readyState = FakeWebSocket.OPEN - internals.ws = secondReplacementSocket - internals.sharedKey = new Uint8Array(32) - internals.setState('connected') - expect(secondReplacementSocket.send).not.toHaveBeenCalled() - client.close() - expect(internals.subscriptions.size).toBe(0) - }) - - it('does not replay a file watch stopped after its socket disconnects', async () => { - const client = new WebRuntimeClient({ - v: 2, - endpoint: 'ws://127.0.0.1:6768', - deviceToken: 'token', - publicKeyB64: Buffer.alloc(32).toString('base64') - }) - const initialSocket = fakeSockets[0]! - initialSocket.readyState = FakeWebSocket.OPEN - const internals = client as unknown as { - ws: FakeWebSocket | null - sharedKey: Uint8Array | null - state: string - subscriptions: Map - setState(next: string): void - } - internals.sharedKey = new Uint8Array(32) - internals.state = 'connected' - const handle = await client.subscribe( - 'files.watch', - { worktree: 'wt-1' }, - { onResponse: vi.fn() } - ) - - initialSocket.onclose?.() - expect(Array.from(internals.subscriptions.values())[0]?.needsReplay).toBe(true) - handle.unsubscribe() - expect(internals.subscriptions.size).toBe(0) - - const replacementSocket = new FakeWebSocket('ws://127.0.0.1:6768') - replacementSocket.readyState = FakeWebSocket.OPEN - internals.ws = replacementSocket - internals.sharedKey = new Uint8Array(32) - internals.setState('connected') - expect(replacementSocket.send).not.toHaveBeenCalled() - client.close() - }) - - it('evicts a failed file-watch setup before reconnect can replay it', async () => { - const client = new WebRuntimeClient({ - v: 2, - endpoint: 'ws://127.0.0.1:6768', - deviceToken: 'token', - publicKeyB64: Buffer.alloc(32).toString('base64') - }) - const initialSocket = fakeSockets[0]! - initialSocket.readyState = FakeWebSocket.OPEN - const sharedKey = new Uint8Array(32) - const onResponse = vi.fn() - const internals = client as unknown as { - ws: FakeWebSocket | null - sharedKey: Uint8Array | null - state: string - subscriptions: Map - handleSocketMessage(rawData: unknown): Promise - setState(next: string): void - } - internals.sharedKey = sharedKey - internals.state = 'connected' - await client.subscribe('files.watch', { worktree: 'wt-1' }, { onResponse }) - const subscriptionId = Array.from(internals.subscriptions.keys())[0]! - - await internals.handleSocketMessage( - encrypt( - JSON.stringify({ - id: subscriptionId, - ok: false, - error: { code: 'watch_failed', message: 'root unavailable' } - }), - sharedKey - ) - ) - - expect(onResponse).toHaveBeenCalledTimes(1) - expect(internals.subscriptions.size).toBe(0) - initialSocket.onclose?.() - const replacementSocket = new FakeWebSocket('ws://127.0.0.1:6768') - replacementSocket.readyState = FakeWebSocket.OPEN - internals.ws = replacementSocket - internals.sharedKey = sharedKey - internals.setState('connected') - expect(replacementSocket.send).not.toHaveBeenCalled() - client.close() }) it('keeps file watches on the owning WebSocket instead of opening child clients', async () => { diff --git a/src/renderer/src/web/web-runtime-client.ts b/src/renderer/src/web/web-runtime-client.ts index 59d7e696fc0..0162dc24ff9 100644 --- a/src/renderer/src/web/web-runtime-client.ts +++ b/src/renderer/src/web/web-runtime-client.ts @@ -564,14 +564,15 @@ export class WebRuntimeClient { } const subscription = this.subscriptions.get(response.id) - if (subscription && isSubscriptionResponse(response)) { - // Why: setup failures are terminal. Evict before callbacks so a - // synchronous retry creates a fresh watch and reconnect cannot replay it. - if (response.ok === false) { + if (subscription) { + const subscriptionResponse = response as RuntimeRpcResponse + // Why: setup failures must be evicted before callbacks so reconnect cannot replay them. + if (subscriptionResponse.ok === false) { this.subscriptions.delete(response.id) } - subscription.callbacks.onResponse(response) - if (response.ok && isEndResult(response.result)) { + // Why: subscription-backed unary RPCs can return ordinary success frames. + subscription.callbacks.onResponse(subscriptionResponse) + if (subscriptionResponse.ok && isEndResult(subscriptionResponse.result)) { this.subscriptions.delete(response.id) subscription.callbacks.onClose?.() } @@ -862,21 +863,6 @@ export class WebRuntimeClient { } } -function isSubscriptionResponse( - response: RuntimeRpcResponse | Record -): response is RuntimeRpcResponse { - if (!('ok' in response)) { - return false - } - if (response.ok === false) { - return true - } - const success = response as RuntimeRpcResponse & { ok: true; streaming?: true } - return ( - success.streaming === true || isEndResult(success.result) || isScrollbackResult(success.result) - ) -} - function isRuntimeFailureResponse( response: RuntimeRpcResponse | Record ): response is RuntimeRpcResponse & { ok: false } { @@ -940,10 +926,6 @@ function isEndResult(value: unknown): value is { type: 'end' } { return !!value && typeof value === 'object' && (value as { type?: unknown }).type === 'end' } -function isScrollbackResult(value: unknown): value is { type: 'scrollback' } { - return !!value && typeof value === 'object' && (value as { type?: unknown }).type === 'scrollback' -} - async function websocketPayloadToUint8( value: unknown ): Promise | null> { diff --git a/src/shared/git-status-line-stats-cache.test.ts b/src/shared/git-status-line-stats-cache.test.ts new file mode 100644 index 00000000000..7c4034daad4 --- /dev/null +++ b/src/shared/git-status-line-stats-cache.test.ts @@ -0,0 +1,202 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { + applyCachedGitStatusLineStats, + beginGitStatusLineStatsCacheWrite, + clearGitStatusLineStatsCache, + clearGitStatusLineStatsCacheKey, + GIT_STATUS_LINE_STATS_CACHE_MAX_AGE_MS, + reuseOrRecomputeGitStatusLineStats, + storeGitStatusLineStats +} from './git-status-line-stats-cache' + +const cachedEntries = [ + { + path: 'src/a.ts', + status: 'modified', + area: 'unstaged', + added: 3, + removed: 2 + } +] + +describe('git status line stats cache', () => { + beforeEach(() => { + clearGitStatusLineStatsCache() + }) + + it('reuses counts only for an exact head and status-entry identity', () => { + storeGitStatusLineStats({ cacheKey: 'native\0/repo', head: 'head-1', entries: cachedEntries }) + const matchingEntries = [{ path: 'src/a.ts', status: 'modified', area: 'unstaged' }] + + expect( + applyCachedGitStatusLineStats({ + cacheKey: 'native\0/repo', + head: 'head-1', + entries: matchingEntries + }) + ).toBe(true) + expect(matchingEntries).toEqual(cachedEntries) + + expect( + applyCachedGitStatusLineStats({ + cacheKey: 'native\0/repo', + head: 'head-2', + entries: [{ path: 'src/a.ts', status: 'modified', area: 'unstaged' }] + }) + ).toBe(false) + }) + + it('invalidates a matching status identity at the bounded age', () => { + storeGitStatusLineStats({ + cacheKey: 'native\0/repo', + head: 'head-1', + entries: cachedEntries, + now: 1_000 + }) + + expect( + applyCachedGitStatusLineStats({ + cacheKey: 'native\0/repo', + head: 'head-1', + entries: [{ path: 'src/a.ts', status: 'modified', area: 'unstaged' }], + now: 1_000 + GIT_STATUS_LINE_STATS_CACHE_MAX_AGE_MS + }) + ).toBe(false) + }) + + it('isolates execution-host keys and supports mutation invalidation', () => { + storeGitStatusLineStats({ + cacheKey: 'wsl:ubuntu\0/repo', + head: 'head-1', + entries: cachedEntries + }) + + expect( + applyCachedGitStatusLineStats({ + cacheKey: 'wsl:debian\0/repo', + head: 'head-1', + entries: [{ path: 'src/a.ts', status: 'modified', area: 'unstaged' }] + }) + ).toBe(false) + + clearGitStatusLineStatsCacheKey('wsl:ubuntu\0/repo') + expect( + applyCachedGitStatusLineStats({ + cacheKey: 'wsl:ubuntu\0/repo', + head: 'head-1', + entries: [{ path: 'src/a.ts', status: 'modified', area: 'unstaged' }] + }) + ).toBe(false) + }) + + it('lets an earlier recompute store after a later reuse-only read began', () => { + const recomputeWrite = beginGitStatusLineStatsCacheWrite('native\0/repo') + // A hinted safety read begins later, reuses, and never stores; it must not + // starve the older recompute's store. + beginGitStatusLineStatsCacheWrite('native\0/repo') + storeGitStatusLineStats({ + cacheKey: 'native\0/repo', + head: 'head-1', + entries: cachedEntries, + writeToken: recomputeWrite + }) + + const entries: { path: string; status: string; area: string; added?: number }[] = [ + { path: 'src/a.ts', status: 'modified', area: 'unstaged' } + ] + expect( + applyCachedGitStatusLineStats({ cacheKey: 'native\0/repo', head: 'head-1', entries }) + ).toBe(true) + expect(entries[0]?.added).toBe(3) + }) + + it('keeps a prior good snapshot reusable when an overlapping recompute is aborted', async () => { + // A completed scan stored healthy counts. + storeGitStatusLineStats({ cacheKey: 'native\0/repo', head: 'head-1', entries: cachedEntries }) + + // A later scan begins and recomputes but is aborted before it can store. + const abortedWrite = beginGitStatusLineStatsCacheWrite('native\0/repo') + let aborted = false + await expect( + reuseOrRecomputeGitStatusLineStats({ + cacheKey: 'native\0/repo', + head: 'head-1', + entries: [{ path: 'src/a.ts', status: 'modified', area: 'unstaged' }], + writeToken: abortedWrite, + reuse: false, + isAborted: () => aborted, + recompute: async () => { + aborted = true + return true + } + }) + ).rejects.toMatchObject({ name: 'AbortError' }) + + // The aborted pass persisted nothing, so it must not evict the healthy + // snapshot — a following safety read still reuses the stored counts. + const entries: { path: string; status: string; area: string; added?: number }[] = [ + { path: 'src/a.ts', status: 'modified', area: 'unstaged' } + ] + expect( + applyCachedGitStatusLineStats({ cacheKey: 'native\0/repo', head: 'head-1', entries }) + ).toBe(true) + expect(entries[0]?.added).toBe(3) + }) + + it('rejects a reuse-only read that is already aborted', async () => { + storeGitStatusLineStats({ cacheKey: 'native\0/repo', head: 'head-1', entries: cachedEntries }) + const writeToken = beginGitStatusLineStatsCacheWrite('native\0/repo') + await expect( + reuseOrRecomputeGitStatusLineStats({ + cacheKey: 'native\0/repo', + head: 'head-1', + entries: [{ path: 'src/a.ts', status: 'modified', area: 'unstaged' }], + writeToken, + reuse: true, + isAborted: () => true, + recompute: async () => true + }) + ).rejects.toMatchObject({ name: 'AbortError' }) + }) + + it('rejects stale cache writes and writes started before invalidation', () => { + const olderWrite = beginGitStatusLineStatsCacheWrite('native\0/repo') + const latestWrite = beginGitStatusLineStatsCacheWrite('native\0/repo') + storeGitStatusLineStats({ + cacheKey: 'native\0/repo', + head: 'head-1', + entries: [{ ...cachedEntries[0], added: 8 }], + writeToken: latestWrite + }) + storeGitStatusLineStats({ + cacheKey: 'native\0/repo', + head: 'head-1', + entries: cachedEntries, + writeToken: olderWrite + }) + const entries: { path: string; status: string; area: string; added?: number }[] = [ + { path: 'src/a.ts', status: 'modified', area: 'unstaged' } + ] + + expect( + applyCachedGitStatusLineStats({ cacheKey: 'native\0/repo', head: 'head-1', entries }) + ).toBe(true) + expect(entries[0]?.added).toBe(8) + + const invalidatedWrite = beginGitStatusLineStatsCacheWrite('native\0/other') + clearGitStatusLineStatsCache() + storeGitStatusLineStats({ + cacheKey: 'native\0/other', + head: 'head-1', + entries: cachedEntries, + writeToken: invalidatedWrite + }) + expect( + applyCachedGitStatusLineStats({ + cacheKey: 'native\0/other', + head: 'head-1', + entries: [{ path: 'src/a.ts', status: 'modified', area: 'unstaged' }] + }) + ).toBe(false) + }) +}) diff --git a/src/shared/git-status-line-stats-cache.ts b/src/shared/git-status-line-stats-cache.ts new file mode 100644 index 00000000000..9a3ccf59356 --- /dev/null +++ b/src/shared/git-status-line-stats-cache.ts @@ -0,0 +1,256 @@ +type GitStatusLineStatsEntry = { + path?: unknown + status?: unknown + area?: unknown + oldPath?: unknown + conflictKind?: unknown + conflictStatus?: unknown + conflictStatusSource?: unknown + submodule?: { + commitChanged?: boolean + trackedChanges?: boolean + untrackedChanges?: boolean + } + added?: number + removed?: number +} + +type CachedLineStats = { + identity: string + storedAt: number + stats: { added?: number; removed?: number }[] +} + +export type GitStatusLineStatsWriteToken = { + cacheKey: string + globalGeneration: number + keyGeneration: number + beginSeq: number +} + +// Why: the TTL is the sole staleness backstop when file contents change while +// the porcelain identity stays "modified" (added/removed are excluded from the +// reuse identity), so a missed watcher signal pins counts for at most this long. +export const GIT_STATUS_LINE_STATS_CACHE_MAX_AGE_MS = 2 * 60_000 +const GIT_STATUS_LINE_STATS_CACHE_MAX_ENTRIES = 128 +const GIT_STATUS_LINE_STATS_WRITE_KEYS_MAX_ENTRIES = 1024 +const lineStatsByWorktree = new Map() +// Why: mutation invalidation must retire scans that began before it. A scan +// captures these generations at begin; a mismatch at store/clear time means an +// invalidation happened mid-scan and the derived stats may be pre-mutation. +let globalInvalidationGeneration = 0 +const keyInvalidationGenerationByWorktree = new Map() +// Why: overlapping recomputes must resolve latest-begun-wins without letting a +// reuse-only read (which never stores) starve an older recompute's store. +const lastStoredBeginSeqByWorktree = new Map() +let nextBeginSeq = 0 + +// Why: wall-clock steps (NTP, VM resume) must not extend or shrink the TTL. +const monotonicNowMs = (): number => performance.now() + +function bumpBoundedKeyMap(map: Map, cacheKey: string, value: number): void { + map.delete(cacheKey) + map.set(cacheKey, value) + while (map.size > GIT_STATUS_LINE_STATS_WRITE_KEYS_MAX_ENTRIES) { + const oldestKey = map.keys().next().value + if (oldestKey === undefined) { + return + } + map.delete(oldestKey) + } +} + +export function beginGitStatusLineStatsCacheWrite(cacheKey: string): GitStatusLineStatsWriteToken { + return { + cacheKey, + globalGeneration: globalInvalidationGeneration, + keyGeneration: keyInvalidationGenerationByWorktree.get(cacheKey) ?? 0, + beginSeq: ++nextBeginSeq + } +} + +function isWriteTokenCurrent(token: GitStatusLineStatsWriteToken): boolean { + return ( + token.globalGeneration === globalInvalidationGeneration && + token.keyGeneration === (keyInvalidationGenerationByWorktree.get(token.cacheKey) ?? 0) && + token.beginSeq >= (lastStoredBeginSeqByWorktree.get(token.cacheKey) ?? 0) + ) +} + +function createInputIdentity(head: string | undefined, entries: GitStatusLineStatsEntry[]): string { + return JSON.stringify([ + head ?? null, + entries.map((entry) => [ + entry.path, + entry.status, + entry.area, + entry.oldPath ?? null, + entry.conflictKind ?? null, + entry.conflictStatus ?? null, + entry.conflictStatusSource ?? null, + entry.submodule?.commitChanged ?? null, + entry.submodule?.trackedChanges ?? null, + entry.submodule?.untrackedChanges ?? null + ]) + ]) +} + +function trimLineStatsCache(now: number): void { + for (const [key, cached] of lineStatsByWorktree) { + if (now - cached.storedAt >= GIT_STATUS_LINE_STATS_CACHE_MAX_AGE_MS) { + lineStatsByWorktree.delete(key) + } + } + while (lineStatsByWorktree.size > GIT_STATUS_LINE_STATS_CACHE_MAX_ENTRIES) { + const oldestKey = lineStatsByWorktree.keys().next().value + if (oldestKey === undefined) { + return + } + lineStatsByWorktree.delete(oldestKey) + } +} + +export function applyCachedGitStatusLineStats(input: { + cacheKey: string + head?: string + entries: GitStatusLineStatsEntry[] + now?: number +}): boolean { + const now = input.now ?? monotonicNowMs() + const cached = lineStatsByWorktree.get(input.cacheKey) + if (!cached) { + return false + } + if ( + now - cached.storedAt >= GIT_STATUS_LINE_STATS_CACHE_MAX_AGE_MS || + cached.identity !== createInputIdentity(input.head, input.entries) + ) { + lineStatsByWorktree.delete(input.cacheKey) + return false + } + + lineStatsByWorktree.delete(input.cacheKey) + lineStatsByWorktree.set(input.cacheKey, cached) + input.entries.forEach((entry, index) => { + const stats = cached.stats[index] + if (stats?.added !== undefined) { + entry.added = stats.added + } + if (stats?.removed !== undefined) { + entry.removed = stats.removed + } + }) + return true +} + +export function storeGitStatusLineStats(input: { + cacheKey: string + head?: string + entries: GitStatusLineStatsEntry[] + now?: number + writeToken?: GitStatusLineStatsWriteToken +}): void { + const writeToken = input.writeToken ?? beginGitStatusLineStatsCacheWrite(input.cacheKey) + if (!isWriteTokenCurrent(writeToken)) { + return + } + bumpBoundedKeyMap(lastStoredBeginSeqByWorktree, input.cacheKey, writeToken.beginSeq) + const now = input.now ?? monotonicNowMs() + lineStatsByWorktree.delete(input.cacheKey) + lineStatsByWorktree.set(input.cacheKey, { + identity: createInputIdentity(input.head, input.entries), + storedAt: now, + stats: input.entries.map((entry) => ({ + ...(entry.added === undefined ? {} : { added: entry.added }), + ...(entry.removed === undefined ? {} : { removed: entry.removed }) + })) + }) + trimLineStatsCache(now) +} + +/** + * Shared post-status line-stat step for every host that executes Git. Reuses + * the cached snapshot only for hinted safety reads; otherwise recomputes and + * stores. `recompute` returns false when the counts are incomplete (e.g. a + * transient numstat failure) so a failed pass never replaces or pins a + * snapshot, and the previous good counts stay reusable. + */ +function createGitStatusLineStatsAbortError(): Error { + const error = new Error('The operation was aborted.') + error.name = 'AbortError' + return error +} + +export async function reuseOrRecomputeGitStatusLineStats(input: { + cacheKey: string + head?: string + entries: GitStatusLineStatsEntry[] + writeToken: GitStatusLineStatsWriteToken + reuse: boolean + isAborted: () => boolean + recompute: () => Promise +}): Promise { + if (input.isAborted()) { + // Why: reject rather than resolve — a cancelled scan must not look like a + // completed status result (including a cache-hit reuse path). + throw createGitStatusLineStatsAbortError() + } + if ( + input.reuse && + applyCachedGitStatusLineStats({ + cacheKey: input.cacheKey, + head: input.head, + entries: input.entries + }) + ) { + if (input.isAborted()) { + throw createGitStatusLineStatsAbortError() + } + return + } + const complete = await input.recompute() + if (input.isAborted()) { + // Why: an aborted pass never reached storeGitStatusLineStats, so there is + // nothing partial to undo; clearing here would instead evict a concurrent + // scan's healthy snapshot and force a redundant numstat on the next read. + // Reject so the caller cannot treat this pass as a successful status. + throw createGitStatusLineStatsAbortError() + } + if (!complete) { + return + } + storeGitStatusLineStats({ + cacheKey: input.cacheKey, + head: input.head, + entries: input.entries, + writeToken: input.writeToken + }) +} + +export function clearGitStatusLineStatsCache(): void { + globalInvalidationGeneration += 1 + lineStatsByWorktree.clear() + keyInvalidationGenerationByWorktree.clear() + lastStoredBeginSeqByWorktree.clear() +} + +export function clearGitStatusLineStatsCacheKey( + cacheKey: string, + writeToken?: GitStatusLineStatsWriteToken +): void { + if (writeToken !== undefined && !isWriteTokenCurrent(writeToken)) { + return + } + if (writeToken === undefined) { + bumpBoundedKeyMap( + keyInvalidationGenerationByWorktree, + cacheKey, + (keyInvalidationGenerationByWorktree.get(cacheKey) ?? 0) + 1 + ) + } else { + // Why: a token-scoped purge must retire scans that began before it, so an + // older in-flight scan can't store pre-purge counts and repopulate this key. + bumpBoundedKeyMap(lastStoredBeginSeqByWorktree, cacheKey, writeToken.beginSeq) + } + lineStatsByWorktree.delete(cacheKey) +} diff --git a/src/shared/git-uncommitted-line-stats.test.ts b/src/shared/git-uncommitted-line-stats.test.ts index d9e876113aa..001e9ef9efa 100644 --- a/src/shared/git-uncommitted-line-stats.test.ts +++ b/src/shared/git-uncommitted-line-stats.test.ts @@ -136,6 +136,15 @@ describe('collectUntrackedAdditions', () => { expect(readFileMock).toHaveBeenCalledTimes(1) }) + it('rejects when the status request is aborted instead of returning partial counts', async () => { + const controller = new AbortController() + controller.abort() + await expect( + collectUntrackedAdditions('/repo', ['a.ts'], controller.signal) + ).rejects.toMatchObject({ name: 'AbortError' }) + expect(lstatMock).not.toHaveBeenCalled() + }) + it('keeps the cache effective across polls for a status-limit-sized change set', async () => { // Why: git status caps at DEFAULT_GIT_STATUS_LIMIT (10,000) entries. A // cache smaller than one scan FIFO-evicts every entry mid-scan, so the diff --git a/src/shared/git-uncommitted-line-stats.ts b/src/shared/git-uncommitted-line-stats.ts index 3fe4c5cc99d..ac46b928486 100644 --- a/src/shared/git-uncommitted-line-stats.ts +++ b/src/shared/git-uncommitted-line-stats.ts @@ -176,12 +176,25 @@ function rememberUntrackedStats( // Untracked files have no git-tracked baseline, so `git diff` ignores them. // We count their contents directly to show an additions magnitude. +function createGitLineStatsAbortError(): Error { + const error = new Error('The operation was aborted.') + error.name = 'AbortError' + return error +} + export async function collectUntrackedAdditions( worktreePath: string, - untrackedPaths: readonly string[] + untrackedPaths: readonly string[], + signal?: AbortSignal ): Promise> { const result = new Map() for (let i = 0; i < untrackedPaths.length; i += UNTRACKED_READ_CONCURRENCY) { + // Why: an aborted refresh must reject (not resolve partial counts) so a + // cancelled scan cannot look like a completed status result, and so we + // stop burning (possibly remote-host) file I/O after cancellation. + if (signal?.aborted) { + throw createGitLineStatsAbortError() + } const chunk = untrackedPaths.slice(i, i + UNTRACKED_READ_CONCURRENCY) await Promise.all( chunk.map(async (relativePath) => { @@ -189,6 +202,9 @@ export async function collectUntrackedAdditions( }) ) } + if (signal?.aborted) { + throw createGitLineStatsAbortError() + } return result } From 1ba71d2cee59d12687a32e1d595247b0c2677a85 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:14:22 -0700 Subject: [PATCH 08/38] fix(mobile): background-mount never-mounted terminals on subscribe so they don't render blank (STA-1840) (#8811) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(mobile): background-mount never-mounted terminals on subscribe so they don't render blank (STA-1840) A mobile terminal.subscribe to a tab the desktop never mounted this session — a workspace the desktop isn't currently showing, a cold-activation-deferred tab (#8597), or a cold-parked tab — has no attached PTY, so the runtime has no headless emulator and the initial snapshot is empty. The mobile terminal then renders blank (repro: on the phone, scroll to an old workspace whose terminals the desktop unmounted; they stay blank). When a mobile subscribe hits an empty snapshot (or resolves no PTY), the runtime now asks the renderer to background-mount that tab so the PTY attaches and the live data stream — already subscribed on that path — delivers its output. A never-mounted workspace has no renderer-graph leaf, so its terminals are surfaced to mobile via synthetic pty: handles that carry no real UI tabId. The runtime passes the ptyId through the mount request and the renderer resolves the owning tab from the persisted tab model (resolveTerminalTabIdForPtyId), falling back to a whole-worktree background mount if it cannot. The request is idempotent for already-mounted tabs and never unmounts a live pane, so the worst case is the same blank as before — no regression. Validated on an iOS simulator with a two-workspace desktop: opening the workspace the desktop was not showing rendered a fully blank terminal without the fix and the live shell prompt with it. Tests: runtime mount-request gating (real-tab / pty-form / no-ptyId / unknown), subscribe-handler empty-snapshot-vs-content, and the ptyId->tabId resolver; all 608 RPC method tests pass. * test(mobile): mock onRequestTerminalTabMount in useIpcEvents tests (fixes CI for #8811) * fix(mobile): harden blank terminal recovery mounts * perf(mobile): avoid redundant terminal recovery mounts * fix(mobile): ignore stale terminal mount requests * fix(mobile): skip recovery mounts for closed streams * fix(mobile): reject ambiguous terminal mount ownership * fix(mobile): preserve terminal handle through recovery mount * fix(mobile): recover legacy terminal history safely --------- Co-authored-by: Jinwoo-H --- src/main/ipc/pty.test.ts | 108 +++++ src/main/ipc/pty.ts | 123 +++-- ...erer-terminal-serializer-readiness.test.ts | 81 ++++ .../renderer-terminal-serializer-readiness.ts | 129 ++++++ src/main/runtime/orca-runtime.test.ts | 22 + src/main/runtime/orca-runtime.ts | 125 ++++- src/main/runtime/rpc/methods/terminal.ts | 191 ++++++++ .../terminal-subscribe-blank-mount.test.ts | 235 ++++++++++ .../rpc/terminal-subscribe-buffer.test.ts | 1 + .../terminal-subscribe-mount-replay.test.ts | 437 ++++++++++++++++++ ...subscribe-renderer-recovery-output.test.ts | 109 +++++ ...erminal-mobile-subscribe-tab-mount.test.ts | 162 +++++++ src/preload/api-types.ts | 6 +- src/preload/index.ts | 15 +- .../pty-buffer-serializer.test.ts | 21 + .../terminal-pane/pty-buffer-serializer.ts | 4 + .../terminal-pane/pty-connection.test.ts | 92 +++- .../terminal-pane/pty-connection.ts | 93 +++- src/renderer/src/hooks/useIpcEvents.test.ts | 11 + src/renderer/src/hooks/useIpcEvents.ts | 28 ++ .../src/lib/mobile-terminal-tab-mount.test.ts | 78 ++++ .../src/lib/mobile-terminal-tab-mount.ts | 41 ++ .../src/lib/terminal-tab-for-pty-id.test.ts | 60 +++ .../src/lib/terminal-tab-for-pty-id.ts | 27 ++ src/renderer/src/web/web-preload-api.ts | 2 + 25 files changed, 2140 insertions(+), 61 deletions(-) create mode 100644 src/main/ipc/renderer-terminal-serializer-readiness.test.ts create mode 100644 src/main/ipc/renderer-terminal-serializer-readiness.ts create mode 100644 src/main/runtime/rpc/terminal-subscribe-blank-mount.test.ts create mode 100644 src/main/runtime/rpc/terminal-subscribe-mount-replay.test.ts create mode 100644 src/main/runtime/rpc/terminal-subscribe-renderer-recovery-output.test.ts create mode 100644 src/main/runtime/terminal-mobile-subscribe-tab-mount.test.ts create mode 100644 src/renderer/src/lib/mobile-terminal-tab-mount.test.ts create mode 100644 src/renderer/src/lib/mobile-terminal-tab-mount.ts create mode 100644 src/renderer/src/lib/terminal-tab-for-pty-id.test.ts create mode 100644 src/renderer/src/lib/terminal-tab-for-pty-id.ts diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 989561da4e9..7ef9762b510 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -6101,6 +6101,7 @@ describe('registerPtyHandlers', () => { env?: Record }): Promise<{ id: string }> hasRendererSerializer?(ptyId: string): boolean + getRendererSerializerGeneration?(ptyId: string): number } let controller: RuntimeSpawnController | null = null const runtime = { @@ -6128,10 +6129,117 @@ describe('registerPtyHandlers', () => { worktreeId: 'wt-1', env: { ORCA_PANE_KEY: ` ${paneKey} ` } }) + const replacementGen = (await handlers.get('pty:declarePendingPaneSerializer')!(null, { + paneKey + })) as number expect(spawnController.hasRendererSerializer?.(result.id)).toBe(false) await handlers.get('pty:settlePaneSerializer')!(null, { paneKey, gen }) + expect(spawnController.hasRendererSerializer?.(result.id)).toBe(false) + expect(spawnController.getRendererSerializerGeneration?.(result.id)).toBe(0) + await handlers.get('pty:settlePaneSerializer')!(null, { paneKey, gen: replacementGen }) expect(spawnController.hasRendererSerializer?.(result.id)).toBe(true) + expect(spawnController.getRendererSerializerGeneration?.(result.id)).toBe(1) + }) + + it('does not let old teardown cancel serializer settlement for a reused PTY id', async () => { + type RuntimeSpawnController = { + spawn(args: { + cols: number + rows: number + worktreeId?: string + env?: Record + }): Promise<{ id: string }> + getRendererSerializerGeneration?(ptyId: string): number + hasRendererSerializer?(ptyId: string): boolean + waitForRendererSerializer?( + ptyId: string, + afterGeneration: number, + timeoutMs?: number + ): Promise + } + const reusedPtyId = 'pty-reused' + setLocalPtyProvider({ + spawn: vi.fn(async () => ({ id: reusedPtyId })), + 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) + let controller: RuntimeSpawnController | null = null + const runtime = { + setPtyController: vi.fn((value) => { + controller = value + }), + preAllocateHandleForPty: vi.fn(() => null), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + registerPtyHandlers(mainWindow as never, runtime as never) + const paneKey = makePaneKey('tab-reused', '33333333-3333-4333-8333-333333333333') + const spawnController = controller as unknown as RuntimeSpawnController + const spawn = async (): Promise => { + await spawnController.spawn({ + cols: 80, + rows: 24, + worktreeId: 'wt-1', + env: { ORCA_PANE_KEY: paneKey } + }) + } + + const firstGen = (await handlers.get('pty:declarePendingPaneSerializer')!(null, { + paneKey + })) as number + await spawn() + await handlers.get('pty:settlePaneSerializer')!(null, { paneKey, gen: firstGen }) + const priorGeneration = spawnController.getRendererSerializerGeneration?.(reusedPtyId) ?? 0 + + const secondGen = (await handlers.get('pty:declarePendingPaneSerializer')!(null, { + paneKey + })) as number + await spawn() + const ready = spawnController.waitForRendererSerializer?.( + reusedPtyId, + priorGeneration, + 1_000 + ) + clearProviderPtyState(reusedPtyId) + clearProviderPtyState(reusedPtyId) + await handlers.get('pty:settlePaneSerializer')!(null, { paneKey, gen: secondGen }) + + await expect(ready).resolves.toBe(true) + expect(spawnController.hasRendererSerializer?.(reusedPtyId)).toBe(true) + }) + + it('tracks exact remote-runtime serializer readiness without a local spawn mapping', async () => { + type RuntimeSpawnController = { + hasRendererSerializer?(ptyId: string): boolean + getRendererSerializerGeneration?(ptyId: string): number + } + let controller: RuntimeSpawnController | null = null + const runtime = { + setPtyController: vi.fn((value) => { + controller = value + }) + } + + registerPtyHandlers(mainWindow as never, runtime as never) + const ptyId = 'remote:env-1@@terminal-1' + const spawnController = controller as unknown as RuntimeSpawnController + + expect(spawnController.hasRendererSerializer?.(ptyId)).toBe(false) + await handlers.get('pty:reportRendererSerializerReady')!(null, { ptyId: 'local-pty' }) + expect(spawnController.hasRendererSerializer?.('local-pty')).toBe(false) + await handlers.get('pty:reportRendererSerializerReady')!(null, { ptyId }) + expect(spawnController.hasRendererSerializer?.(ptyId)).toBe(true) + expect(spawnController.getRendererSerializerGeneration?.(ptyId)).toBe(1) }) it('clears pending pane serializer declarations when their renderer is destroyed', async () => { diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index e01b8544e8e..d04a84a506e 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -97,6 +97,7 @@ import { } from '../../shared/terminal-input' import { isRemoteAgentHooksEnabled } from '../../shared/agent-hook-relay' import { createTerminalSessionStateSaveFailureMessage } from '../../shared/terminal-session-state-save-failure' +import { RendererTerminalSerializerReadiness } from './renderer-terminal-serializer-readiness' import { readShellStartupEnvVar } from '../pty/shell-startup-env' import { isTerminalLeafId, @@ -259,15 +260,13 @@ type PaneSpawnReservationResult = { // race to spawn the same tab/leaf. Key by stable paneKey so the loser adopts // the winner's PTY instead of creating a duplicate shell. const paneSpawnReservationsByPaneKey = new Map() -// Why: at PTY spawn time we capture the gen that was pending for the spawn's -// paneKey, so teardown can settle ONLY that gen. Without this, a paneKey -// remount that replaces the pending entry with a new gen would still get -// stomped by the old PTY's teardown firing settle on the wrong gen. -const ptyPendingGenByPtyId = new Map() +// Why: bind the declaration generation directly to its spawn result. PTY ids +// are reusable and teardown callbacks carry no incarnation token, so teardown +// must never guess which pending renderer generation it owns. +const pendingPtyIdBySerializerGeneration = new Map() // Why: the runtime's hasRendererSerializer probe needs a ptyId-keyed signal. -// Populated on settlePaneSerializer (renderer has registered for this ptyId) -// and cleared on PTY teardown. -const rendererSerializerByPtyId = new Set() +// A later spawn starts a fresh incarnation; subscription abort owns waiter cleanup. +const rendererSerializerReadiness = new RendererTerminalSerializerReadiness() function parseValidPaneKey(paneKey: unknown): ReturnType { if (typeof paneKey !== 'string' || paneKey.length > 256) { @@ -305,6 +304,7 @@ function cleanupPendingPaneSerializersForSender(ownerWebContentsId: number): voi for (const [paneKey, pending] of pendingByPaneKey) { if (pending.ownerWebContentsId === ownerWebContentsId) { pendingByPaneKey.delete(paneKey) + pendingPtyIdBySerializerGeneration.delete(pending.gen) } } } @@ -320,7 +320,15 @@ function registerPendingPaneSerializerCleanup(sender: WebContents | undefined): function declarePendingPaneSerializer(paneKey: string, sender: WebContents | undefined): number { const gen = ++pendingSerializerGenSeq registerPendingPaneSerializerCleanup(sender) + const replaced = pendingByPaneKey.get(paneKey) + if (replaced) { + pendingPtyIdBySerializerGeneration.delete(replaced.gen) + } pendingByPaneKey.set(paneKey, { gen, ownerWebContentsId: sender?.id ?? null }) + const existingPtyId = paneKeyPtyId.get(paneKey) + if (existingPtyId) { + pendingPtyIdBySerializerGeneration.set(gen, existingPtyId) + } return gen } @@ -372,10 +380,12 @@ function resolvePaneSpawnReservation( return response } -function settlePendingPaneSerializer(paneKey: string, gen: number): void { - if (pendingByPaneKey.get(paneKey)?.gen === gen) { - pendingByPaneKey.delete(paneKey) +function settlePendingPaneSerializer(paneKey: string, gen: number): boolean { + if (pendingByPaneKey.get(paneKey)?.gen !== gen) { + return false } + pendingByPaneKey.delete(paneKey) + return true } export function hasPendingRendererSerializerForPaneKey(paneKey: string): boolean { @@ -1211,7 +1221,6 @@ export function clearProviderPtyState(id: string): void { return !paneKey || (stillOwnsPaneKey && stablePaneKey === paneKey) } }) - rendererSerializerByPtyId.delete(id) // Why: the hook server's per-paneKey caches (lastPrompt / lastTool) would // otherwise accumulate entries for dead panes over the process lifetime. // Use the spawn-time paneKey mapping since the server has no other way to @@ -1222,17 +1231,6 @@ export function clearProviderPtyState(id: string): void { paneKeyPtyId.delete(paneKey) } ptyPaneKey.delete(id) - // Why: drop the pre-signal pending entry only if it still belongs to THIS - // PTY's spawn generation. If a remount for the same paneKey has already - // pre-signaled a new gen, this teardown must NOT touch it — otherwise - // the second mount's hydration loses to the daemon-snapshot seed. See - // the generation-token rationale in - // docs/mobile-prefer-renderer-scrollback.md. - const ownedGen = ptyPendingGenByPtyId.get(id) - if (ownedGen !== undefined) { - settlePendingPaneSerializer(paneKey, ownedGen) - } - ptyPendingGenByPtyId.delete(id) if (stillOwnsPaneKey) { // Why: notify registered consumers AFTER we've dropped the paneKey↔ptyId // entries so a listener that re-reads the map sees the post-teardown @@ -1553,6 +1551,7 @@ export function registerPtyHandlers( ipcMain.removeHandler('pty:declarePendingPaneSerializer') ipcMain.removeHandler('pty:settlePaneSerializer') ipcMain.removeHandler('pty:clearPendingPaneSerializer') + ipcMain.removeHandler('pty:reportRendererSerializerReady') ipcMain.removeHandler('pty:getMainBufferSnapshot') ipcMain.removeHandler('pty:sideEffectSnapshot') ipcMain.removeHandler('pty:getRendererDeliveryDebugSnapshot') @@ -2793,7 +2792,13 @@ export function registerPtyHandlers( // not stack listeners and trip Node's MaxListeners=10 warning. Many // sleeping PTYs waking at once (e.g. on relaunch) routinely fan out 10+ // concurrent calls. - type SerializeResult = { data: string; cols: number; rows: number; lastTitle?: string } | null + type SerializeResult = { + data: string + cols: number + rows: number + seq?: number + lastTitle?: string + } | null const pendingSerializeRequests = new Map< string, { resolve: (result: SerializeResult) => void; timeout: NodeJS.Timeout } @@ -2819,6 +2824,7 @@ export function registerPtyHandlers( data?: unknown cols?: unknown rows?: unknown + seq?: unknown lastTitle?: unknown } | null } @@ -2833,11 +2839,20 @@ export function registerPtyHandlers( typeof snapshot.cols === 'number' && typeof snapshot.rows === 'number' ) { - const result: { data: string; cols: number; rows: number; lastTitle?: string } = { + const result: { + data: string + cols: number + rows: number + seq?: number + lastTitle?: string + } = { data: snapshot.data, cols: snapshot.cols, rows: snapshot.rows } + if (typeof snapshot.seq === 'number' && Number.isFinite(snapshot.seq)) { + result.seq = snapshot.seq + } if (typeof snapshot.lastTitle === 'string' && snapshot.lastTitle.length > 0) { result.lastTitle = snapshot.lastTitle } @@ -3335,6 +3350,15 @@ export function registerPtyHandlers( // so record their spawn-time paneKey here too. Synthetic hook titles and // paneKey-scoped cache cleanup both depend on this reverse lookup. const paneKey = rememberPaneKeyForPty(result.id, env?.ORCA_PANE_KEY) + const pendingSerializer = paneKey ? pendingByPaneKey.get(paneKey) : undefined + const inheritRendererReadiness = + result.isReattach === true && + !pendingSerializer && + rendererSerializerReadiness.has(result.id) + rendererSerializerReadiness.beginIncarnation(result.id, inheritRendererReadiness) + if (paneKey && pendingSerializer) { + pendingPtyIdBySerializerGeneration.set(pendingSerializer.gen, result.id) + } if (!args.connectionId) { registerPty({ ptyId: result.id, @@ -3548,7 +3572,13 @@ export function registerPtyHandlers( // seed (no renderer authoritative for this PTY). A registry write happens // when the renderer calls registerPtySerializer; we check via the same // pendingByPaneKey + ptyId pairing that the cooperation gate uses. - return rendererSerializerByPtyId.has(ptyId) + return rendererSerializerReadiness.has(ptyId) + }, + getRendererSerializerGeneration: (ptyId) => { + return rendererSerializerReadiness.generation(ptyId) + }, + waitForRendererSerializer: (ptyId, afterGeneration, timeoutMs, signal) => { + return rendererSerializerReadiness.wait(ptyId, afterGeneration, timeoutMs, signal) }, getSize: (ptyId) => ptySizes.get(ptyId) ?? null, resize: (ptyId, cols, rows) => { @@ -4317,14 +4347,18 @@ export function registerPtyHandlers( const rendererPreSignaled = validatedPaneKey ? pendingByPaneKey.has(validatedPaneKey) : false - const rendererAlreadyRegistered = rendererSerializerByPtyId.has(result.id) + const rendererAlreadyRegistered = + result.isReattach === true && + !rendererPreSignaled && + rendererSerializerReadiness.has(result.id) + rendererSerializerReadiness.beginIncarnation(result.id, rendererAlreadyRegistered) // Why: capture the pending gen at spawn time so teardown for THIS PTY // only settles its own generation. A remount that replaces the entry // with a new gen must not be stomped by the old PTY's teardown. if (validatedPaneKey && rendererPreSignaled) { const pending = pendingByPaneKey.get(validatedPaneKey) if (pending) { - ptyPendingGenByPtyId.set(result.id, pending.gen) + pendingPtyIdBySerializerGeneration.set(pending.gen, result.id) } } @@ -5272,15 +5306,13 @@ export function registerPtyHandlers( if (!isValidPaneKey(args.paneKey) || typeof args.gen !== 'number') { return } - settlePendingPaneSerializer(args.paneKey, args.gen) - // Why: settle means the renderer has registered its serializer locally - // for whatever ptyId came back from spawn. The renderer doesn't carry - // the ptyId back through this IPC because the cooperation gate ran - // pre-spawn; instead we mark the pane as authoritative by paneKey → - // ptyId via the existing paneKeyPtyId mapping populated at spawn. - const ptyId = paneKeyPtyId.get(args.paneKey) - if (ptyId) { - rendererSerializerByPtyId.add(ptyId) + const ptyId = pendingPtyIdBySerializerGeneration.get(args.gen) + const settledCurrentGeneration = settlePendingPaneSerializer(args.paneKey, args.gen) + // Why: the generation-to-PTY binding survives late teardown of a reused + // id; paneKey reverse maps are provider lifecycle state and may already be gone. + pendingPtyIdBySerializerGeneration.delete(args.gen) + if (settledCurrentGeneration && ptyId) { + rendererSerializerReadiness.markReady(ptyId) } } ) @@ -5292,6 +5324,23 @@ export function registerPtyHandlers( return } settlePendingPaneSerializer(args.paneKey, args.gen) + pendingPtyIdBySerializerGeneration.delete(args.gen) + } + ) + + ipcMain.handle( + 'pty:reportRendererSerializerReady', + async (_event, args: { ptyId?: unknown }): Promise => { + if ( + typeof args.ptyId !== 'string' || + !args.ptyId.startsWith('remote:') || + args.ptyId.length > 512 + ) { + return + } + // Why: remote-runtime panes do not pass through the local spawn + // cooperation gate, so their exact PTY id is the only readiness key. + rendererSerializerReadiness.markReady(args.ptyId) } ) } diff --git a/src/main/ipc/renderer-terminal-serializer-readiness.test.ts b/src/main/ipc/renderer-terminal-serializer-readiness.test.ts new file mode 100644 index 00000000000..821aaa8f8aa --- /dev/null +++ b/src/main/ipc/renderer-terminal-serializer-readiness.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from 'vitest' +import { RendererTerminalSerializerReadiness } from './renderer-terminal-serializer-readiness' + +describe('RendererTerminalSerializerReadiness', () => { + it('resolves current and future waiters when the renderer settles', async () => { + const readiness = new RendererTerminalSerializerReadiness() + const pending = readiness.wait('pty-1', 0, 1_000) + + readiness.markReady('pty-1') + + await expect(pending).resolves.toBe(true) + expect(readiness.generation('pty-1')).toBe(1) + await expect(readiness.wait('pty-1', 0, 1_000)).resolves.toBe(true) + }) + + it('waits for a fresh settlement when historical readiness is stale', async () => { + const readiness = new RendererTerminalSerializerReadiness() + readiness.markReady('pty-1') + const pending = readiness.wait('pty-1', readiness.generation('pty-1'), 1_000) + + readiness.markReady('pty-1') + + await expect(pending).resolves.toBe(true) + expect(readiness.generation('pty-1')).toBe(2) + }) + + it('releases waiters when the PTY is cleared', async () => { + const readiness = new RendererTerminalSerializerReadiness() + readiness.markReady('pty-1') + const pending = readiness.wait('pty-1', readiness.generation('pty-1'), 1_000) + + readiness.clear('pty-1') + + await expect(pending).resolves.toBe(false) + expect(readiness.has('pty-1')).toBe(false) + }) + + it('releases waiters on abort and timeout', async () => { + vi.useFakeTimers() + const readiness = new RendererTerminalSerializerReadiness() + const abortController = new AbortController() + const aborted = readiness.wait('pty-abort', 0, 1_000, abortController.signal) + const timedOut = readiness.wait('pty-timeout', 0, 1_000) + + abortController.abort() + await vi.advanceTimersByTimeAsync(1_000) + + await expect(aborted).resolves.toBe(false) + await expect(timedOut).resolves.toBe(false) + vi.useRealTimers() + }) + + it('keeps a lifecycle waiter until readiness or abort when no timeout is supplied', async () => { + vi.useFakeTimers() + const readiness = new RendererTerminalSerializerReadiness() + const abortController = new AbortController() + const pending = readiness.wait('pty-lifecycle', 0, undefined, abortController.signal) + + await vi.advanceTimersByTimeAsync(60_000) + abortController.abort() + + await expect(pending).resolves.toBe(false) + vi.useRealTimers() + }) + + it('ignores late teardown from an older incarnation of a reused PTY id', async () => { + const readiness = new RendererTerminalSerializerReadiness() + const oldIncarnation = readiness.beginIncarnation('pty-reused') + readiness.markReady('pty-reused') + const priorGeneration = readiness.generation('pty-reused') + + readiness.beginIncarnation('pty-reused') + const pending = readiness.wait('pty-reused', priorGeneration, 1_000) + const oldTeardownCleared = readiness.clear('pty-reused', oldIncarnation) + readiness.markReady('pty-reused') + + expect(oldTeardownCleared).toBe(false) + await expect(pending).resolves.toBe(true) + expect(readiness.has('pty-reused')).toBe(true) + }) +}) diff --git a/src/main/ipc/renderer-terminal-serializer-readiness.ts b/src/main/ipc/renderer-terminal-serializer-readiness.ts new file mode 100644 index 00000000000..f58a9b2062f --- /dev/null +++ b/src/main/ipc/renderer-terminal-serializer-readiness.ts @@ -0,0 +1,129 @@ +type RendererTerminalSerializerWaiter = { + afterGeneration: number + finish: (ready: boolean) => void +} + +export class RendererTerminalSerializerReadiness { + private incarnationSequence = 0 + private readonly incarnationByPtyId = new Map() + private readonly readyIncarnationByPtyId = new Map() + private readonly generationByPtyId = new Map() + private readonly waitersByPtyId = new Map>() + + has(ptyId: string): boolean { + const incarnation = this.incarnationByPtyId.get(ptyId) + return incarnation !== undefined && this.readyIncarnationByPtyId.get(ptyId) === incarnation + } + + generation(ptyId: string): number { + return this.generationByPtyId.get(ptyId) ?? 0 + } + + beginIncarnation(ptyId: string, inheritReady = false): number { + const wasReady = this.has(ptyId) + const incarnation = ++this.incarnationSequence + this.incarnationByPtyId.set(ptyId, incarnation) + if (inheritReady && wasReady) { + this.readyIncarnationByPtyId.set(ptyId, incarnation) + } else { + this.readyIncarnationByPtyId.delete(ptyId) + } + return incarnation + } + + markReady(ptyId: string): void { + let incarnation = this.incarnationByPtyId.get(ptyId) + if (incarnation === undefined) { + incarnation = ++this.incarnationSequence + this.incarnationByPtyId.set(ptyId, incarnation) + } + this.readyIncarnationByPtyId.set(ptyId, incarnation) + const generation = this.generation(ptyId) + 1 + this.generationByPtyId.set(ptyId, generation) + const waiters = this.waitersByPtyId.get(ptyId) + if (!waiters) { + return + } + for (const waiter of waiters) { + if (generation > waiter.afterGeneration) { + waiter.finish(true) + } + } + } + + clear(ptyId: string, incarnation?: number): boolean { + if (incarnation !== undefined && this.incarnationByPtyId.get(ptyId) !== incarnation) { + return false + } + this.incarnationByPtyId.delete(ptyId) + this.readyIncarnationByPtyId.delete(ptyId) + this.generationByPtyId.delete(ptyId) + this.finishWaiters(ptyId, false) + return true + } + + private finishWaiters(ptyId: string, ready: boolean): void { + const waiters = this.waitersByPtyId.get(ptyId) + if (!waiters) { + return + } + for (const waiter of waiters) { + waiter.finish(ready) + } + } + + wait( + ptyId: string, + afterGeneration: number, + timeoutMs?: number, + signal?: AbortSignal + ): Promise { + if (this.generation(ptyId) > afterGeneration) { + return Promise.resolve(true) + } + if (signal?.aborted) { + return Promise.resolve(false) + } + + return new Promise((resolve) => { + let timer: ReturnType | null = null + let settled = false + let waiters = this.waitersByPtyId.get(ptyId) + if (!waiters) { + waiters = new Set() + this.waitersByPtyId.set(ptyId, waiters) + } + + const onAbort = (): void => finish(false) + const waiter: RendererTerminalSerializerWaiter = { + afterGeneration, + finish: (ready) => finish(ready) + } + const finish = (ready: boolean): void => { + if (settled) { + return + } + settled = true + if (timer) { + clearTimeout(timer) + timer = null + } + signal?.removeEventListener('abort', onAbort) + waiters.delete(waiter) + if (waiters.size === 0) { + this.waitersByPtyId.delete(ptyId) + } + resolve(ready) + } + + waiters.add(waiter) + signal?.addEventListener('abort', onAbort, { once: true }) + if (timeoutMs !== undefined) { + timer = setTimeout(() => finish(false), timeoutMs) + if (typeof timer.unref === 'function') { + timer.unref() + } + } + }) + } +} diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index f9e2aed305c..f64a163cfe7 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -8178,6 +8178,28 @@ describe('OrcaRuntimeService', () => { ) }) + it('replaces suffix-only headless state with the recovered renderer snapshot', async () => { + const runtime = createRuntime() + syncSinglePty(runtime, 'pty-1') + runtime.seedHeadlessTerminal('pty-1', 'suffix-only redraw', { cols: 80, rows: 24 }) + + runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery('pty-1', { + data: 'restored history\r\nprompt $ ', + cols: 80, + rows: 24, + cwd: '/projects/restored' + }) + runtime.onPtyData('pty-1', 'after recovery\r\n', 100) + + const snapshot = await runtime.serializeMainTerminalBuffer('pty-1', { + scrollbackRows: 100 + }) + expect(snapshot?.data).toContain('restored history') + expect(snapshot?.data).toContain('after recovery') + expect(snapshot?.data).not.toContain('suffix-only redraw') + expect(snapshot?.cwd).toBe('/projects/restored') + }) + it('adopts OSC7 host metadata from seeded headless terminal scrollback', async () => { const runtime = createRuntime() syncSinglePty(runtime, 'pty-1') diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 778d5feeb0e..c08e72b63b5 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1236,7 +1236,7 @@ type RuntimePtyController = { serializeBuffer?( ptyId: string, opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } - ): Promise<{ data: string; cols: number; rows: number; lastTitle?: string } | null> + ): Promise<{ data: string; cols: number; rows: number; seq?: number; lastTitle?: string } | null> /** Authoritative provider-owned snapshot for restored PTYs with no mounted renderer. */ serializeProviderBuffer?( ptyId: string, @@ -1246,6 +1246,13 @@ type RuntimePtyController = { // hydration when no renderer is authoritative for this PTY. See // docs/mobile-prefer-renderer-scrollback.md. hasRendererSerializer?(ptyId: string): boolean + getRendererSerializerGeneration?(ptyId: string): number + waitForRendererSerializer?( + ptyId: string, + afterGeneration: number, + timeoutMs?: number, + signal?: AbortSignal + ): Promise getSize?(ptyId: string): { cols: number; rows: number } | null } @@ -3301,7 +3308,13 @@ export class OrcaRuntimeService { } if (existing && (existing.ptyId !== ptyId || existing.ptyGeneration !== ptyGeneration)) { - this.invalidateLeafHandle(leafKey) + // Why: mobile can subscribe while the pane is waiting for its first PTY. + // Keep that handle usable after the recovery mount binds it. + const adoptedFirstPty = + existing.ptyId === null && this.adoptFirstPtyForLeafHandle(leafKey, ptyId, ptyGeneration) + if (!adoptedFirstPty) { + this.invalidateLeafHandle(leafKey) + } } } @@ -6960,9 +6973,9 @@ export class OrcaRuntimeService { data: string cols: number rows: number + seq?: number cwd?: string | null lastTitle?: string - seq?: number source?: 'headless' | 'renderer' oscLinks?: TerminalOscLinkRange[] alternateScreen?: boolean @@ -6972,6 +6985,10 @@ export class OrcaRuntimeService { return this.serializeTerminalBufferFromAvailableState(ptyId, opts) } + hasHeadlessTerminalState(ptyId: string): boolean { + return this.headlessTerminals.has(ptyId) + } + serializeMainTerminalBuffer( ptyId: string, opts: { scrollbackRows?: number } = {} @@ -6979,9 +6996,9 @@ export class OrcaRuntimeService { data: string cols: number rows: number + seq?: number cwd?: string | null lastTitle?: string - seq?: number source?: 'headless' | 'renderer' oscLinks?: TerminalOscLinkRange[] alternateScreen?: boolean @@ -7417,13 +7434,14 @@ export class OrcaRuntimeService { : rendererSnapshot } - private async serializeRendererTerminalBuffer( + async serializeRendererTerminalBuffer( ptyId: string, opts: { scrollbackRows?: number } = {} ): Promise<{ data: string cols: number rows: number + seq?: number cwd?: string | null lastTitle?: string source?: 'renderer' @@ -7436,6 +7454,7 @@ export class OrcaRuntimeService { data: string cols: number rows: number + seq?: number cwd?: string | null lastTitle?: string oscLinks?: TerminalOscLinkRange[] @@ -19219,6 +19238,88 @@ export class OrcaRuntimeService { }) } + // Why: never-mounted tabs have no attached PTY or mobile snapshot; synthetic + // handles need the ptyId so the renderer can mount the exact owning tab. + requestRendererTerminalTabMount(handle: string): boolean { + const record = this.handles.get(handle) + if (!record?.worktreeId) { + return false + } + const tabId = record.tabId.startsWith('pty:') ? undefined : record.tabId + const ptyId = record.ptyId ?? undefined + if (!tabId && !ptyId) { + return false + } + try { + this.getAuthoritativeWindow().webContents.send('terminal:requestTabMount', { + worktreeId: record.worktreeId, + ...(tabId ? { tabId } : {}), + ...(ptyId ? { ptyId } : {}) + }) + return true + } catch { + // No authoritative window (shutdown/headless): the subscribe keeps its + // existing empty-snapshot fallback. + return false + } + } + + getRendererTerminalSerializerGeneration(ptyId: string): number { + return this.ptyController?.getRendererSerializerGeneration?.(ptyId) ?? 0 + } + + getRendererTerminalSerializerGenerationForHandle(handle: string): number { + const ptyId = this.handles.get(handle)?.ptyId + return ptyId ? this.getRendererTerminalSerializerGeneration(ptyId) : 0 + } + + replaceHeadlessTerminalFromRendererSnapshotForRecovery( + ptyId: string, + snapshot: { + data: string + cols: number + rows: number + cwd?: string | null + oscLinks?: TerminalOscLinkRange[] + }, + trailingOutput: { data: string; seq: number }[] = [] + ): void { + if (!snapshot.data) { + return + } + // Why: a redraw byte can create a suffix-only model before the restored + // renderer settles. Replace it with the exact snapshot already sent mobile. + this.providerSnapshotPreferredPtys.add(ptyId) + this.disposeHeadlessTerminal(ptyId) + this.seedHeadlessTerminal( + ptyId, + snapshot.data, + { cols: snapshot.cols, rows: snapshot.rows }, + { cwd: snapshot.cwd, oscLinks: snapshot.oscLinks } + ) + for (const chunk of trailingOutput) { + this.trackHeadlessTerminalData(ptyId, chunk.data, chunk.seq) + } + // The seed's write chain already owns subsequent live bytes; suppress the + // ordinary on-data hydration path from replacing this known-good seed. + this.headlessHydrationState.set(ptyId, 'done') + } + + waitForRendererTerminalSerializer( + ptyId: string, + afterGeneration: number, + timeoutMs?: number, + signal?: AbortSignal + ): Promise { + return this.ptyController?.waitForRendererSerializer?.( + ptyId, + afterGeneration, + timeoutMs, + signal + ) ?? + Promise.resolve(false) + } + // Why: a leaf appears in the graph before its PTY spawns. If we issue a // handle while ptyId is null, the next graph sync after PTY spawn will // change ptyId and invalidate the handle. Wait for a connected PTY so @@ -22517,6 +22618,20 @@ export class OrcaRuntimeService { this.rejectWaitersForHandle(handle, 'terminal_handle_stale') } + private adoptFirstPtyForLeafHandle( + leafKey: string, + ptyId: string | null, + ptyGeneration: number + ): boolean { + const handle = this.handleByLeafKey.get(leafKey) + const record = handle ? this.handles.get(handle) : null + if (!handle || !record || record.ptyId !== null || ptyId === null) { + return false + } + this.handles.set(handle, { ...record, ptyId, ptyGeneration }) + return true + } + private rememberDetachedPreAllocatedLeaves(): void { for (const leaf of this.leaves.values()) { if (leaf.ptyId && this.handleByPtyId.has(leaf.ptyId)) { diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 7a9b9e448a6..445a1f204e2 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -50,6 +50,9 @@ const TERMINAL_MULTIPLEX_ACK_TOTAL_HIGH_WATER_BYTES = 2 * 1024 * 1024 // payload bytes rather than UTF-16 code units. const TERMINAL_MULTIPLEX_PENDING_MAX_BYTES = 256 * 1024 const TERMINAL_QUERY_REPLAY_MAX_CHARS = 16 * 1024 +// Why: keep initial subscribe latency bounded; readiness remains observed after +// this deadline and triggers an in-stream recovery snapshot when it arrives. +const MOBILE_RENDERER_MOUNT_READY_TIMEOUT_MS = 3_000 let nextTerminalStreamId = 1 type SnapshotFrameOptions = { @@ -650,6 +653,44 @@ async function serializeBudgetedMobileSnapshot( return null } +async function serializeStableMobileRendererSnapshot( + runtime: OrcaRuntimeService, + ptyId: string +): Promise { + const candidates = [MOBILE_SUBSCRIBE_SCROLLBACK_ROWS, 500, 250, 100, 25, 0] + let candidateIndex = 0 + for (let attempt = 0; attempt < candidates.length; attempt += 1) { + // Why: stability retries share the six-call snapshot budget. Advance + // toward zero scrollback so the final attempt always has a bounded payload. + candidateIndex = Math.max(candidateIndex, attempt) + const rows = candidates[candidateIndex] + const outputSequenceBefore = runtime.getPtyOutputSequence(ptyId) + const serialized = await runtime.serializeRendererTerminalBuffer(ptyId, { + scrollbackRows: rows + }) + const outputSequenceAfter = runtime.getPtyOutputSequence(ptyId) + if (outputSequenceBefore !== outputSequenceAfter) { + continue + } + if (!serialized) { + return null + } + const overByteBudget = terminalStreamByteLengthExceeds( + serialized.data, + MOBILE_SNAPSHOT_BYTE_BUDGET + ) + if (!overByteBudget || rows === 0) { + return { + ...serialized, + scrollbackRows: rows, + truncatedByByteBudget: rows < MOBILE_SUBSCRIBE_SCROLLBACK_ROWS || overByteBudget + } + } + candidateIndex += 1 + } + return null +} + // Why: mobile xterm can only re-wrap SOFT-wrapped lines on a client-side // term.resize(); the restored scrollback snapshot contains HARD newlines from // the host serialization, so a width change leaves prior output wrapped at the @@ -2355,13 +2396,25 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ ) => { let leaf = runtime.resolveLeafForHandle(params.terminal) const isMobile = params.client?.type === 'mobile' + const serializerGenerationBeforeAnyMount = isMobile + ? (runtime.getRendererTerminalSerializerGenerationForHandle?.(params.terminal) ?? 0) + : 0 + let rendererMountRequestedBeforePty = false const useBinaryStream = params.capabilities?.terminalBinaryStream === 1 && Boolean(sendBinary) + // Why: a closed stream must not allocate listeners, mobile-fit state, or + // a hidden renderer surface that no client remains to consume. + if (signal?.aborted) { + return + } // Why: the left pane's PTY spawns asynchronously after the tab is created. // Mobile clients that subscribe before the PTY is ready would get a bare // scrollback+end with no live stream or phone-fit. Wait for the PTY so // the subscribe can proceed normally. if (!leaf?.ptyId && isMobile) { + // Why: a never-mounted tab has no graph leaf to await; mounting the + // exact tab lets its PTY attach without activating the worktree. + rendererMountRequestedBeforePty = runtime.requestRendererTerminalTabMount(params.terminal) try { const ptyId = await runtime.waitForLeafPtyId(params.terminal, 10_000, signal) leaf = { ptyId } @@ -2391,6 +2444,16 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ const ptyId = leaf.ptyId const clientId = params.client?.id + // 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 = + isMobile && + (rendererMountRequestedBeforePty || runtime.hasHeadlessTerminalState?.(ptyId) === false) + const serializerGenerationBeforeMobileFit = missingHeadlessStateBeforeMobileFit + ? rendererMountRequestedBeforePty + ? serializerGenerationBeforeAnyMount + : runtime.getRendererTerminalSerializerGeneration(ptyId) + : 0 const supportsDesktopViewportClaims = params.capabilities?.desktopViewportClaims === 1 // Why: only unregister the width floor this subscription took (see the // multiplex stream's registeredRemoteDesktopDriver note). @@ -2532,6 +2595,8 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ let unsubscribeResize = (): void => {} let unsubscribeFit = (): void => {} let unregisterBinaryHandler = (): void => {} + let abortRendererMountWait = (): void => {} + let lateRendererReadyPromise: Promise | null = null let outputBatcher: ReturnType | null = null let resolveStream = (): void => {} const streamClosed = new Promise((resolve) => { @@ -2551,6 +2616,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ unsubscribeResize() unsubscribeFit() unregisterBinaryHandler() + abortRendererMountWait() if (isMobile && clientId) { runtime.handleMobileUnsubscribe(ptyId, clientId) } else if (registeredRemoteDesktopDriver && clientId) { @@ -2772,6 +2838,83 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ if (closed) { return } + // Why: missing model state—not snapshot text—is the signal that this + // PTY may never have attached; avoid remounting legitimate blank panes. + // A renderer-sourced snapshot also proves the exact pane is already + // attached, so waiting for a fresh mount generation would only stall. + const mountRequested = + missingHeadlessStateBeforeMobileFit && + serialized?.source !== 'renderer' && + (rendererMountRequestedBeforePty || + runtime.requestRendererTerminalTabMount(params.terminal)) + if (missingHeadlessStateBeforeMobileFit && mountRequested) { + // Why: an idle legacy PTY emits no later byte; a fresh settle proves + // this exact remount completed before we replay its restored screen. + const mountWaitController = new AbortController() + const abortMountWait = (): void => mountWaitController.abort() + abortRendererMountWait = abortMountWait + if (signal?.aborted) { + abortMountWait() + } else { + signal?.addEventListener('abort', abortMountWait, { once: true }) + } + const rendererReadyPromise = runtime + .waitForRendererTerminalSerializer( + ptyId, + serializerGenerationBeforeMobileFit, + undefined, + mountWaitController.signal + ) + .catch(() => false) + const finishMountWait = (): void => { + signal?.removeEventListener('abort', abortMountWait) + if (abortRendererMountWait === abortMountWait) { + abortRendererMountWait = () => {} + } + } + void rendererReadyPromise.then(finishMountWait, finishMountWait) + let deadlineTimer: ReturnType | null = null + const initialDeadline = new Promise((resolve) => { + deadlineTimer = setTimeout(() => resolve(false), MOBILE_RENDERER_MOUNT_READY_TIMEOUT_MS) + if (typeof deadlineTimer.unref === 'function') { + deadlineTimer.unref() + } + }) + const rendererReady = await Promise.race([rendererReadyPromise, initialDeadline]) + if (deadlineTimer) { + clearTimeout(deadlineTimer) + } + if (closed || signal?.aborted) { + return + } + if (rendererReady) { + read = await runtime.readTerminal(params.terminal) + const stableRendererSnapshot = await serializeStableMobileRendererSnapshot( + runtime, + ptyId + ) + if (closed) { + return + } + if (stableRendererSnapshot?.data.length) { + serialized = stableRendererSnapshot + const trailingOutput = pendingOutput.flatMap((item) => { + const data = getOutputAfterSnapshotSeq(item, stableRendererSnapshot.seq) + const seq = item.meta?.seq + return data && typeof seq === 'number' ? [{ data, seq }] : [] + }) + runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery( + ptyId, + stableRendererSnapshot, + trailingOutput + ) + } + } else { + // Why: a renderer can settle after the bounded initial response. + // Keep observing it so an idle PTY still self-heals without bytes. + lateRendererReadyPromise = rendererReadyPromise + } + } let initialOutputOverflowed = false if (pendingOutputOverflowed) { pendingOutput.splice(0) @@ -2932,6 +3075,54 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ } pendingOutputBytes = 0 outputBatcher.flush() + const lateRendererReady = lateRendererReadyPromise + lateRendererReadyPromise = null + if (lateRendererReady) { + void lateRendererReady + .then(async (rendererReady) => { + if (!rendererReady || closed) { + return + } + outputBatcher?.flush() + const recovery = await serializeStableMobileRendererSnapshot(runtime, ptyId) + if (closed) { + return + } + if (!recovery?.data.length) { + return + } + // Why: late recovery has no buffered-output gate. Only an exact + // renderer high-water may reset mobile without erasing live bytes. + if (recovery.seq !== runtime.getPtyOutputSequence(ptyId)) { + return + } + runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery(ptyId, recovery) + // Why: shipped mobile clients apply resized snapshots in place, + // allowing a blank initialized xterm to recover without resubscribe. + const recoveryStats = sendSnapshotFrames(sendFrame, { + kind: 'resized', + cols: recovery.cols, + rows: recovery.rows, + displayMode, + reason: 'renderer-mount-ready', + source: recovery.source, + truncated: false, + truncatedByByteBudget: recovery.truncatedByByteBudget, + data: recovery.data + }) + lastResizeCols = recovery.cols + console.log('[mobile-terminal-stream] recovery snapshot', { + terminal: params.terminal, + streamId, + reason: 'renderer-mount-ready', + bytes: recoveryStats.bytes, + chunks: recoveryStats.chunks, + scrollbackRows: recovery.scrollbackRows, + truncatedByByteBudget: recovery.truncatedByByteBudget === true + }) + }) + .catch(() => {}) + } const sendResizedFrame = (event: { cols: number rows: number diff --git a/src/main/runtime/rpc/terminal-subscribe-blank-mount.test.ts b/src/main/runtime/rpc/terminal-subscribe-blank-mount.test.ts new file mode 100644 index 00000000000..89a46d36073 --- /dev/null +++ b/src/main/runtime/rpc/terminal-subscribe-blank-mount.test.ts @@ -0,0 +1,235 @@ +/** STA-1840 regression: missing mobile terminal models request an exact renderer tab mount. */ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from './dispatcher' +import type { RpcRequest } from './core' +import type { OrcaRuntimeService } from '../orca-runtime' +import { TERMINAL_METHODS } from './methods/terminal' +import type { RuntimeTerminalWait } from '../../../shared/runtime-types' + +function stubRuntime(overrides: Partial = {}): OrcaRuntimeService { + return { + getRuntimeId: () => 'test-runtime', + registerRemoteTerminalViewSubscriber: () => () => {}, + requestRendererTerminalTabMount: () => false, + getRendererTerminalSerializerGenerationForHandle: () => 0, + getRendererTerminalSerializerGeneration: () => 0, + waitForRendererTerminalSerializer: async () => false, + getPtyOutputSequence: () => 0, + replaceHeadlessTerminalFromRendererSnapshotForRecovery: () => {}, + serializeRendererTerminalBuffer: async () => null, + hasHeadlessTerminalState: () => true, + ...overrides + } as OrcaRuntimeService +} + +const makeRequest = (method: string, params?: unknown): RpcRequest => ({ + id: 'req-1', + authToken: 'tok', + method, + params +}) + +describe('terminal.subscribe blank-tab background mount', () => { + it('does not mount a hidden tab for an already-aborted mobile subscribe', async () => { + const controller = new AbortController() + controller.abort() + const requestRendererTerminalTabMount = vi.fn(() => true) + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue(null), + requestRendererTerminalTabMount, + waitForLeafPtyId: vi.fn(), + readTerminal: vi.fn() + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + await dispatcher.dispatchStreaming( + makeRequest('terminal.subscribe', { + terminal: 'terminal-1', + client: { id: 'phone-1', type: 'mobile' }, + capabilities: { terminalBinaryStream: 1 } + }), + vi.fn(), + { + signal: controller.signal, + connectionId: 'conn-phone', + sendBinary: vi.fn(), + registerBinaryStreamHandler: vi.fn(() => vi.fn()) + } + ) + + expect(requestRendererTerminalTabMount).not.toHaveBeenCalled() + expect(runtime.waitForLeafPtyId).not.toHaveBeenCalled() + expect(runtime.readTerminal).not.toHaveBeenCalled() + }) + + it('requests a renderer tab mount when a mobile subscribe has no headless model', async () => { + // Why: stale preview text must not hide the missing live model/attachment. + const cleanups = new Map void>() + const callOrder: string[] = [] + const unsubscribeData = vi.fn() + const requestRendererTerminalTabMount = vi.fn(() => { + callOrder.push('request-mount') + return true + }) + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + requestRendererTerminalTabMount, + hasHeadlessTerminalState: vi.fn().mockReturnValue(false), + handleMobileSubscribe: vi.fn().mockResolvedValue(true), + handleMobileUnsubscribe: vi.fn(), + subscribeToTerminalData: vi.fn(() => { + callOrder.push('subscribe-data') + return unsubscribeData + }), + readTerminal: vi.fn().mockResolvedValue({ tail: ['stale preview'], truncated: false }), + serializeTerminalBuffer: vi + .fn() + .mockResolvedValue({ data: 'stale snapshot', cols: 80, rows: 24, seq: 4 }), + 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(() => {})) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.subscribe', { + terminal: 'terminal-1', + client: { id: 'phone-1', type: 'mobile' }, + capabilities: { terminalBinaryStream: 1 } + }), + vi.fn(), + { + connectionId: 'conn-phone', + sendBinary: vi.fn(), + registerBinaryStreamHandler: vi.fn(() => vi.fn()) + } + ) + + await vi.waitFor(() => + expect(requestRendererTerminalTabMount).toHaveBeenCalledWith('terminal-1') + ) + expect(callOrder).toEqual(['subscribe-data', 'request-mount']) + + runtime.cleanupSubscription('terminal-1:phone-1') + await dispatchPromise + expect(unsubscribeData).toHaveBeenCalledTimes(1) + }) + + it('does not request a renderer tab mount when an attached terminal is legitimately blank', async () => { + const cleanups = new Map void>() + const requestRendererTerminalTabMount = vi.fn(() => true) + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + requestRendererTerminalTabMount, + handleMobileSubscribe: vi.fn().mockResolvedValue(true), + handleMobileUnsubscribe: vi.fn(), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn().mockResolvedValue(null), + 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(() => {})) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.subscribe', { + terminal: 'terminal-1', + client: { id: 'phone-1', type: 'mobile' }, + capabilities: { terminalBinaryStream: 1 } + }), + vi.fn(), + { + connectionId: 'conn-phone', + sendBinary: vi.fn(), + registerBinaryStreamHandler: vi.fn(() => vi.fn()) + } + ) + + await vi.waitFor(() => expect(runtime.handleMobileSubscribe).toHaveBeenCalled()) + expect(requestRendererTerminalTabMount).not.toHaveBeenCalled() + + runtime.cleanupSubscription('terminal-1:phone-1') + await dispatchPromise + }) + + it('does not wait for a remount when the current snapshot came from the renderer', async () => { + const cleanups = new Map void>() + const requestRendererTerminalTabMount = vi.fn(() => true) + const waitForRendererTerminalSerializer = vi.fn() + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + requestRendererTerminalTabMount, + waitForRendererTerminalSerializer, + hasHeadlessTerminalState: vi.fn().mockReturnValue(false), + handleMobileSubscribe: vi.fn().mockResolvedValue(true), + handleMobileUnsubscribe: vi.fn(), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn().mockResolvedValue({ + data: 'current renderer prompt $ ', + cols: 80, + rows: 24, + source: 'renderer' + }), + 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(() => {})) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.subscribe', { + terminal: 'terminal-1', + client: { id: 'phone-1', type: 'mobile' }, + capabilities: { terminalBinaryStream: 1 } + }), + vi.fn(), + { + connectionId: 'conn-phone', + sendBinary: vi.fn(), + registerBinaryStreamHandler: vi.fn(() => vi.fn()) + } + ) + + await vi.waitFor(() => expect(runtime.handleMobileSubscribe).toHaveBeenCalled()) + expect(requestRendererTerminalTabMount).not.toHaveBeenCalled() + expect(waitForRendererTerminalSerializer).not.toHaveBeenCalled() + + runtime.cleanupSubscription('terminal-1:phone-1') + await dispatchPromise + }) +}) diff --git a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts index 4a674f9df91..5c02a25326f 100644 --- a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts @@ -17,6 +17,7 @@ function stubRuntime(overrides: Partial = {}): OrcaRuntimeSe // Why: subscribe streams register as remote view subscribers for Phase-5 // query-authority suppression (terminal-query-authority.md). registerRemoteTerminalViewSubscriber: () => () => {}, + requestRendererTerminalTabMount: () => false, ...overrides } as OrcaRuntimeService } diff --git a/src/main/runtime/rpc/terminal-subscribe-mount-replay.test.ts b/src/main/runtime/rpc/terminal-subscribe-mount-replay.test.ts new file mode 100644 index 00000000000..3a7614587f1 --- /dev/null +++ b/src/main/runtime/rpc/terminal-subscribe-mount-replay.test.ts @@ -0,0 +1,437 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RuntimeTerminalWait } from '../../../shared/runtime-types' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + decodeTerminalStreamText +} from '../../../shared/terminal-stream-protocol' +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 } + } +} + +describe('terminal subscribe mount replay', () => { + it('includes the restored idle screen when a missing model is background-mounted', async () => { + const binaryFrames: Uint8Array[] = [] + const cleanups = new Map void>() + let mounted = false + const runtime = { + getRuntimeId: () => 'test-runtime', + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + hasHeadlessTerminalState: vi.fn(() => mounted), + requestRendererTerminalTabMount: vi.fn(() => true), + getRendererTerminalSerializerGenerationForHandle: vi.fn(() => 3), + getRendererTerminalSerializerGeneration: vi.fn(() => 3), + getPtyOutputSequence: vi.fn(() => (mounted ? 4 : 0)), + replaceHeadlessTerminalFromRendererSnapshotForRecovery: vi.fn(), + waitForRendererTerminalSerializer: vi.fn(async (_ptyId, afterGeneration) => { + expect(afterGeneration).toBe(3) + mounted = true + return true + }), + handleMobileSubscribe: vi.fn().mockResolvedValue(true), + handleMobileUnsubscribe: vi.fn(), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + registerRemoteTerminalViewSubscriber: vi.fn(() => vi.fn()), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn(async () => + mounted + ? { data: 'idle prompt $ ', cols: 80, rows: 24, seq: 4 } + : { data: '', cols: 80, rows: 24, seq: 1 } + ), + serializeRendererTerminalBuffer: vi.fn(async () => + mounted ? { data: 'idle prompt $ ', cols: 80, rows: 24, seq: 4 } : null + ), + 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-phone', + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, + registerBinaryStreamHandler: vi.fn(() => vi.fn()) + }) + + await vi.waitFor(() => expect(runtime.requestRendererTerminalTabMount).toHaveBeenCalled()) + await vi.waitFor(() => + expect( + binaryFrames + .map((bytes) => decodeTerminalStreamFrame(bytes)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk) + .map((frame) => decodeTerminalStreamText(frame!.payload)) + .join('') + ).toContain('idle prompt $ ') + ) + + runtime.cleanupSubscription('terminal-1:phone-1') + await dispatchPromise + }) + + it('prefers restored history when phone-fit creates suffix-only headless state', async () => { + const binaryFrames: Uint8Array[] = [] + const cleanups = new Map void>() + let generation = 1 + let headlessPresent = false + let serializeCalls = 0 + const runtime = { + getRuntimeId: () => 'test-runtime', + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + hasHeadlessTerminalState: vi.fn(() => headlessPresent), + requestRendererTerminalTabMount: vi.fn(() => true), + getRendererTerminalSerializerGenerationForHandle: vi.fn(() => generation), + getRendererTerminalSerializerGeneration: vi.fn(() => generation), + getPtyOutputSequence: vi.fn(() => 0), + replaceHeadlessTerminalFromRendererSnapshotForRecovery: vi.fn(), + waitForRendererTerminalSerializer: vi.fn(async (_ptyId, afterGeneration) => { + return generation > afterGeneration + }), + handleMobileSubscribe: vi.fn(async () => { + headlessPresent = true + generation = 2 + return true + }), + handleMobileUnsubscribe: vi.fn(), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + registerRemoteTerminalViewSubscriber: vi.fn(() => vi.fn()), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn(async () => { + serializeCalls += 1 + if (serializeCalls === 1) { + return { data: 'suffix-only redraw', cols: 80, rows: 24, seq: 2 } + } + return { data: 'raced idle prompt $ ', cols: 80, rows: 24, seq: 5 } + }), + serializeRendererTerminalBuffer: vi.fn(async () => ({ + data: 'raced idle prompt $ ', + cols: 80, + rows: 24, + seq: 5 + })), + 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-phone', + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, + registerBinaryStreamHandler: vi.fn(() => vi.fn()) + }) + + await vi.waitFor(() => + expect( + binaryFrames + .map((bytes) => decodeTerminalStreamFrame(bytes)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk) + .map((frame) => decodeTerminalStreamText(frame!.payload)) + .join('') + ).toContain('raced idle prompt $ ') + ) + expect( + binaryFrames + .map((bytes) => decodeTerminalStreamFrame(bytes)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk) + .map((frame) => decodeTerminalStreamText(frame!.payload)) + .join('') + ).not.toContain('suffix-only redraw') + expect(runtime.waitForRendererTerminalSerializer).toHaveBeenCalledWith( + 'pty-1', + 1, + undefined, + expect.any(AbortSignal) + ) + + runtime.cleanupSubscription('terminal-1:phone-1') + await dispatchPromise + }) + + it('replays a late recovery when readiness lands after the bounded initial response', async () => { + vi.useFakeTimers() + const binaryFrames: Uint8Array[] = [] + const cleanups = new Map void>() + let mounted = false + let signalWaitStarted!: () => void + const waitStarted = new Promise((resolve) => { + signalWaitStarted = resolve + }) + const runtime = { + getRuntimeId: () => 'test-runtime', + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-delayed' }), + hasHeadlessTerminalState: vi.fn(() => false), + requestRendererTerminalTabMount: vi.fn(() => true), + getRendererTerminalSerializerGenerationForHandle: vi.fn(() => 1), + getRendererTerminalSerializerGeneration: vi.fn(() => 1), + getPtyOutputSequence: vi.fn(() => (mounted ? 4 : 0)), + replaceHeadlessTerminalFromRendererSnapshotForRecovery: vi.fn(), + waitForRendererTerminalSerializer: vi.fn( + (_ptyId: string, _afterGeneration: number, timeoutMs: number | undefined) => { + expect(timeoutMs).toBeUndefined() + signalWaitStarted() + return new Promise((resolve) => { + setTimeout(() => { + mounted = true + resolve(true) + }, 3_001) + }) + } + ), + handleMobileSubscribe: vi.fn().mockResolvedValue(true), + handleMobileUnsubscribe: vi.fn(), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + registerRemoteTerminalViewSubscriber: vi.fn(() => vi.fn()), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn(async () => ({ + data: mounted ? 'delayed idle prompt $ ' : 'late suffix-only redraw', + cols: 80, + rows: 24, + seq: mounted ? 4 : 1 + })), + serializeRendererTerminalBuffer: vi.fn(async () => + mounted ? { data: 'delayed idle prompt $ ', cols: 80, rows: 24, seq: 4 } : null + ), + 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-phone', + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, + registerBinaryStreamHandler: vi.fn(() => vi.fn()) + }) + + await waitStarted + await vi.advanceTimersByTimeAsync(3_000) + await Promise.resolve() + expect(binaryFrames.length).toBeGreaterThan(0) + expect( + binaryFrames + .map((bytes) => decodeTerminalStreamFrame(bytes)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk) + .map((frame) => decodeTerminalStreamText(frame!.payload)) + .join('') + ).toContain('late suffix-only redraw') + expect( + binaryFrames + .map((bytes) => decodeTerminalStreamFrame(bytes)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk) + .map((frame) => decodeTerminalStreamText(frame!.payload)) + .join('') + ).not.toContain('delayed idle prompt $ ') + await vi.advanceTimersByTimeAsync(1) + await Promise.resolve() + await Promise.resolve() + + expect( + binaryFrames + .map((bytes) => decodeTerminalStreamFrame(bytes)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk) + .map((frame) => decodeTerminalStreamText(frame!.payload)) + .join('') + ).toContain('delayed idle prompt $ ') + + runtime.cleanupSubscription('terminal-1:phone-1') + await dispatchPromise + vi.useRealTimers() + }) + + it('recovers from the pre-mount generation when suffix state appears during the PTY wait', async () => { + const binaryFrames: Uint8Array[] = [] + const cleanups = new Map void>() + let generation = 0 + let headlessPresent = false + const requestRendererTerminalTabMount = vi.fn(() => { + generation = 1 + return true + }) + const runtime = { + getRuntimeId: () => 'test-runtime', + resolveLeafForHandle: vi.fn().mockReturnValue(null), + waitForLeafPtyId: vi.fn(async () => { + // A redraw may reach main before the PTY wait completes, but it does + // not contain the legacy terminal's restored history. + headlessPresent = true + return 'pty-late-leaf' + }), + hasHeadlessTerminalState: vi.fn(() => headlessPresent), + requestRendererTerminalTabMount, + getRendererTerminalSerializerGenerationForHandle: vi.fn(() => 0), + getRendererTerminalSerializerGeneration: vi.fn(() => generation), + getPtyOutputSequence: vi.fn(() => 0), + replaceHeadlessTerminalFromRendererSnapshotForRecovery: vi.fn(), + waitForRendererTerminalSerializer: vi.fn(async (_ptyId, afterGeneration) => { + return generation > afterGeneration + }), + handleMobileSubscribe: vi.fn().mockResolvedValue(true), + handleMobileUnsubscribe: vi.fn(), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + registerRemoteTerminalViewSubscriber: vi.fn(() => vi.fn()), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn().mockResolvedValue({ + data: 'suffix-only redraw', + cols: 80, + rows: 24, + seq: 1 + }), + serializeRendererTerminalBuffer: vi.fn().mockResolvedValue({ + data: 'late leaf prompt $ ', + cols: 80, + rows: 24 + }), + 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-phone', + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, + registerBinaryStreamHandler: vi.fn(() => vi.fn()) + }) + + await vi.waitFor(() => + expect( + binaryFrames + .map((bytes) => decodeTerminalStreamFrame(bytes)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk) + .map((frame) => decodeTerminalStreamText(frame!.payload)) + .join('') + ).toContain('late leaf prompt $ ') + ) + expect(requestRendererTerminalTabMount).toHaveBeenCalledTimes(1) + expect(runtime.waitForRendererTerminalSerializer).toHaveBeenCalledWith( + 'pty-late-leaf', + 0, + undefined, + expect.any(AbortSignal) + ) + + runtime.cleanupSubscription('terminal-1:phone-1') + await dispatchPromise + }) + + it('cancels the mount-ready wait when the mobile subscription closes', async () => { + const cleanups = new Map void>() + let waitSignal: AbortSignal | undefined + const runtime = { + getRuntimeId: () => 'test-runtime', + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + hasHeadlessTerminalState: vi.fn(() => false), + requestRendererTerminalTabMount: vi.fn(() => true), + getRendererTerminalSerializerGenerationForHandle: vi.fn(() => 1), + getRendererTerminalSerializerGeneration: vi.fn(() => 1), + getPtyOutputSequence: vi.fn(() => 0), + replaceHeadlessTerminalFromRendererSnapshotForRecovery: vi.fn(), + waitForRendererTerminalSerializer: vi.fn( + (_ptyId: string, _afterGeneration: number, _timeoutMs: number, signal?: AbortSignal) => { + waitSignal = signal + return new Promise((resolve) => { + signal?.addEventListener('abort', () => resolve(false), { once: true }) + }) + } + ), + handleMobileSubscribe: vi.fn().mockResolvedValue(true), + handleMobileUnsubscribe: vi.fn(), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + registerRemoteTerminalViewSubscriber: vi.fn(() => vi.fn()), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn().mockResolvedValue(null), + serializeRendererTerminalBuffer: vi.fn().mockResolvedValue(null), + 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-phone', + sendBinary: vi.fn(), + registerBinaryStreamHandler: vi.fn(() => vi.fn()) + }) + + await vi.waitFor(() => expect(runtime.waitForRendererTerminalSerializer).toHaveBeenCalled()) + runtime.cleanupSubscription('terminal-1:phone-1') + + expect(waitSignal?.aborted).toBe(true) + await dispatchPromise + }) +}) diff --git a/src/main/runtime/rpc/terminal-subscribe-renderer-recovery-output.test.ts b/src/main/runtime/rpc/terminal-subscribe-renderer-recovery-output.test.ts new file mode 100644 index 00000000000..91cd571e0f4 --- /dev/null +++ b/src/main/runtime/rpc/terminal-subscribe-renderer-recovery-output.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RuntimeTerminalWait } from '../../../shared/runtime-types' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + decodeTerminalStreamText +} from '../../../shared/terminal-stream-protocol' +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-output-race', + authToken: 'tok', + method: 'terminal.subscribe', + params: { + terminal: 'terminal-output-race', + client: { id: 'phone-output-race', type: 'mobile' }, + capabilities: { terminalBinaryStream: 1 } + } +} + +describe('terminal subscribe renderer recovery output ordering', () => { + it('replays bytes absent from the renderer snapshot using its exact sequence boundary', async () => { + const binaryFrames: Uint8Array[] = [] + const cleanups = new Map void>() + let outputSequence = 0 + let rendererSerializeCalls = 0 + let onData: + | ((data: string, meta?: { seq?: number; rawLength?: number; cwd?: string }) => void) + | undefined + const runtime = { + getRuntimeId: () => 'test-runtime', + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-output-race' }), + hasHeadlessTerminalState: vi.fn(() => false), + requestRendererTerminalTabMount: vi.fn(() => true), + getRendererTerminalSerializerGenerationForHandle: vi.fn(() => 1), + getRendererTerminalSerializerGeneration: vi.fn(() => 1), + waitForRendererTerminalSerializer: vi.fn().mockResolvedValue(true), + getPtyOutputSequence: vi.fn(() => outputSequence), + handleMobileSubscribe: vi.fn().mockResolvedValue(true), + handleMobileUnsubscribe: vi.fn(), + subscribeToTerminalData: vi.fn((_ptyId, listener) => { + onData = listener + return vi.fn() + }), + registerRemoteTerminalViewSubscriber: vi.fn(() => vi.fn()), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn().mockResolvedValue(null), + serializeRendererTerminalBuffer: vi.fn(async () => { + rendererSerializeCalls += 1 + if (rendererSerializeCalls === 1) { + outputSequence = 6 + onData?.('during', { seq: 6, rawLength: 6 }) + return { data: 'stale renderer', cols: 80, rows: 24 } + } + return { data: 'restored history', cols: 80, rows: 24, seq: 0 } + }), + replaceHeadlessTerminalFromRendererSnapshotForRecovery: vi.fn(() => { + outputSequence = 11 + onData?.('after', { seq: 11, rawLength: 5 }) + }), + 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-phone', + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, + registerBinaryStreamHandler: vi.fn(() => vi.fn()) + }) + + await vi.waitFor(() => { + const output = binaryFrames + .map((bytes) => decodeTerminalStreamFrame(bytes)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + .map((frame) => decodeTerminalStreamText(frame!.payload)) + .join('') + expect(output).toBe('duringafter') + }) + + const snapshot = binaryFrames + .map((bytes) => decodeTerminalStreamFrame(bytes)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk) + .map((frame) => decodeTerminalStreamText(frame!.payload)) + .join('') + expect(snapshot).toBe('restored history') + expect(rendererSerializeCalls).toBe(2) + + runtime.cleanupSubscription('terminal-output-race:phone-output-race') + await dispatchPromise + }) +}) diff --git a/src/main/runtime/terminal-mobile-subscribe-tab-mount.test.ts b/src/main/runtime/terminal-mobile-subscribe-tab-mount.test.ts new file mode 100644 index 00000000000..d84283e9181 --- /dev/null +++ b/src/main/runtime/terminal-mobile-subscribe-tab-mount.test.ts @@ -0,0 +1,162 @@ +/** STA-1840 regression: known blank-terminal handles request a bounded renderer mount. */ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' + +type HandleSeed = { + handle: string + worktreeId: string + tabId: string + ptyId: string | null +} + +type RuntimeInternals = { + handles: Map< + string, + HandleSeed & { + runtimeId: string + rendererGraphEpoch: number + leafId: string + ptyGeneration: number + } + > + getAuthoritativeWindow: () => { + webContents: { send: (channel: string, payload: unknown) => void } + } + leaves: Map + issueHandle: (leaf: unknown) => string +} + +function seedRuntime(seeds: HandleSeed[]): { + runtime: OrcaRuntimeService + send: ReturnType +} { + const runtime = new OrcaRuntimeService() + const internals = runtime as unknown as RuntimeInternals + for (const seed of seeds) { + internals.handles.set(seed.handle, { + ...seed, + runtimeId: 'rt-test', + rendererGraphEpoch: 1, + leafId: 'leaf-1', + ptyGeneration: 1 + }) + } + const send = vi.fn() + internals.getAuthoritativeWindow = () => ({ webContents: { send } }) + return { runtime, send } +} + +describe('mobile terminal subscribe tab mount', () => { + it('keeps a waiting real-tab handle usable when the mount binds its first PTY', async () => { + const runtime = new OrcaRuntimeService() + const syncGraph = (ptyId: string | null): void => { + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: 'wt-1', + title: 'Terminal', + activeLeafId: 'leaf-1', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: 'wt-1', + leafId: 'leaf-1', + paneRuntimeId: 1, + ptyId + } + ] + }) + } + + runtime.attachWindow(1) + syncGraph(null) + const internals = runtime as unknown as RuntimeInternals + const leaf = internals.leaves.values().next().value + if (!leaf) { + throw new Error('expected terminal leaf') + } + const handle = internals.issueHandle(leaf) + const ptyWait = runtime.waitForLeafPtyId(handle) + + syncGraph('pty-1') + + await expect(ptyWait).resolves.toBe('pty-1') + expect(runtime.resolveLiveLeafForHandle(handle)).toEqual({ ptyId: 'pty-1' }) + await expect(runtime.readTerminal(handle)).resolves.toMatchObject({ handle }) + }) + + it('requests a tab mount by tabId for a real-tab handle awaiting its PTY (null-leaf blank path)', () => { + const { runtime, send } = seedRuntime([ + { handle: 'h1', worktreeId: 'wt-1', tabId: 'tab-1', ptyId: null } + ]) + + runtime.requestRendererTerminalTabMount('h1') + + expect(send).toHaveBeenCalledWith('terminal:requestTabMount', { + worktreeId: 'wt-1', + tabId: 'tab-1' + }) + }) + + it('requests a tab mount by tabId even when a real-tab handle carries a ptyId', () => { + const { runtime, send } = seedRuntime([ + { handle: 'h1', worktreeId: 'wt-1', tabId: 'tab-1', ptyId: 'wt-1@@abc' } + ]) + + runtime.requestRendererTerminalTabMount('h1') + + expect(send).toHaveBeenCalledWith('terminal:requestTabMount', { + worktreeId: 'wt-1', + tabId: 'tab-1', + ptyId: 'wt-1@@abc' + }) + }) + + it('requests a mount by ptyId for a synthetic pty-form handle (never-mounted workspace)', () => { + // Why: never-mounted workspaces expose only synthetic pty handles to mobile. + const { runtime, send } = seedRuntime([ + { handle: 'h1', worktreeId: 'wt-1', tabId: 'pty:wt-1@@abc', ptyId: 'wt-1@@abc' } + ]) + + runtime.requestRendererTerminalTabMount('h1') + + expect(send).toHaveBeenCalledWith('terminal:requestTabMount', { + worktreeId: 'wt-1', + ptyId: 'wt-1@@abc' + }) + }) + + it('does not request a mount when a pty-form handle has no ptyId', () => { + const { runtime, send } = seedRuntime([ + { handle: 'h1', worktreeId: 'wt-1', tabId: 'pty:abc', ptyId: null } + ]) + + runtime.requestRendererTerminalTabMount('h1') + + expect(send).not.toHaveBeenCalled() + }) + + it('does not request a mount for an unknown handle', () => { + const { runtime, send } = seedRuntime([]) + + runtime.requestRendererTerminalTabMount('missing') + + expect(send).not.toHaveBeenCalled() + }) + + it('swallows window lookup failures so subscribe keeps its fallback', () => { + const { runtime } = seedRuntime([ + { handle: 'h1', worktreeId: 'wt-1', tabId: 'tab-1', ptyId: null } + ]) + const internals = runtime as unknown as RuntimeInternals + internals.getAuthoritativeWindow = () => { + throw new Error('no window') + } + + expect(() => runtime.requestRendererTerminalTabMount('h1')).not.toThrow() + }) +}) diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 8b2e112a775..862ead494a2 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1411,11 +1411,12 @@ export type PreloadApi = { onClearBufferRequest: (callback: (data: { ptyId: string }) => void) => () => void sendSerializedBuffer: ( requestId: string, - snapshot: { data: string; cols: number; rows: number; lastTitle?: string } | null + snapshot: { data: string; cols: number; rows: number; seq?: number; lastTitle?: string } | null ) => void declarePendingPaneSerializer: (paneKey: string) => Promise settlePaneSerializer: (paneKey: string, gen: number) => Promise clearPendingPaneSerializer: (paneKey: string, gen: number) => Promise + reportRendererSerializerReady?: (ptyId: string) => Promise management: PtyManagementApi } feedback: { @@ -2844,6 +2845,9 @@ export type PreloadApi = { onRequestTerminalCreate: ( callback: (data: RuntimeTerminalCreateRequestPayload) => void ) => () => void + onRequestTerminalTabMount: ( + callback: (data: { worktreeId: string; tabId?: string; ptyId?: string }) => void + ) => () => void replyTerminalCreate: (reply: { requestId: string tabId?: string diff --git a/src/preload/index.ts b/src/preload/index.ts index 2e7dd1234e7..a77fbad0f0f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1142,7 +1142,7 @@ const api = { sendSerializedBuffer: ( requestId: string, - snapshot: { data: string; cols: number; rows: number; lastTitle?: string } | null + snapshot: { data: string; cols: number; rows: number; seq?: number; lastTitle?: string } | null ): void => { ipcRenderer.send('pty:serializeBuffer:response', { requestId, snapshot }) }, @@ -1161,6 +1161,9 @@ const api = { clearPendingPaneSerializer: (paneKey: string, gen: number): Promise => ipcRenderer.invoke('pty:clearPendingPaneSerializer', { paneKey, gen }), + reportRendererSerializerReady: (ptyId: string): Promise => + ipcRenderer.invoke('pty:reportRendererSerializerReady', { ptyId }), + management: { listSessions: () => ipcRenderer.invoke('pty:management:listSessions'), killAll: () => ipcRenderer.invoke('pty:management:killAll'), @@ -3516,6 +3519,16 @@ const api = { ipcRenderer.on('terminal:requestTabCreate', listener) return () => ipcRenderer.removeListener('terminal:requestTabCreate', listener) }, + onRequestTerminalTabMount: ( + callback: (data: { worktreeId: string; tabId?: string; ptyId?: string }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { worktreeId: string; tabId?: string; ptyId?: string } + ) => callback(data) + ipcRenderer.on('terminal:requestTabMount', listener) + return () => ipcRenderer.removeListener('terminal:requestTabMount', listener) + }, replyTerminalCreate: (reply: { requestId: string tabId?: string diff --git a/src/renderer/src/components/terminal-pane/pty-buffer-serializer.test.ts b/src/renderer/src/components/terminal-pane/pty-buffer-serializer.test.ts index 2907e2b3341..436982b061b 100644 --- a/src/renderer/src/components/terminal-pane/pty-buffer-serializer.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-buffer-serializer.test.ts @@ -78,4 +78,25 @@ describe('pty buffer serializer registry', () => { expect(window.api.pty.sendSerializedBuffer).toHaveBeenCalledWith('request-1', null) }) + + it('preserves the renderer-parsed output sequence in the response', async () => { + const { registerPtySerializer } = await import('./pty-buffer-serializer') + registerPtySerializer('pty-sequenced', () => ({ + data: 'parsed output', + cols: 80, + rows: 24, + seq: 42 + })) + const serializeRequestHandler = vi.mocked(window.api.pty.onSerializeBufferRequest).mock + .calls[0]?.[0] + + serializeRequestHandler?.({ requestId: 'request-sequenced', ptyId: 'pty-sequenced' }) + await Promise.resolve() + await Promise.resolve() + + expect(window.api.pty.sendSerializedBuffer).toHaveBeenCalledWith( + 'request-sequenced', + expect.objectContaining({ seq: 42 }) + ) + }) }) diff --git a/src/renderer/src/components/terminal-pane/pty-buffer-serializer.ts b/src/renderer/src/components/terminal-pane/pty-buffer-serializer.ts index 2958b5aa08f..c32ea0aafaf 100644 --- a/src/renderer/src/components/terminal-pane/pty-buffer-serializer.ts +++ b/src/renderer/src/components/terminal-pane/pty-buffer-serializer.ts @@ -14,6 +14,7 @@ export type SerializedBuffer = { data: string cols: number rows: number + seq?: number lastTitle?: string } @@ -148,6 +149,9 @@ function ensureSerializerListener(): void { cols: result.cols, rows: result.rows } + if (result.seq !== undefined) { + payload.seq = result.seq + } if (lastTitle !== undefined) { payload.lastTitle = lastTitle } else if (result.lastTitle !== undefined) { 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 e144c0ced8c..317742e68c7 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -225,6 +225,7 @@ function resolveMockPaneWindowsShiftEnterEncoding( } type ConnectCallbacks = { + onConnect?: () => void onData?: ( data: string, meta?: { seq?: number; rawLength?: number; background?: boolean; droppedOutput?: boolean } @@ -866,7 +867,8 @@ describe('connectPanePty', () => { sendSerializedBuffer: vi.fn(), declarePendingPaneSerializer: vi.fn().mockResolvedValue(1), settlePaneSerializer: vi.fn().mockResolvedValue(undefined), - clearPendingPaneSerializer: vi.fn().mockResolvedValue(undefined) + clearPendingPaneSerializer: vi.fn().mockResolvedValue(undefined), + reportRendererSerializerReady: vi.fn().mockResolvedValue(undefined) }, platform: { get: vi.fn(() => ({ platform: 'win32', osRelease: '10.0.26100' })) @@ -13565,8 +13567,10 @@ describe('connectPanePty', () => { it('attaches remote runtime PTY handles instead of creating a replacement terminal', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport() - transport.attach.mockImplementation(() => { + transport.attach.mockImplementation(({ callbacks }: { callbacks?: ConnectCallbacks }) => { transport.getPtyId.mockReturnValue('remote:env-1@@terminal-1') + callbacks?.onReplayData?.('restored remote prompt $ ') + callbacks?.onConnect?.() }) transportFactoryQueue.push(transport) @@ -13582,10 +13586,12 @@ describe('connectPanePty', () => { } as StoreState const pane = createPane(2) + pane.terminal.write.mockImplementation((_data: string, callback?: () => void) => callback?.()) const manager = createManager(2) const deps = createDeps() connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks() expect(transport.connect).not.toHaveBeenCalled() expect(transport.attach).toHaveBeenCalledWith( @@ -13593,6 +13599,60 @@ describe('connectPanePty', () => { ) expect(deps.updateTabPtyId).toHaveBeenCalledWith('tab-1', 'remote:env-1@@terminal-1') expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'remote:env-1@@terminal-1') + await vi.waitFor(() => + expect(window.api.pty.reportRendererSerializerReady).toHaveBeenCalledWith( + 'remote:env-1@@terminal-1' + ) + ) + }) + + it('reports remote renderer readiness only after delayed restore output is parsed', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + let remoteCallbacks: ConnectCallbacks | undefined + transport.attach.mockImplementation( + ({ callbacks }: { callbacks?: ConnectCallbacks }) => { + transport.getPtyId.mockReturnValue('remote:env-1@@terminal-delayed') + remoteCallbacks = callbacks + } + ) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'remote:terminal-delayed' }] + }, + settings: { + ...mockStoreState.settings, + activeRuntimeEnvironmentId: 'env-1' + } + } as StoreState + const pane = createPane(2) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + + connectPanePty(pane as never, createManager(2) as never, createDeps() as never) + await flushAsyncTicks() + expect(window.api.pty.reportRendererSerializerReady).not.toHaveBeenCalled() + + remoteCallbacks?.onReplayData?.('delayed restored prompt $ ') + remoteCallbacks?.onConnect?.() + await vi.waitFor(() => expect(parseCallbacks.length).toBeGreaterThan(0)) + expect(window.api.pty.reportRendererSerializerReady).not.toHaveBeenCalled() + + for (let step = 0; step < 10; step += 1) { + await vi.waitFor(() => expect(parseCallbacks.length).toBeGreaterThan(0)) + parseCallbacks.shift()?.() + await flushAsyncTicks() + if (vi.mocked(window.api.pty.reportRendererSerializerReady!).mock.calls.length > 0) { + break + } + } + expect(writes.join('')).toContain('delayed restored prompt $ ') + await vi.waitFor(() => + expect(window.api.pty.reportRendererSerializerReady).toHaveBeenCalledWith( + 'remote:env-1@@terminal-delayed' + ) + ) }) it('cold-spawns slept remote runtime PTYs instead of reattaching the preserved handle', async () => { @@ -13612,6 +13672,9 @@ describe('connectPanePty', () => { | ((ptyId: string) => void) | undefined onPtySpawn?.(freshPtyId) + const connectCallbacks = callbacks as ConnectCallbacks | undefined + connectCallbacks?.onReplayData?.('shell ready\r\n') + connectCallbacks?.onConnect?.() return freshPtyId } ) @@ -13646,6 +13709,7 @@ describe('connectPanePty', () => { } as StoreState const pane = createPane(1) + pane.terminal.write.mockImplementation((_data: string, callback?: () => void) => callback?.()) const manager = createManager(1) const deps = createDeps({ restoredLeafId: LEAF_1, @@ -13685,6 +13749,7 @@ describe('connectPanePty', () => { expect(deps.clearTabPtyId).toHaveBeenCalledWith('tab-1', restoredPtyId) expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, freshPtyId) expect(deps.updateTabPtyId).toHaveBeenCalledWith('tab-1', freshPtyId) + expect(window.api.pty.reportRendererSerializerReady).toHaveBeenCalledWith(freshPtyId) expect(transport.sendInput).not.toHaveBeenCalled() expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledWith(paneKey) }) @@ -15813,7 +15878,9 @@ describe('connectPanePty', () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport() transport.connect.mockImplementation(async (opts: { sessionId?: string }) => { - return { id: opts.sessionId ?? 'pty-new', replay: 'restored-ssh-output' } + const id = opts.sessionId ?? 'pty-new' + transport.getPtyId.mockReturnValue(id) + return { id, replay: 'restored-ssh-output' } }) transportFactoryQueue.push(transport) @@ -15826,6 +15893,7 @@ describe('connectPanePty', () => { } const pane = createPane(1) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) const manager = createManager(1) const deps = createDeps({ restoredLeafId: LEAF_1, @@ -15835,6 +15903,15 @@ describe('connectPanePty', () => { connectPanePty(pane as never, manager as never, deps as never) await flushAsyncTicks(20) + const settlePaneSerializer = vi.mocked(window.api.pty.settlePaneSerializer) + expect(parseCallbacks.length).toBeGreaterThan(0) + expect(settlePaneSerializer).not.toHaveBeenCalled() + for (let step = 0; step < 20 && settlePaneSerializer.mock.calls.length === 0; step += 1) { + parseCallbacks.shift()?.() + await flushAsyncTicks() + } + expect(settlePaneSerializer).toHaveBeenCalledWith(expect.any(String), 1) + const api = ( globalThis as unknown as { window: { @@ -15855,12 +15932,9 @@ describe('connectPanePty', () => { // Why: the relay's replay buffer holds the full terminal history, so the // client clears xterm before writing to prevent duplication with any // content already in the terminal from a prior session. - expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[2J\x1b[3J\x1b[H', expect.any(Function)) - expect(pane.terminal.write).toHaveBeenCalledWith('restored-ssh-output', expect.any(Function)) - expect(pane.terminal.write).toHaveBeenCalledWith( - POST_REPLAY_REATTACH_RESET, - expect.any(Function) - ) + expect(writes).toContain('\x1b[2J\x1b[3J\x1b[H') + expect(writes).toContain('restored-ssh-output') + expect(writes).toContain(POST_REPLAY_REATTACH_RESET) expect(api.pty.signal).toHaveBeenCalledWith('leaf-session', 'SIGWINCH') }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 8382693987f..351fc5b5e2f 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -98,7 +98,11 @@ import { } from '../../../../shared/terminal-color-scheme-protocol' import { warnTerminalLifecycleAnomaly } from './terminal-lifecycle-diagnostics' import { subscribeToTerminalUserInput } from './terminal-user-input-signal' -import { registerPtySerializer, registerPtyTitleSource } from './pty-buffer-serializer' +import { + hasPtySerializer, + registerPtySerializer, + registerPtyTitleSource +} from './pty-buffer-serializer' import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream' import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection' import { @@ -4020,7 +4024,10 @@ export function connectPanePty( return { data, cols: pane.terminal.cols, - rows: pane.terminal.rows + rows: pane.terminal.rows, + ...(rendererOrderedPtyId === ptyId && rendererOrderedSeq !== null + ? { seq: rendererOrderedSeq } + : {}) } } catch { return null @@ -4043,6 +4050,47 @@ export function connectPanePty( } } + let replayWriteQueue = Promise.resolve() + const settlePaneSerializerAfterReplay = async ( + ptyId: string, + generation: number + ): Promise => { + try { + await replayWriteQueue + if (disposed || transport.getPtyId() !== ptyId) { + await window.api.pty.clearPendingPaneSerializer(cacheKey, generation).catch(() => {}) + return + } + await waitForTerminalOutputParsed(pane.terminal) + if (!disposed && transport.getPtyId() === ptyId) { + await window.api.pty.settlePaneSerializer(cacheKey, generation) + return + } + } catch { + // Clear below so a failed parser/replay cannot leave the pane generation pending. + } + await window.api.pty.clearPendingPaneSerializer(cacheKey, generation).catch(() => {}) + } + const reportRemoteRendererSerializerReady = (): void => { + const ptyId = transport.getPtyId() + if (!ptyId || !isRemoteRuntimePtyId(ptyId)) { + return + } + if (!hasPtySerializer(ptyId)) { + registerPaneSerializerFor(ptyId) + } + // Why: onSubscribed follows the snapshot callback, but replay drains + // asynchronously; join it and xterm's parser before reporting readiness. + void replayWriteQueue + .then(() => waitForTerminalOutputParsed(pane.terminal)) + .then(() => { + if (!disposed && transport.getPtyId() === ptyId) { + void window.api.pty.reportRendererSerializerReady?.(ptyId) + } + }) + .catch(() => {}) + } + // Why: for ordinary local startup commands, the local PTY provider already // writes via the shell-ready barrier. terminal-paste and SSH startup // commands stay renderer-delivered so xterm/relay can apply their handling. @@ -4462,6 +4510,7 @@ export function connectPanePty( ...(coldRestoreOverride ? { launchAgent: coldRestoreOverride.agent } : {}), ...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}), callbacks: { + onConnect: reportRemoteRendererSerializerReady, onData: dataCallback, onReplayData: replayDataCallback, onError: reportError @@ -4529,9 +4578,14 @@ export function connectPanePty( reconcilePtySizeAfterSpawn(resolvedPtyId, cols, rows) } const gen = await preSignalPromise - if (typeof gen === 'number' && resolvedPtyId) { - if (!isRemoteRuntimePtyId(resolvedPtyId)) { + if ( + resolvedPtyId && + (typeof gen === 'number' || isRemoteRuntimePtyId(resolvedPtyId)) + ) { + if (!isRemoteRuntimePtyId(resolvedPtyId) || !hasPtySerializer(resolvedPtyId)) { registerPaneSerializerFor(resolvedPtyId) + } + if (typeof gen === 'number') { void window.api.pty.settlePaneSerializer(cacheKey, gen).catch(() => {}) } } else if (typeof gen === 'number') { @@ -4778,7 +4832,6 @@ export function connectPanePty( }) } - let replayWriteQueue = Promise.resolve() type PendingReplayData = { data: string clearBeforeReplay: boolean @@ -7117,6 +7170,7 @@ export function connectPanePty( ...(coldRestoreStartup?.agent ? { launchAgent: coldRestoreStartup.agent } : {}), ...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}), callbacks: { + onConnect: reportRemoteRendererSerializerReady, onData: dataCallback, onReplayData: replayDataCallback, onError: (message) => { @@ -7165,7 +7219,17 @@ export function connectPanePty( const gen = await preSignalPromise if (typeof gen === 'number') { if (!isRemoteRuntimePtyId(pendingSessionId)) { - void window.api.pty.settlePaneSerializer(cacheKey, gen).catch(() => {}) + const settledPtyId = + result && typeof result === 'object' && 'id' in result + ? result.id + : (transport.getPtyId() ?? pendingSessionId) + const hasRestorePayload = + result && + typeof result === 'object' && + ('snapshot' in result || 'replay' in result || 'coldRestore' in result) + await (hasRestorePayload + ? settlePaneSerializerAfterReplay(settledPtyId, gen) + : window.api.pty.settlePaneSerializer(cacheKey, gen)) } } }) @@ -7311,6 +7375,7 @@ export function connectPanePty( ...(coldRestoreStartup?.agent ? { launchAgent: coldRestoreStartup.agent } : {}), ...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}), callbacks: { + onConnect: reportRemoteRendererSerializerReady, onData: dataCallback, onReplayData: replayDataCallback, onError: (message) => { @@ -7355,7 +7420,17 @@ export function connectPanePty( const gen = await preSignalPromise if (typeof gen === 'number') { if (!isRemoteRuntimePtyId(deferredReattachSessionId)) { - void window.api.pty.settlePaneSerializer(cacheKey, gen).catch(() => {}) + const settledPtyId = + result && typeof result === 'object' && 'id' in result + ? result.id + : (transport.getPtyId() ?? deferredReattachSessionId) + const hasRestorePayload = + result && + typeof result === 'object' && + ('snapshot' in result || 'replay' in result || 'coldRestore' in result) + await (hasRestorePayload + ? settlePaneSerializerAfterReplay(settledPtyId, gen) + : window.api.pty.settlePaneSerializer(cacheKey, gen)) } } }) @@ -7414,6 +7489,7 @@ export function connectPanePty( cols, rows, callbacks: { + onConnect: reportRemoteRendererSerializerReady, onData: dataCallback, onReplayData: replayDataCallback, onError: reportError @@ -7424,7 +7500,7 @@ export function connectPanePty( updateTabPtyId: 'if-missing', sampleVisibleForegroundAgent: true }) - if (attachPtyId === eagerLivePtyId) { + if (attachPtyId === eagerLivePtyId || isRemoteRuntimePtyId(attachedPtyId)) { registerPaneSerializerFor(attachedPtyId) } } catch (err) { @@ -7470,6 +7546,7 @@ export function connectPanePty( cols, rows, callbacks: { + onConnect: reportRemoteRendererSerializerReady, onData: dataCallback, onReplayData: replayDataCallback, onError: reportError diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index bc0ca7fec92..9e188a80805 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -969,6 +969,7 @@ describe('useIpcEvents browser tab create routing', () => { onActivateWorktree: () => () => {}, onCreateTerminal: () => () => {}, onRequestTerminalCreate: () => () => {}, + onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, onRenameTerminal: () => () => {}, @@ -1190,6 +1191,7 @@ describe('useIpcEvents updater integration', () => { onActivateWorktree: () => () => {}, onCreateTerminal: () => () => {}, onRequestTerminalCreate: () => () => {}, + onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, onRenameTerminal: () => () => {}, @@ -1434,6 +1436,7 @@ describe('useIpcEvents updater integration', () => { onActivateWorktree: () => () => {}, onCreateTerminal: () => () => {}, onRequestTerminalCreate: () => () => {}, + onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, onRenameTerminal: () => () => {}, @@ -1918,6 +1921,7 @@ describe('useIpcEvents updater integration', () => { requestTerminalCreateListenerRef.current = listener return () => {} }, + onRequestTerminalTabMount: () => () => {}, replyTerminalCreate, onSplitTerminal: () => () => {}, onRenameTerminal: () => () => {}, @@ -2807,6 +2811,7 @@ describe('useIpcEvents browser tab close routing', () => { onActivateWorktree: () => () => {}, onCreateTerminal: () => () => {}, onRequestTerminalCreate: () => () => {}, + onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, onRenameTerminal: () => () => {}, @@ -3296,6 +3301,7 @@ describe('useIpcEvents browser tab close routing', () => { onActivateWorktree: () => () => {}, onCreateTerminal: () => () => {}, onRequestTerminalCreate: () => () => {}, + onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, onRenameTerminal: () => () => {}, @@ -3513,6 +3519,7 @@ describe('useIpcEvents browser tab close routing', () => { onActivateWorktree: () => () => {}, onCreateTerminal: () => () => {}, onRequestTerminalCreate: () => () => {}, + onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, onRenameTerminal: () => () => {}, @@ -3725,6 +3732,7 @@ describe('useIpcEvents browser tab close routing', () => { onActivateWorktree: () => () => {}, onCreateTerminal: () => () => {}, onRequestTerminalCreate: () => () => {}, + onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, onRenameTerminal: () => () => {}, @@ -3964,6 +3972,7 @@ describe('useIpcEvents CLI-created worktree activation', () => { }, onCreateTerminal: () => () => {}, onRequestTerminalCreate: () => () => {}, + onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, onRenameTerminal: () => () => {}, @@ -4211,6 +4220,7 @@ describe('useIpcEvents CLI-created worktree activation', () => { onActivateWorktree: () => () => {}, onCreateTerminal: () => () => {}, onRequestTerminalCreate: () => () => {}, + onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, onRenameTerminal: () => () => {}, @@ -4444,6 +4454,7 @@ describe('useIpcEvents agent status snapshot integration', () => { onActivateWorktree: () => () => {}, onCreateTerminal: () => () => {}, onRequestTerminalCreate: () => () => {}, + onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, onRenameTerminal: () => () => {}, diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index efec4bae01c..b3e2dae6b17 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -14,6 +14,8 @@ import { createBackgroundSleepingAgentWakeDispatcher } from '@/lib/wake-sleeping import { OPEN_WORKSPACE_BOARD_EVENT } from '@/components/sidebar/useWorkspaceBoardPanel' import { SPLIT_TERMINAL_PANE_EVENT, CLOSE_TERMINAL_PANE_EVENT } from '@/constants/terminal' import { requestBackgroundTerminalWorktreeMount } from '@/components/terminal/background-terminal-worktree-mount' +import { planMobileTerminalTabMount } from '@/lib/mobile-terminal-tab-mount' +import { hasRegisteredRuntimeTerminalTab } from '@/runtime/sync-runtime-graph' import type { SplitTerminalPaneDetail, CloseTerminalPaneDetail } from '@/constants/terminal' import { getVisibleWorktreeIds } from '@/components/sidebar/visible-worktrees' import { activateTabNumberShortcut } from '@/lib/tab-number-shortcuts' @@ -1666,6 +1668,32 @@ export function useIpcEvents(): void { ) ) + // Why: background-mounting a mobile-subscribed tab attaches a PTY that this + // renderer never mounted, without navigating the desktop (STA-1840). + unsubs.push( + window.api.ui.onRequestTerminalTabMount(({ worktreeId, tabId, ptyId }) => { + if (!worktreeId) { + return + } + // Why: synthetic pty handles need persisted-tab resolution, but a miss + // must not mount every saved terminal in a large hidden worktree. + const mount = planMobileTerminalTabMount( + useAppStore.getState(), + { + worktreeId, + ...(tabId ? { tabId } : {}), + ...(ptyId ? { ptyId } : {}) + }, + { + isTabMounted: hasRegisteredRuntimeTerminalTab + } + ) + if (mount) { + requestBackgroundTerminalWorktreeMount(mount) + } + }) + ) + // Why: CLI-driven terminal creation sends a request and waits for the // tabId reply so it can resolve a handle the caller can use immediately. // This mirrors the browser's onRequestTabCreate/replyTabCreate pattern. diff --git a/src/renderer/src/lib/mobile-terminal-tab-mount.test.ts b/src/renderer/src/lib/mobile-terminal-tab-mount.test.ts new file mode 100644 index 00000000000..6529ebca17f --- /dev/null +++ b/src/renderer/src/lib/mobile-terminal-tab-mount.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AppState } from '@/store/types' +import { planMobileTerminalTabMount } from './mobile-terminal-tab-mount' + +type PlannerState = Pick + +function state(tabCount = 1): PlannerState { + return { + tabsByWorktree: { + wt: Array.from({ length: tabCount }, (_, index) => ({ + id: `tab-${index}`, + ptyId: `wt@@${index}` + })) + } as unknown as AppState['tabsByWorktree'], + terminalLayoutsByTabId: {} + } +} + +describe('planMobileTerminalTabMount', () => { + it('keeps real-tab requests targeted to exactly one tab', () => { + expect(planMobileTerminalTabMount(state(), { worktreeId: 'wt', tabId: 'tab-0' })).toEqual({ + worktreeId: 'wt', + tabIds: ['tab-0'] + }) + }) + + it('resolves synthetic handles to exactly one owning tab at workspace scale', () => { + expect(planMobileTerminalTabMount(state(200), { worktreeId: 'wt', ptyId: 'wt@@173' })).toEqual({ + worktreeId: 'wt', + tabIds: ['tab-173'] + }) + }) + + it('does not mount the whole worktree when a stale pty id has no owner', () => { + expect( + planMobileTerminalTabMount(state(200), { worktreeId: 'wt', ptyId: 'wt@@missing' }) + ).toBeNull() + }) + + it('does not mount either tab when stale persistence has duplicate pty ownership', () => { + const s = state(200) + s.terminalLayoutsByTabId['tab-199'] = { + root: null, + activeLeafId: null, + expandedLeafId: null, + ptyIdsByLeafId: { leaf: 'wt@@173' } + } + + expect(planMobileTerminalTabMount(s, { worktreeId: 'wt', ptyId: 'wt@@173' })).toBeNull() + }) + + it('does not mount a hidden worktree for a stale direct tab id', () => { + const isTabMounted = vi.fn() + + expect( + planMobileTerminalTabMount( + state(200), + { worktreeId: 'wt', tabId: 'tab-missing' }, + { isTabMounted } + ) + ).toBeNull() + expect(isTabMounted).not.toHaveBeenCalled() + }) + + it('does not schedule hidden layout work for an already-mounted tab', () => { + const isTabMounted = vi.fn().mockReturnValue(true) + + expect( + planMobileTerminalTabMount( + state(200), + { worktreeId: 'wt', ptyId: 'wt@@173' }, + { isTabMounted } + ) + ).toBeNull() + expect(isTabMounted).toHaveBeenCalledTimes(1) + expect(isTabMounted).toHaveBeenCalledWith('tab-173') + }) +}) diff --git a/src/renderer/src/lib/mobile-terminal-tab-mount.ts b/src/renderer/src/lib/mobile-terminal-tab-mount.ts new file mode 100644 index 00000000000..28431cc5ad6 --- /dev/null +++ b/src/renderer/src/lib/mobile-terminal-tab-mount.ts @@ -0,0 +1,41 @@ +import type { AppState } from '@/store/types' +import type { BackgroundMountTerminalWorktreeDetail } from '@/constants/terminal' +import { resolveTerminalTabIdForPtyId } from './terminal-tab-for-pty-id' + +export type MobileTerminalTabMountRequest = { + worktreeId: string + tabId?: string + ptyId?: string +} + +type MobileTerminalTabMountOptions = { + isTabMounted?: (tabId: string) => boolean +} + +/** Why: exact-tab planning prevents a stale ptyId from mounting every saved xterm (#8597). */ +export function planMobileTerminalTabMount( + state: Pick, + request: MobileTerminalTabMountRequest, + options: MobileTerminalTabMountOptions = {} +): BackgroundMountTerminalWorktreeDetail | null { + if (!request.worktreeId) { + return null + } + const requestedTabExists = request.tabId + ? (state.tabsByWorktree[request.worktreeId] ?? []).some((tab) => tab.id === request.tabId) + : false + // Why: stale real-tab handles must fail closed like stale synthetic handles; + // otherwise they mount and measure a hidden worktree with no pane to recover. + const tabId = request.tabId + ? requestedTabExists + ? request.tabId + : null + : request.ptyId + ? resolveTerminalTabIdForPtyId(state, request.worktreeId, request.ptyId) + : null + // Why: replaying the background-mount event for a live pane restarts its + // three-second hidden measurement window on every mobile reconnect. + return tabId && !options.isTabMounted?.(tabId) + ? { worktreeId: request.worktreeId, tabIds: [tabId] } + : null +} diff --git a/src/renderer/src/lib/terminal-tab-for-pty-id.test.ts b/src/renderer/src/lib/terminal-tab-for-pty-id.test.ts new file mode 100644 index 00000000000..4fdc698e79e --- /dev/null +++ b/src/renderer/src/lib/terminal-tab-for-pty-id.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { resolveTerminalTabIdForPtyId } from './terminal-tab-for-pty-id' +import type { AppState } from '@/store/types' + +type ResolverState = Pick + +function state(partial: { + tabs?: Record + layouts?: Record }> +}): ResolverState { + return { + tabsByWorktree: (partial.tabs ?? {}) as unknown as AppState['tabsByWorktree'], + terminalLayoutsByTabId: (partial.layouts ?? {}) as unknown as AppState['terminalLayoutsByTabId'] + } +} + +describe('resolveTerminalTabIdForPtyId', () => { + it('matches a tab by its own ptyId', () => { + const s = state({ + tabs: { + wt: [ + { id: 'tab-a', ptyId: 'wt@@1' }, + { id: 'tab-b', ptyId: 'wt@@2' } + ] + } + }) + expect(resolveTerminalTabIdForPtyId(s, 'wt', 'wt@@2')).toBe('tab-b') + }) + + it('matches a tab by a split leaf ptyId in its saved layout', () => { + const s = state({ + tabs: { wt: [{ id: 'tab-a', ptyId: null }] }, + layouts: { 'tab-a': { ptyIdsByLeafId: { leaf1: 'wt@@1', leaf2: 'wt@@9' } } } + }) + expect(resolveTerminalTabIdForPtyId(s, 'wt', 'wt@@9')).toBe('tab-a') + }) + + it('returns null when no tab owns the ptyId', () => { + const s = state({ tabs: { wt: [{ id: 'tab-a', ptyId: 'wt@@1' }] } }) + expect(resolveTerminalTabIdForPtyId(s, 'wt', 'wt@@nope')).toBeNull() + }) + + it('returns null when stale persistence binds the ptyId to multiple tabs', () => { + const s = state({ + tabs: { + wt: [ + { id: 'tab-a', ptyId: 'wt@@1' }, + { id: 'tab-b', ptyId: null } + ] + }, + layouts: { 'tab-b': { ptyIdsByLeafId: { leaf2: 'wt@@1' } } } + }) + expect(resolveTerminalTabIdForPtyId(s, 'wt', 'wt@@1')).toBeNull() + }) + + it('returns null for an unknown worktree', () => { + const s = state({ tabs: { wt: [{ id: 'tab-a', ptyId: 'wt@@1' }] } }) + expect(resolveTerminalTabIdForPtyId(s, 'other', 'wt@@1')).toBeNull() + }) +}) diff --git a/src/renderer/src/lib/terminal-tab-for-pty-id.ts b/src/renderer/src/lib/terminal-tab-for-pty-id.ts new file mode 100644 index 00000000000..77ee3c29706 --- /dev/null +++ b/src/renderer/src/lib/terminal-tab-for-pty-id.ts @@ -0,0 +1,27 @@ +import type { AppState } from '@/store/types' + +/** Resolve a synthetic mobile handle's ptyId through persisted tab and split bindings. */ +export function resolveTerminalTabIdForPtyId( + state: Pick, + worktreeId: string, + ptyId: string +): string | null { + const tabs = state.tabsByWorktree[worktreeId] ?? [] + let resolvedTabId: string | null = null + for (const tab of tabs) { + const ptyIdsByLeafId = state.terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId + const ownsPty = + tab.ptyId === ptyId || + (ptyIdsByLeafId !== undefined && Object.values(ptyIdsByLeafId).includes(ptyId)) + if (!ownsPty) { + continue + } + if (resolvedTabId && resolvedTabId !== tab.id) { + // Why: stale duplicate ownership must not attach whichever hidden tab + // happens to appear first in persisted order. + return null + } + resolvedTabId = tab.id + } + return resolvedTabId +} diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index abd8534c7a1..5fa4365a344 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -2424,6 +2424,7 @@ function createWebUiApi(): NonNullable['ui']> { onActivateWorktree: () => noopUnsubscribe, onCreateTerminal: () => noopUnsubscribe, onRequestTerminalCreate: () => noopUnsubscribe, + onRequestTerminalTabMount: () => noopUnsubscribe, replyTerminalCreate: () => {}, onSplitTerminal: () => noopUnsubscribe, onRenameTerminal: () => noopUnsubscribe, @@ -2865,6 +2866,7 @@ function createPtyApi(): NonNullable['pty']> { declarePendingPaneSerializer: () => Promise.resolve(0), settlePaneSerializer: () => Promise.resolve(), clearPendingPaneSerializer: () => Promise.resolve(), + reportRendererSerializerReady: () => Promise.resolve(), management: { listSessions: () => Promise.resolve({ sessions: [], degraded: false }), killAll: () => Promise.resolve({ killedCount: 0, remainingCount: 0, killedSessionIds: [] }), From 69c14fadceee8218858759890481b0b606caae55 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:28:39 -0700 Subject: [PATCH 09/38] fix(terminal): make viewport ownership transactional across output, replay, and reflow (#8674) Co-authored-by: Orca --- src/main/ipc/pty.test.ts | 6 +- src/main/runtime/orca-runtime.ts | 9 +- src/preload/api-types.ts | 8 +- src/preload/index.ts | 8 +- .../components/terminal-pane/TerminalPane.tsx | 62 +- .../desktop-fit-fallback.test.ts | 66 ++ .../terminal-pane/desktop-fit-fallback.ts | 42 + .../terminal-pane/pty-connection.test.ts | 820 +++++++++++-- .../terminal-pane/pty-connection.ts | 1051 ++++++++++++----- .../pty-size-reassertion.test.ts | 34 +- .../terminal-pane/pty-size-reassertion.ts | 5 +- .../terminal-pane/replay-guard.test.ts | 22 + .../components/terminal-pane/replay-guard.ts | 46 + .../terminal-pane/terminal-appearance.ts | 23 +- .../terminal-visibility-resume.test.ts | 19 +- .../terminal-visibility-resume.ts | 16 +- .../use-terminal-pane-global-effects.test.ts | 4 +- .../use-terminal-pane-lifecycle.ts | 19 +- .../terminal-pane/useTerminalFontZoom.ts | 10 +- .../pane-fit-resize-observer.test.ts | 21 + .../pane-manager/pane-fit-resize-observer.ts | 6 +- src/renderer/src/lib/pane-manager/pane-fit.ts | 257 ++++ .../pane-initial-fit-lifecycle.test.ts | 28 + .../lib/pane-manager/pane-lifecycle.test.ts | 4 +- .../src/lib/pane-manager/pane-lifecycle.ts | 10 +- .../lib/pane-manager/pane-manager-types.ts | 2 + .../src/lib/pane-manager/pane-scroll.test.ts | 213 ++++ .../src/lib/pane-manager/pane-scroll.ts | 213 +++- .../pane-terminal-output-scheduler.ts | 67 +- .../lib/pane-manager/pane-tree-ops.test.ts | 416 ++++++- .../src/lib/pane-manager/pane-tree-ops.ts | 120 +- .../pane-webgl-refresh-lifecycle.test.ts | 33 +- .../lib/pane-manager/pane-webgl-renderer.ts | 8 +- .../terminal-reflow-scroll-anchor.ts | 137 +++ .../terminal-scroll-buffer-snapshot.ts | 53 + .../terminal-scroll-intent-dom-tracking.ts | 254 ++++ ...erminal-scroll-intent-input-resync.test.ts | 298 +++++ .../terminal-scroll-intent-rebuild.ts | 125 ++ .../terminal-scroll-intent-settle.ts | 33 + ...roll-intent-structural-transitions.test.ts | 116 ++ .../terminal-scroll-intent.test.ts | 441 ++++++- .../pane-manager/terminal-scroll-intent.ts | 321 +++-- .../pane-manager/terminal-scrollback-clear.ts | 16 + ...inal-structural-replay-coordinator.test.ts | 248 ++++ .../terminal-structural-replay-coordinator.ts | 91 ++ .../terminal-viewport-scrollbar-sync.ts | 29 + .../xterm-user-scrolling-contract.test.ts | 182 +++ ...codex-skill-preview-artifact-repro.spec.ts | 96 +- ...l-pinned-viewport-streaming-switch.spec.ts | 210 ++++ .../e2e/terminal-scroll-intent-follow.spec.ts | 156 ++- 50 files changed, 5575 insertions(+), 899 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/desktop-fit-fallback.test.ts create mode 100644 src/renderer/src/components/terminal-pane/desktop-fit-fallback.ts create mode 100644 src/renderer/src/lib/pane-manager/pane-fit.ts create mode 100644 src/renderer/src/lib/pane-manager/terminal-reflow-scroll-anchor.ts create mode 100644 src/renderer/src/lib/pane-manager/terminal-scroll-buffer-snapshot.ts create mode 100644 src/renderer/src/lib/pane-manager/terminal-scroll-intent-dom-tracking.ts create mode 100644 src/renderer/src/lib/pane-manager/terminal-scroll-intent-input-resync.test.ts create mode 100644 src/renderer/src/lib/pane-manager/terminal-scroll-intent-rebuild.ts create mode 100644 src/renderer/src/lib/pane-manager/terminal-scroll-intent-settle.ts create mode 100644 src/renderer/src/lib/pane-manager/terminal-scroll-intent-structural-transitions.test.ts create mode 100644 src/renderer/src/lib/pane-manager/terminal-scrollback-clear.ts create mode 100644 src/renderer/src/lib/pane-manager/terminal-structural-replay-coordinator.test.ts create mode 100644 src/renderer/src/lib/pane-manager/terminal-structural-replay-coordinator.ts create mode 100644 src/renderer/src/lib/pane-manager/terminal-viewport-scrollbar-sync.ts create mode 100644 src/renderer/src/lib/pane-manager/xterm-user-scrolling-contract.test.ts create mode 100644 tests/e2e/terminal-pinned-viewport-streaming-switch.spec.ts diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 7ef9762b510..4f08581282b 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -6205,11 +6205,7 @@ describe('registerPtyHandlers', () => { paneKey })) as number await spawn() - const ready = spawnController.waitForRendererSerializer?.( - reusedPtyId, - priorGeneration, - 1_000 - ) + const ready = spawnController.waitForRendererSerializer?.(reusedPtyId, priorGeneration, 1_000) clearProviderPtyState(reusedPtyId) clearProviderPtyState(reusedPtyId) await handlers.get('pty:settlePaneSerializer')!(null, { paneKey, gen: secondGen }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index c08e72b63b5..3e7ba7e2791 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -19311,13 +19311,10 @@ export class OrcaRuntimeService { timeoutMs?: number, signal?: AbortSignal ): Promise { - return this.ptyController?.waitForRendererSerializer?.( - ptyId, - afterGeneration, - timeoutMs, - signal - ) ?? + return ( + this.ptyController?.waitForRendererSerializer?.(ptyId, afterGeneration, timeoutMs, signal) ?? Promise.resolve(false) + ) } // Why: a leaf appears in the graph before its PTY spawns. If we issue a diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 862ead494a2..9d5e0b1fd90 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1411,7 +1411,13 @@ export type PreloadApi = { onClearBufferRequest: (callback: (data: { ptyId: string }) => void) => () => void sendSerializedBuffer: ( requestId: string, - snapshot: { data: string; cols: number; rows: number; seq?: number; lastTitle?: string } | null + snapshot: { + data: string + cols: number + rows: number + seq?: number + lastTitle?: string + } | null ) => void declarePendingPaneSerializer: (paneKey: string) => Promise settlePaneSerializer: (paneKey: string, gen: number) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index a77fbad0f0f..699853f6c8d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1142,7 +1142,13 @@ const api = { sendSerializedBuffer: ( requestId: string, - snapshot: { data: string; cols: number; rows: number; seq?: number; lastTitle?: string } | null + snapshot: { + data: string + cols: number + rows: number + seq?: number + lastTitle?: string + } | null ): void => { ipcRenderer.send('pty:serializeBuffer:response', { requestId, snapshot }) }, diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index f9a4e1fc40f..975aad10f94 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -107,7 +107,9 @@ import { } from '../native-chat/native-chat-leaf-routing' import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { resolvePaneKeyForManager } from '@/lib/pane-manager/pane-key-resolution' -import { safeFit } from '@/lib/pane-manager/pane-tree-ops' +import { safeFit, safeFitAndThen } from '@/lib/pane-manager/pane-tree-ops' +import { applyDesktopFitFallbackAfterReplay } from './desktop-fit-fallback' +import { clearTerminalScrollbackAndFollowOutput } from '@/lib/pane-manager/terminal-scrollback-clear' import { captureTerminalShutdownLayout } from './terminal-shutdown-layout-capture' import { getOverrideAffectedPanes, getPanesNeedingOverrideFit } from './override-affected-panes' import { @@ -517,15 +519,12 @@ export default function TerminalPane({ if (rect.width === 0 || rect.height === 0) { continue } - safeFit(pane) - const stuckAtMobile = - event.priorCols != null && - event.priorRows != null && - pane.terminal.cols === event.priorCols && - pane.terminal.rows === event.priorRows - if (stuckAtMobile && event.cols > 0 && event.rows > 0) { - pane.terminal.resize(event.cols, event.rows) - } + applyDesktopFitFallbackAfterReplay(pane, { + ...event, + // Why: the timeout/replay queue can outlive this pane binding; + // never apply old server dimensions to a replacement PTY. + shouldApply: () => getAffectedPanes().includes(pane) + }) } }) } @@ -1130,7 +1129,7 @@ export default function TerminalPane({ const clearPaneScrollback = useCallback( (pane: ManagedPane): void => { clearedScrollbackLeafIdsRef.current.add(pane.leafId) - pane.terminal.clear() + clearTerminalScrollbackAndFollowOutput(pane.terminal) // Why: also clear the host buffer for remote-server panes, or the next // host snapshot replays the scrollback we just cleared locally. const ptyId = paneTransportsRef.current.get(pane.id)?.getPtyId() ?? null @@ -1884,26 +1883,27 @@ export default function TerminalPane({ return } for (const pane of manager.getPanes()) { - safeFit(pane) - const transport = paneTransportsRef.current.get(pane.id) - if (!transport?.isConnected()) { - continue - } - const ptyId = transport.getPtyId() - if (!ptyId) { - continue - } - // Why: match pty-connection resize guards so web refit retries do not - // forward SIGWINCH while mobile-lock or phone-fit overrides are active. - if (getFitOverrideForPty(ptyId) || isPtyLocked(ptyId)) { - continue - } - // Why: skip forwarding a stale near-zero fit to the host PTY while the - // overlay is still settling after a worktree switch. - if (pane.terminal.cols < 8 || pane.terminal.rows < 4) { - continue - } - transport.resize(pane.terminal.cols, pane.terminal.rows) + safeFitAndThen(pane, 'web-client-pty-resize', () => { + const transport = paneTransportsRef.current.get(pane.id) + if (!transport?.isConnected()) { + return + } + const ptyId = transport.getPtyId() + if (!ptyId) { + return + } + // Why: match pty-connection resize guards so web refit retries do not + // forward SIGWINCH while mobile-lock or phone-fit overrides are active. + if (getFitOverrideForPty(ptyId) || isPtyLocked(ptyId)) { + return + } + // Why: skip forwarding a stale near-zero fit to the host PTY while the + // overlay is still settling after a worktree switch. + if (pane.terminal.cols < 8 || pane.terminal.rows < 4) { + return + } + transport.resize(pane.terminal.cols, pane.terminal.rows) + }) } } const scheduleFrame = (): void => { diff --git a/src/renderer/src/components/terminal-pane/desktop-fit-fallback.test.ts b/src/renderer/src/components/terminal-pane/desktop-fit-fallback.test.ts new file mode 100644 index 00000000000..3fef752e5ea --- /dev/null +++ b/src/renderer/src/components/terminal-pane/desktop-fit-fallback.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from 'vitest' +import { applyDesktopFitFallbackAfterReplay } from './desktop-fit-fallback' +import { + beginTerminalScrollIntentBufferRebuild, + endTerminalScrollIntentBufferRebuild +} from '@/lib/pane-manager/terminal-scroll-intent-rebuild' + +function createPane() { + const terminal = { + cols: 49, + rows: 20, + buffer: { active: { type: 'normal', viewportY: 0, baseY: 0 } }, + resize: vi.fn((cols: number, rows: number) => { + terminal.cols = cols + terminal.rows = rows + }) + } + return { + terminal, + container: { + dataset: {}, + getBoundingClientRect: () => ({ width: 800, height: 600 }) + }, + fitAddon: { + proposeDimensions: vi.fn(() => null), + fit: vi.fn() + } + } +} + +describe('desktop fit fallback', () => { + it('waits until structural replay completes before direct resize', async () => { + const pane = createPane() + beginTerminalScrollIntentBufferRebuild(pane.terminal) + + applyDesktopFitFallbackAfterReplay(pane as never, { + cols: 120, + rows: 40, + priorCols: 49, + priorRows: 20 + }) + expect(pane.terminal.resize).not.toHaveBeenCalled() + + endTerminalScrollIntentBufferRebuild(pane.terminal) + await Promise.resolve() + expect(pane.terminal.resize).toHaveBeenCalledWith(120, 40) + }) + + it('drops deferred dimensions when the pane binding becomes stale', async () => { + const pane = createPane() + let isCurrent = true + beginTerminalScrollIntentBufferRebuild(pane.terminal) + applyDesktopFitFallbackAfterReplay(pane as never, { + cols: 120, + rows: 40, + priorCols: 49, + priorRows: 20, + shouldApply: () => isCurrent + }) + + isCurrent = false + endTerminalScrollIntentBufferRebuild(pane.terminal) + await Promise.resolve() + expect(pane.terminal.resize).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/desktop-fit-fallback.ts b/src/renderer/src/components/terminal-pane/desktop-fit-fallback.ts new file mode 100644 index 00000000000..7bb5da4f44d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/desktop-fit-fallback.ts @@ -0,0 +1,42 @@ +import type { ManagedPane } from '@/lib/pane-manager/pane-manager-types' +import { safeFit } from '@/lib/pane-manager/pane-fit' +import { deferTerminalGeometryMutationDuringRebuild } from '@/lib/pane-manager/terminal-scroll-intent-rebuild' + +type DesktopFitFallbackDimensions = { + cols: number + rows: number + priorCols?: number | null + priorRows?: number | null + shouldApply?: () => boolean +} + +export function applyDesktopFitFallbackAfterReplay( + pane: ManagedPane, + dimensions: DesktopFitFallbackDimensions +): void { + const applyFallback = (): void => { + if (dimensions.shouldApply?.() === false) { + return + } + safeFit(pane) + const stuckAtPriorGrid = + dimensions.priorCols != null && + dimensions.priorRows != null && + pane.terminal.cols === dimensions.priorCols && + pane.terminal.rows === dimensions.priorRows + if (stuckAtPriorGrid && dimensions.cols > 0 && dimensions.rows > 0) { + pane.terminal.resize(dimensions.cols, dimensions.rows) + } + } + // Why: the server dimensions are only a fallback; source-dimension replay + // must parse and restore its viewport before this can reflow xterm. + if ( + !deferTerminalGeometryMutationDuringRebuild( + pane.terminal, + 'desktop-fit-fallback', + applyFallback + ) + ) { + applyFallback() + } +} 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 317742e68c7..2cfae612636 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -418,57 +418,65 @@ function createPane(paneId: number) { cursorY: 0, cursorX: 0 } + const terminal = { + cols: 120, + rows: 40, + element: {}, + buffer: { + active: activeBuffer + }, + modes: { + bracketedPasteMode: false, + sendFocusMode: false + }, + options: { + scrollback: 5_000, + ignoreBracketedPasteMode: false, + theme: { + foreground: '#eeeeee', + background: '#111111' + } + }, + write: vi.fn<(data: string, callback?: () => void) => void>(function write(...args): void { + const [data, callback] = args + if (data === '' || callback?.name === 'runParsedSteps') { + callback?.() + } + }), + resize: vi.fn(), + clear: vi.fn(), + scrollToBottom: vi.fn(() => { + activeBuffer.viewportY = activeBuffer.baseY + }), + scrollToLine: vi.fn((line: number) => { + activeBuffer.viewportY = line + }), + scrollLines: vi.fn((amount: number) => { + activeBuffer.viewportY = Math.max( + 0, + Math.min(activeBuffer.baseY, activeBuffer.viewportY + amount) + ) + }), + paste: vi.fn(), + onData: vi.fn(() => ({ dispose: vi.fn() })), + onResize: vi.fn(() => ({ dispose: vi.fn() })), + onRender: vi.fn((_listener: () => void) => ({ dispose: vi.fn() })), + onTitleChange: vi.fn(() => ({ dispose: vi.fn() })), + hasSelection: vi.fn(() => false), + parser: { + registerCsiHandler: vi.fn(() => ({ dispose: vi.fn() })), + registerOscHandler: vi.fn(() => ({ dispose: vi.fn() })) + } + } return { id: paneId, leafId, stablePaneId: leafId, - terminal: { - cols: 120, - rows: 40, - element: {}, - buffer: { - active: activeBuffer - }, - modes: { - bracketedPasteMode: false, - sendFocusMode: false - }, - options: { - scrollback: 5_000, - ignoreBracketedPasteMode: false, - theme: { - foreground: '#eeeeee', - background: '#111111' - } - }, - write: vi.fn(), - resize: vi.fn(), - clear: vi.fn(), - scrollToBottom: vi.fn(() => { - activeBuffer.viewportY = activeBuffer.baseY - }), - scrollToLine: vi.fn((line: number) => { - activeBuffer.viewportY = line - }), - scrollLines: vi.fn((amount: number) => { - activeBuffer.viewportY = Math.max( - 0, - Math.min(activeBuffer.baseY, activeBuffer.viewportY + amount) - ) - }), - paste: vi.fn(), - onData: vi.fn(() => ({ dispose: vi.fn() })), - onResize: vi.fn(() => ({ dispose: vi.fn() })), - onTitleChange: vi.fn(() => ({ dispose: vi.fn() })), - hasSelection: vi.fn(() => false), - parser: { - registerCsiHandler: vi.fn(() => ({ dispose: vi.fn() })), - registerOscHandler: vi.fn(() => ({ dispose: vi.fn() })) - } - }, + terminal, container: createPaneContainer(), fitAddon: { - fit: vi.fn() + fit: vi.fn(), + proposeDimensions: vi.fn(() => ({ cols: terminal.cols, rows: terminal.rows })) } } } @@ -4934,7 +4942,10 @@ describe('connectPanePty', () => { const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } const transport = createMockTransport() + let currentPtyId: string | null = null + vi.mocked(transport.getPtyId).mockImplementation(() => currentPtyId) transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + currentPtyId = 'pty-local-1' capturedDataCallback.current = callbacks.onData ?? null return 'pty-local-1' }) @@ -4962,23 +4973,33 @@ describe('connectPanePty', () => { connectPanePty(pane as never, manager as never, deps as never) capturedDataCallback.current?.('\x1b]133;D;130\x07thebr ~/repo $ ') - await flushAsyncTicks(1) + await flushAsyncTicks() expect(mockStoreState.dropAgentStatus).toHaveBeenCalledWith(paneKey) expect(mockStoreState.removeAgentStatus).not.toHaveBeenCalled() }) it('clears pre-hook launch config when an Orca-started command exits', async () => { + vi.useFakeTimers({ toFake: ['setTimeout'] }) const { connectPanePty } = await import('./pty-connection') + vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('zsh') const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } const transport = createMockTransport() + let currentPtyId: string | null = null + vi.mocked(transport.getPtyId).mockImplementation(() => currentPtyId) transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + currentPtyId = 'pty-local-1' capturedDataCallback.current = callbacks.onData ?? null return 'pty-local-1' }) transportFactoryQueue.push(transport) const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, + ptyIdsByTabId: { 'tab-1': [] } + } as StoreState connectPanePty( createPane(1) as never, @@ -4991,7 +5012,8 @@ describe('connectPanePty', () => { agentEnv: {} }, launchAgent: 'codex' - } + }, + restoredPtyIdByLeafId: {} }) as never ) @@ -5004,7 +5026,9 @@ describe('connectPanePty', () => { expect.objectContaining({ agentType: 'codex' }) ) capturedDataCallback.current?.('\x1b]133;D;130\x07thebr ~/repo $ ') - await flushAsyncTicks(1) + await flushAsyncTicks() + await vi.advanceTimersByTimeAsync(350) + await flushAsyncTicks() expect(mockStoreState.clearAgentLaunchConfig).toHaveBeenCalledWith(paneKey) expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled() @@ -6023,7 +6047,7 @@ describe('connectPanePty', () => { it('resets reattach renderer state after daemon snapshot replay without applying the full mode reset', async () => { const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() + const transport = createMockTransport('tab-pty') transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { if (sessionId) { return { id: sessionId, snapshot: '\x1b[?1004hrestored snapshot' } @@ -6073,7 +6097,7 @@ describe('connectPanePty', () => { // the snapshot bytes, so a remote pane whose size drifted from the daemon's // grid still repaints the exact host layout. const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() + const transport = createMockTransport('tab-pty') transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { if (sessionId) { return { @@ -6121,13 +6145,301 @@ describe('connectPanePty', () => { expect(resizeToSnapshotCall as number).toBeLessThan(snapshotWriteCall as number) }) + it('waits for a recovered destination fit before forwarding its grid or live output', async () => { + const { connectPanePty } = await import('./pty-connection') + const { safeFit } = await import('@/lib/pane-manager/pane-tree-ops') + const transport = createMockTransport('tab-pty') + let deliverLiveData = (_data: string): void => {} + transport.connect.mockImplementation( + async ({ sessionId, callbacks }: { sessionId?: string; callbacks?: ConnectCallbacks }) => { + if (callbacks?.onData) { + deliverLiveData = callbacks.onData + } + return sessionId + ? { + id: sessionId, + snapshot: 'source-grid snapshot', + snapshotCols: 80, + snapshotRows: 24 + } + : null + } + ) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] } + } as StoreState + const pane = createPane(1) + pane.fitAddon.proposeDimensions = vi.fn(() => undefined) as never + const { parseCallbacks, writes } = captureCallbackTerminalWrites(pane) + const signalPty = window.api.pty.signal as unknown as ReturnType + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(20) + transport.resize.mockClear() + signalPty.mockClear() + while (parseCallbacks.length > 0) { + parseCallbacks.shift()?.() + await flushAsyncTicks(2) + } + await flushAsyncTicks(8) + + expect(transport.resize).not.toHaveBeenCalled() + expect(signalPty).not.toHaveBeenCalledWith('tab-pty', 'SIGWINCH') + + deliverLiveData('live-after-snapshot') + await flushAsyncTicks(4) + expect(writes.join('')).not.toContain('live-after-snapshot') + + pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: 120, rows: 40 })) as never + safeFit(pane as never) + await flushAsyncTicks(12) + + expect(transport.resize).toHaveBeenCalledWith(120, 40) + expect(signalPty).toHaveBeenCalledWith('tab-pty', 'SIGWINCH') + expect(writes.join('')).toContain('live-after-snapshot') + }) + + it('restores a pinned viewport only after a same-size reattach snapshot finishes parsing', async () => { + const { connectPanePty } = await import('./pty-connection') + const { markTerminalFollowOutput, markTerminalPinnedViewport } = + await import('@/lib/pane-manager/terminal-scroll-intent') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => + sessionId + ? { + id: sessionId, + snapshot: 'same-size authoritative snapshot', + snapshotCols: 120, + snapshotRows: 40 + } + : null + ) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] } + } as StoreState + const pane = createPane(1) + pane.terminal.buffer.active.baseY = 100 + pane.terminal.buffer.active.viewportY = 80 + markTerminalPinnedViewport(pane.terminal) + const { parseCallbacks } = captureCallbackTerminalWrites(pane) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(20) + expect(pane.terminal.scrollToLine).not.toHaveBeenCalled() + + // Model xterm's native pinned state after clear + replay: the old line + // number is no longer meaningful, but the old distance from bottom is. + pane.terminal.buffer.active.baseY = 200 + pane.terminal.buffer.active.viewportY = 0 + while (parseCallbacks.length > 0) { + parseCallbacks.shift()?.() + await flushAsyncTicks(2) + } + await flushAsyncTicks(8) + + expect(pane.terminal.scrollToLine).toHaveBeenLastCalledWith(180) + markTerminalFollowOutput(pane.terminal) + }) + + it('does not apply a queued reattach snapshot after the PTY is replaced', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + const reattachResult = createDeferred<{ + id: string + snapshot: string + }>() + const replayCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ sessionId, callbacks }: { sessionId?: string; callbacks?: ConnectCallbacks }) => { + if (!sessionId) { + return null + } + replayCallback.current = callbacks?.onReplayData ?? null + return reattachResult.promise + } + ) + transportFactoryQueue.push(transport) + const pane = createPane(1) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(8) + replayCallback.current?.('blocking replay') + await flushAsyncTicks(12) + expect(writes).toEqual(['\x1b[2J\x1b[3J\x1b[H']) + reattachResult.resolve({ id: 'tab-pty', snapshot: 'stale authoritative snapshot' }) + await flushAsyncTicks(12) + const resizeCallsBeforeReplacement = transport.resize.mock.calls.length + vi.mocked(transport.getPtyId).mockReturnValue('replacement-pty') + while (parseCallbacks.length > 0) { + parseCallbacks.shift()?.() + await flushAsyncTicks(4) + } + await flushAsyncTicks(12) + + expect(writes).not.toContain('stale authoritative snapshot') + expect(transport.resize).toHaveBeenCalledTimes(resizeCallsBeforeReplacement) + expect(window.api.pty.signal).not.toHaveBeenCalledWith('tab-pty', 'SIGWINCH') + }) + + it('does not apply a queued reattach snapshot after its PTY exits', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + const reattachResult = createDeferred<{ id: string; snapshot: string }>() + const replayCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks?: ConnectCallbacks }) => { + replayCallback.current = callbacks?.onReplayData ?? null + return reattachResult.promise + } + ) + transportFactoryQueue.push(transport) + const pane = createPane(1) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(8) + replayCallback.current?.('blocking replay') + await flushAsyncTicks(12) + reattachResult.resolve({ id: 'tab-pty', snapshot: 'dead authoritative snapshot' }) + await flushAsyncTicks(12) + const resizeCallsBeforeExit = transport.resize.mock.calls.length + vi.mocked(transport.getPtyId).mockReturnValue(null) + while (parseCallbacks.length > 0) { + parseCallbacks.shift()?.() + await flushAsyncTicks(4) + } + await flushAsyncTicks(12) + + expect(writes).not.toContain('dead authoritative snapshot') + expect(transport.resize).toHaveBeenCalledTimes(resizeCallsBeforeExit) + expect(window.api.pty.signal).not.toHaveBeenCalledWith('tab-pty', 'SIGWINCH') + expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I') + }) + + it('drops stale callbacks but delivers fresh replacement output after an old replay clear', async () => { + const { connectPanePty } = await import('./pty-connection') + const { getTerminalScrollIntentKind, markTerminalPinnedViewport } = + await import('@/lib/pane-manager/terminal-scroll-intent') + const transport = createMockTransport('tab-pty') + const oldResult = createDeferred() + const oldCallbacks: { current: ConnectCallbacks | null } = { current: null } + const replacementCallbacks: { current: ConnectCallbacks | null } = { current: null } + let connectCount = 0 + transport.connect.mockImplementation( + async ({ callbacks }: { sessionId?: string; callbacks?: ConnectCallbacks }) => { + connectCount += 1 + if (connectCount === 1) { + oldCallbacks.current = callbacks ?? null + return oldResult.promise + } + vi.mocked(transport.getPtyId).mockReturnValue('replacement-pty') + replacementCallbacks.current = callbacks ?? null + return 'replacement-pty' + } + ) + transportFactoryQueue.push(transport) + const pane = createPane(1) + pane.terminal.buffer.active.viewportY = 80 + pane.terminal.buffer.active.baseY = 100 + markTerminalPinnedViewport(pane.terminal) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(8) + oldCallbacks.current?.onReplayData?.('old replay') + await flushAsyncTicks(8) + parseCallbacks.shift()?.() + await flushAsyncTicks(8) + oldCallbacks.current?.onError?.('SSH_SESSION_EXPIRED: tab-pty') + oldResult.resolve(undefined) + await flushAsyncTicks(20) + expect(replacementCallbacks.current).not.toBeNull() + + replacementCallbacks.current?.onData?.('B-PROMPT') + oldCallbacks.current?.onData?.('STALE-A') + while (parseCallbacks.length > 0) { + parseCallbacks.shift()?.() + await flushAsyncTicks(4) + } + await flushAsyncTicks(20) + + expect(writes).toContain('B-PROMPT') + expect(writes).not.toContain('STALE-A') + expect(pane.terminal.scrollToBottom).toHaveBeenCalled() + expect(getTerminalScrollIntentKind(pane.terminal)).toBe('followOutput') + }) + + it('retries a fresh-spawn native follow reset when renderer dimensions return', async () => { + const { connectPanePty } = await import('./pty-connection') + const { getTerminalScrollIntentKind } = + await import('@/lib/pane-manager/terminal-scroll-intent') + const transport = createMockTransport('fresh-pty') + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] } + } as StoreState + const pane = createPane(1) + pane.terminal.buffer.active.viewportY = 40 + pane.terminal.buffer.active.baseY = 100 + const renderListener: { current: (() => void) | null } = { current: null } + const renderDisposable = { dispose: vi.fn() } + pane.terminal.onRender = vi.fn((listener: () => void) => { + renderListener.current = listener + return renderDisposable + }) + vi.mocked(pane.terminal.scrollToBottom) + .mockImplementationOnce(() => { + throw new TypeError("Cannot read properties of undefined (reading 'dimensions')") + }) + .mockImplementation(() => { + pane.terminal.buffer.active.viewportY = pane.terminal.buffer.active.baseY + }) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks() + expect(pane.terminal.buffer.active.viewportY).toBe(40) + expect(renderListener.current).not.toBeNull() + + renderListener.current?.() + expect(pane.terminal.buffer.active.viewportY).toBe(100) + expect(pane.terminal.scrollToBottom).toHaveBeenCalledTimes(2) + expect(getTerminalScrollIntentKind(pane.terminal)).toBe('followOutput') + expect(renderDisposable.dispose).toHaveBeenCalledTimes(1) + }) + it('writes the daemon pendingEscapeTailAnsi after the reset on local reattach (#7329)', async () => { // Why: the mid-escape tail must be re-armed LAST — after the reattach reset, // whose ESC would abort it — so the racing live continuation completes it // instead of rendering literally. Covers the local daemon reattach path, // which previously dropped the field the remote path already honored. const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() + const transport = createMockTransport('tab-pty') transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { if (sessionId) { return { @@ -6269,7 +6581,7 @@ describe('connectPanePty', () => { it('preserves live modes and injects focus-in after focused agent reattach', async () => { const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() + const transport = createMockTransport('tab-pty') transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { if (sessionId) { return { id: sessionId, snapshot: '\x1b[?1004h\x1b[?25lrestored cursor snapshot' } @@ -6311,7 +6623,7 @@ describe('connectPanePty', () => { it('keeps ?25h in the live agent reattach reset when the snapshot leaves the cursor visible', async () => { const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() + const transport = createMockTransport('tab-pty') transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { if (sessionId) { // A mid-frame snapshot cut after the TUI re-showed its cursor. @@ -6344,7 +6656,7 @@ describe('connectPanePty', () => { it('does not inject focus-in after reattach when the terminal does not own DOM focus', async () => { const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() + const transport = createMockTransport('tab-pty') transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { if (sessionId) { return { id: sessionId, snapshot: '\x1b[?1004h\x1b[?25lrestored cursor snapshot' } @@ -6379,7 +6691,7 @@ describe('connectPanePty', () => { it('resets stale focus and cursor modes for a focused non-agent shell reattach', async () => { const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() + const transport = createMockTransport('tab-pty') transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { if (sessionId) { return { id: sessionId, snapshot: '\x1b[?1004h\x1b[?25lstale shell snapshot' } @@ -6416,7 +6728,7 @@ describe('connectPanePty', () => { it('does not treat persisted tab launchAgent metadata as a live agent reattach', async () => { const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() + const transport = createMockTransport('tab-pty') transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { if (sessionId) { return { id: sessionId, snapshot: '\x1b[?1004h\x1b[?25lstale shell snapshot' } @@ -6464,7 +6776,7 @@ describe('connectPanePty', () => { it('does not treat an agent-name token in a shell title as a live agent reattach', async () => { const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() + const transport = createMockTransport('tab-pty') transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { if (sessionId) { return { id: sessionId, snapshot: '\x1b[?1004h\x1b[?25lstale shell snapshot' } @@ -6503,7 +6815,7 @@ describe('connectPanePty', () => { it('does not treat ordinary shell scrollback mentioning Cursor Agent as a live agent reattach', async () => { const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() + const transport = createMockTransport('tab-pty') transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { if (sessionId) { return { @@ -6596,7 +6908,7 @@ describe('connectPanePty', () => { // authoritative source and wins by precedence. it('paints only the daemon snapshot when reattach result includes both snapshot and replay', async () => { const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() + const transport = createMockTransport('tab-pty') transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { if (sessionId) { return { @@ -6631,7 +6943,7 @@ describe('connectPanePty', () => { it('paints only relay replay when reattach result has replay and coldRestore but no snapshot', async () => { const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() + const transport = createMockTransport('tab-pty') transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { if (sessionId) { return { @@ -7872,7 +8184,7 @@ describe('connectPanePty', () => { ) transportFactoryQueue.push(transport) const pane = createPane(1) - const { writes } = captureCallbackTerminalWrites(pane) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) const deps = createDeps({ restoredLeafId: LEAF_1, restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } @@ -7882,8 +8194,14 @@ describe('connectPanePty', () => { await flushAsyncTicks(20) const snapshotIndex = writes.indexOf('authoritative-snapshot') - const liveIndex = writes.indexOf('post-snapshot-live') expect(snapshotIndex).toBeGreaterThanOrEqual(0) + expect(writes).not.toContain('post-snapshot-live') + while (parseCallbacks.length > 0) { + parseCallbacks.shift()?.() + await flushAsyncTicks(2) + } + await flushAsyncTicks(8) + const liveIndex = writes.indexOf('post-snapshot-live') expect(liveIndex).toBeGreaterThan(snapshotIndex) }) @@ -8059,6 +8377,49 @@ describe('connectPanePty', () => { ) }) + it('holds post-snapshot live bytes until a hidden restore can fit the destination grid', async () => { + enableMainAuthority() + const { safeFit } = await import('@/lib/pane-manager/pane-tree-ops') + const deps = createDeps({ isVisibleRef: { current: false } }) + const { pane, transport, dataCallback } = await connectHiddenPane(deps) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'source-grid hidden snapshot\r\n', + cols: 80, + rows: 24, + seq: 64 + }) + pane.fitAddon.proposeDimensions = vi.fn(() => undefined) as never + transport.resize.mockClear() + + dataCallback('hidden output\r\n', { seq: 16, rawLength: 16 }) + const { _dispatchPtyModelRestoreNeededForTest } = await import('./pty-model-restore-channel') + _dispatchPtyModelRestoreNeededForTest({ id: 'pty-id', reason: 'hidden-drop', markerSeq: 64 }) + ;(deps.isVisibleRef as { current: boolean }).current = true + const { requestTerminalBacklogRecovery } = + await import('@/lib/pane-manager/pane-terminal-output-scheduler') + requestTerminalBacklogRecovery(pane.terminal as never) + await flushAsyncTicks(20) + + expect(transport.resize).not.toHaveBeenCalled() + dataCallback('live-after-hidden', { seq: 81, rawLength: 17 }) + await flushAsyncTicks(6) + expect(pane.terminal.write.mock.calls.map((call) => String(call[0])).join('')).not.toContain( + 'live-after-hidden' + ) + + pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: 120, rows: 40 })) as never + safeFit(pane as never) + await flushAsyncTicks(20) + + expect(transport.resize).toHaveBeenCalledWith(120, 40) + expect(pane.terminal.write.mock.calls.map((call) => String(call[0])).join('')).toContain( + 'live-after-hidden' + ) + }) + it('kicks pane recovery when reveal finds the write pipeline certified dead', async () => { // The 2026-07-13 fossil-pane incident shape: bytes drop while hidden, // the pipeline is certified dead, and certification's own recovery @@ -8488,6 +8849,17 @@ describe('connectPanePty', () => { it('abandons the restore on queue overflow, writes the stream through, and repaints once', async () => { const { pane, dataCallback, getMainBufferSnapshot, resolveFirstSnapshot } = await startInFlightRestore() + const { markTerminalPinnedViewport } = + await import('@/lib/pane-manager/terminal-scroll-intent') + const parseCallbacks: (() => void)[] = [] + pane.terminal.write.mockImplementation((_data: string, callback?: () => void) => { + if (callback) { + parseCallbacks.push(callback) + } + }) + pane.terminal.buffer.active.viewportY = 42 + pane.terminal.buffer.active.baseY = 100 + markTerminalPinnedViewport(pane.terminal) // Flood while the snapshot is in flight: overflows the 512KB restore // queue — the live stream is outrunning snapshot fetch+replay. @@ -8501,6 +8873,15 @@ describe('connectPanePty', () => { // Cut 1: the overflow abandons the restore instead of re-fetching. expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + pane.terminal.buffer.active.viewportY = 200 + pane.terminal.buffer.active.baseY = 200 + for (const callback of parseCallbacks.splice(0)) { + callback() + } + // The post-replay fit is part of the transaction now; let its promise + // settle before asserting that overflow abandonment owns later bytes. + await flushAsyncTicks(20) + expect(pane.terminal.scrollToLine).toHaveBeenLastCalledWith(142) // Cut 2: drop sentinels and seq-gap chunks during the flood window // must not re-arm restores — the post-gap bytes write through. @@ -10315,7 +10696,11 @@ describe('connectPanePty', () => { const pane = createPane(1) pane.terminal.cols = 80 pane.terminal.rows = 8 - const { writes } = captureCallbackTerminalWrites(pane) + const writes: string[] = [] + pane.terminal.write = vi.fn((data: string, callback?: () => void) => { + writes.push(data) + callback?.() + }) const manager = createManager(1) const deps = createDeps({ isVisibleRef: { current: false }, @@ -10401,7 +10786,11 @@ describe('connectPanePty', () => { const pane = createPane(1) pane.terminal.cols = 80 pane.terminal.rows = 8 - const { writes } = captureCallbackTerminalWrites(pane) + const writes: string[] = [] + pane.terminal.write = vi.fn((data: string, callback?: () => void) => { + writes.push(data) + callback?.() + }) const manager = createManager(1) const deps = createDeps({ isVisibleRef: { current: false }, @@ -10477,7 +10866,11 @@ describe('connectPanePty', () => { const pane = createPane(1) pane.terminal.cols = 80 pane.terminal.rows = 8 - const { writes } = captureCallbackTerminalWrites(pane) + const writes: string[] = [] + pane.terminal.write = vi.fn((data: string, callback?: () => void) => { + writes.push(data) + callback?.() + }) const manager = createManager(1) const deps = createDeps({ isVisibleRef: { current: true }, @@ -11735,6 +12128,120 @@ describe('connectPanePty', () => { disposable.dispose() }) + it('cancels a delayed snapshot scroll restore when the pane binding is disposed', async () => { + const { connectPanePty } = await import('./pty-connection') + const { isTerminalScrollIntentRebuildInFlight } = + await import('@/lib/pane-manager/terminal-scroll-intent-rebuild') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null + } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) + const live = 'visible-after\r\n' + getMainBufferSnapshot.mockResolvedValue({ + data: 'snapshot-state\r\n', + cols: 100, + rows: 30, + seq: hidden.length + live.length + }) + + const pane = createPane(1) + pane.terminal.buffer.active.viewportY = 42 + pane.terminal.buffer.active.baseY = 100 + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + const deps = createDeps({ isVisibleRef: { current: false } }) + const disposable = connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(live, { + seq: hidden.length + live.length, + rawLength: live.length + }) + await flushAsyncTicks(20) + + expect(writes).toContain('snapshot-state\r\n') + expect(isTerminalScrollIntentRebuildInFlight(pane.terminal)).toBe(true) + pane.terminal.scrollToLine.mockClear() + disposable.dispose() + expect(isTerminalScrollIntentRebuildInFlight(pane.terminal)).toBe(true) + + pane.terminal.buffer.active.baseY = 200 + pane.terminal.buffer.active.viewportY = 200 + for (const callback of parseCallbacks) { + callback() + } + await flushAsyncTicks() + + expect(isTerminalScrollIntentRebuildInFlight(pane.terminal)).toBe(false) + expect(pane.terminal.scrollToLine).not.toHaveBeenCalled() + }) + + it('does not apply a delayed snapshot restore after newer user intent', async () => { + const { connectPanePty } = await import('./pty-connection') + const { markTerminalFollowOutput } = await import('@/lib/pane-manager/terminal-scroll-intent') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null + } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) + const live = 'visible-after\r\n' + getMainBufferSnapshot.mockResolvedValue({ + data: 'snapshot-state\r\n', + cols: 100, + rows: 30, + seq: hidden.length + live.length + }) + + const pane = createPane(1) + pane.terminal.buffer.active.viewportY = 42 + pane.terminal.buffer.active.baseY = 100 + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + const deps = createDeps({ isVisibleRef: { current: false } }) + const disposable = connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(live, { + seq: hidden.length + live.length, + rawLength: live.length + }) + await flushAsyncTicks(20) + + expect(writes).toContain('snapshot-state\r\n') + pane.terminal.buffer.active.viewportY = 200 + pane.terminal.buffer.active.baseY = 200 + markTerminalFollowOutput(pane.terminal) + pane.terminal.scrollToLine.mockClear() + for (const callback of parseCallbacks) { + callback() + } + await flushAsyncTicks() + + // Why: replay completion must not overwrite scroll intent recorded while + // xterm was still parsing the restored snapshot. + expect(pane.terminal.scrollToLine).not.toHaveBeenCalled() + disposable.dispose() + }) + it('does not signal SIGWINCH after hidden-backlog snapshot replay when dimensions are unchanged', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') @@ -12347,7 +12854,7 @@ describe('connectPanePty', () => { capturedReplayCallback.current?.('first replay') capturedReplayCallback.current?.('second replay') - await flushAsyncTicks(2) + await flushAsyncTicks(6) expect(pane.terminal.write).toHaveBeenCalledTimes(1) expect(pane.terminal.write).toHaveBeenNthCalledWith( @@ -12368,6 +12875,116 @@ describe('connectPanePty', () => { disposable.dispose() }) + it('holds newer live bytes until a later replay frame has fully parsed', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const transport = createMockTransport('remote:env-1@@terminal-live-order') + const callbacksRef: { + replay: ((data: string) => void) | null + data: ((data: string) => void) | null + } = { replay: null, data: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + callbacksRef.replay = callbacks.onReplayData ?? null + callbacksRef.data = callbacks.onData ?? null + return 'remote:env-1@@terminal-live-order' + }) + transportFactoryQueue.push(transport) + const pane = createPane(1) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + const binding = connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(8) + + callbacksRef.replay?.('authoritative replay') + await flushAsyncTicks(8) + expect(writes).toEqual(['\x1b[2J\x1b[3J\x1b[H']) + + callbacksRef.data?.('NEWER-LIVE\r\n') + expect(writes).not.toContain('NEWER-LIVE\r\n') + for (let index = 0; index < 12 && parseCallbacks.length > 0; index += 1) { + parseCallbacks.shift()?.() + await flushAsyncTicks(4) + } + await flushAsyncTicks(8) + + const replayIndex = writes.indexOf('authoritative replay') + const resetIndex = writes.indexOf(POST_REPLAY_REATTACH_RESET) + const liveIndex = writes.indexOf('NEWER-LIVE\r\n') + expect(replayIndex).toBeGreaterThan(0) + expect(resetIndex).toBeGreaterThan(replayIndex) + expect(liveIndex).toBeGreaterThan(resetIndex) + binding.dispose() + }) + + it('drops a queued relay replay instead of retagging it for a replacement PTY', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const transport = createMockTransport('remote:env-1@@terminal-old') + const replayCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + replayCallback.current = callbacks.onReplayData ?? null + return 'remote:env-1@@terminal-old' + }) + transportFactoryQueue.push(transport) + const pane = createPane(1) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + const binding = connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(8) + + replayCallback.current?.('blocking replay') + await flushAsyncTicks(8) + replayCallback.current?.('stale queued replay') + vi.mocked(transport.getPtyId).mockReturnValue('remote:env-1@@terminal-replacement') + while (parseCallbacks.length > 0) { + parseCallbacks.shift()?.() + await flushAsyncTicks(4) + } + await flushAsyncTicks(12) + + expect(writes).not.toContain('blocking replay') + expect(writes).not.toContain('stale queued replay') + binding.dispose() + }) + + it('requests snapshot recovery for one oversized live frame deferred by replay', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-large-live') + const callbacksRef: { + replay: ((data: string) => void) | null + data: ((data: string) => void) | null + } = { replay: null, data: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + callbacksRef.replay = callbacks.onReplayData ?? null + callbacksRef.data = callbacks.onData ?? null + return 'pty-large-live' + }) + transportFactoryQueue.push(transport) + const pane = createPane(1) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + const binding = connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(8) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue(null) + getMainBufferSnapshot.mockClear() + + callbacksRef.replay?.('authoritative replay') + await flushAsyncTicks(8) + const oversizedLiveFrame = 'L'.repeat(512 * 1024 + 1) + callbacksRef.data?.(oversizedLiveFrame) + while (parseCallbacks.length > 0) { + parseCallbacks.shift()?.() + await flushAsyncTicks(4) + } + await flushAsyncTicks(20) + + expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-large-live', { + scrollbackRows: 5000 + }) + expect(writes.some((write) => write.startsWith('L'))).toBe(false) + binding.dispose() + }) + it('does not switch renderers for Arabic output', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport() @@ -13610,12 +14227,10 @@ describe('connectPanePty', () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport() let remoteCallbacks: ConnectCallbacks | undefined - transport.attach.mockImplementation( - ({ callbacks }: { callbacks?: ConnectCallbacks }) => { - transport.getPtyId.mockReturnValue('remote:env-1@@terminal-delayed') - remoteCallbacks = callbacks - } - ) + transport.attach.mockImplementation(({ callbacks }: { callbacks?: ConnectCallbacks }) => { + transport.getPtyId.mockReturnValue('remote:env-1@@terminal-delayed') + remoteCallbacks = callbacks + }) transportFactoryQueue.push(transport) mockStoreState = { ...mockStoreState, @@ -15876,7 +16491,7 @@ describe('connectPanePty', () => { it('replays attach buffer for deferred SSH reattach and clears stale tab session metadata', async () => { const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() + const transport = createMockTransport('leaf-session') transport.connect.mockImplementation(async (opts: { sessionId?: string }) => { const id = opts.sessionId ?? 'pty-new' transport.getPtyId.mockReturnValue(id) @@ -18620,7 +19235,7 @@ describe('connectPanePty', () => { await flushAsyncTicks() // xterm is 120x40 (createPane default), PTY reports 80x24 → re-assert. - expect(transport.resize).toHaveBeenCalledWith(120, 40) + expect(transport.resize).toHaveBeenCalledWith(120, 40, { claim: true }) }) it('does not fit during visibility-resume reassertion', async () => { @@ -18645,7 +19260,7 @@ describe('connectPanePty', () => { await flushAsyncTicks() expect(fit).not.toHaveBeenCalled() - expect(transport.resize).toHaveBeenCalledWith(120, 40) + expect(transport.resize).toHaveBeenCalledWith(120, 40, { claim: true }) }) it('re-asserts after observed pane geometry changes while visible', async () => { @@ -18863,6 +19478,61 @@ describe('connectPanePty', () => { } }) + it('defers a remote-desktop viewport claim until structural replay completes', async () => { + const originalDocument = globalThis.document + ;(globalThis as { document?: Document }).document = { + visibilityState: 'visible', + hasFocus: vi.fn(() => true) + } as unknown as Document + globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { + queueMicrotask(() => callback(0)) + return 1 + }) + const { setFitOverride } = await import('@/lib/pane-manager/mobile-fit-overrides') + const { beginTerminalScrollIntentBufferRebuild, endTerminalScrollIntentBufferRebuild } = + await import('@/lib/pane-manager/terminal-scroll-intent-rebuild') + const pane = createPane(2) + const observer = installObservedPane(pane) + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const manager = createManager(2) + const deps = createDeps({ + restoredLeafId: LEAF_2, + paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } + }) + let proposedGrid = { cols: 120, rows: 40 } + pane.fitAddon = { + ...pane.fitAddon, + proposeDimensions: vi.fn(() => proposedGrid) + } as never + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks() + observer.trigger() + await flushAsyncTicks() + setFitOverride('pty-pane-2', 'remote-desktop-fit', 80, 24) + proposedGrid = { cols: 70, rows: 30 } + vi.mocked(pane.terminal.resize).mockClear() + transport.resize.mockClear() + beginTerminalScrollIntentBufferRebuild(pane.terminal) + + observer.trigger() + await flushAsyncTicks() + expect(pane.terminal.resize).not.toHaveBeenCalled() + expect(transport.resize).not.toHaveBeenCalled() + + endTerminalScrollIntentBufferRebuild(pane.terminal) + await flushAsyncTicks() + expect(pane.terminal.resize).toHaveBeenCalledWith(70, 30) + expect(transport.resize).toHaveBeenCalledWith(70, 30, { claim: true }) + } finally { + setFitOverride('pty-pane-2', 'desktop-fit', 0, 0) + observer.restore() + globalThis.document = originalDocument + } + }) + it('skips observed desktop reassertion while mobile owns the PTY without a fit override', async () => { const { setDriverForPty } = await import('@/lib/pane-manager/mobile-driver-state') const pane = createPane(2) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 351fc5b5e2f..a833ea24f35 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -41,7 +41,12 @@ import { type HasPty } from './terminal-dead-session-reconcile' import type { PtyConnectionDeps } from './pty-connection-types' -import { safeFit } from '@/lib/pane-manager/pane-tree-ops' +import { + cancelPendingSafeFitContinuations, + safeFit, + safeFitAndThen, + type SafeFitContinuationHandle +} from '@/lib/pane-manager/pane-tree-ops' import { requestStablePaneFit } from '@/lib/pane-manager/pane-fit-resize-observer' import { getFitOverrideForPty, bindPanePtyId } from '@/lib/pane-manager/mobile-fit-overrides' import { isPtyLocked } from '@/lib/pane-manager/mobile-driver-state' @@ -49,7 +54,12 @@ import { reconcilePtySizeAcrossFrames, type PtySizeReconcileHandle } from './pty import { shouldClaimRemoteDesktopViewport } from './remote-desktop-viewport-claim' import { getAppliedSizeReadE2eDelayMs } from './pty-applied-size-read-e2e-delay' import { createPtySizeReassertion } from './pty-size-reassertion' -import { isPaneReplaying, replayIntoTerminal, replayIntoTerminalAsync } from './replay-guard' +import { + isPaneReplaying, + replayIntoTerminal, + replayIntoTerminalAsync, + waitForTerminalReplayWritesParsed +} from './replay-guard' import { isTerminalWritePipelineCertifiedDead, registerUndeliverableWriteHandler @@ -119,10 +129,16 @@ import { } from '@/lib/pane-manager/windows-pty-compatibility' import { recordTerminalOutput } from '@/lib/pane-manager/pane-scroll' import { ensureArabicShapingJoinerForText } from '@/lib/pane-manager/terminal-arabic-shaping-joiner' +import { clearTerminalScrollbackAndFollowOutput } from '@/lib/pane-manager/terminal-scrollback-clear' import { - captureTerminalWriteScrollIntent, - enforceTerminalWriteScrollIntent + getTerminalScrollIntentKind, + markTerminalFollowOutput } from '@/lib/pane-manager/terminal-scroll-intent' +import { + cancelTerminalScrollIntentBufferRebuildCompletions, + deferTerminalGeometryMutationDuringRebuild +} from '@/lib/pane-manager/terminal-scroll-intent-rebuild' +import { createTerminalStructuralReplayCoordinator } from '@/lib/pane-manager/terminal-structural-replay-coordinator' import { createBrowserUuid } from '@/lib/browser-uuid' import { makePaneKey, parseLegacyNumericPaneKey } from '../../../../shared/stable-pane-id' import { @@ -963,6 +979,7 @@ export function connectPanePty( const terminalRecoveryInstance = registerTerminalPaneRecoveryInstance(deps.tabId) exposeE2eTerminalPtyOutputDebug() let disposed = false + const structuralReplayCoordinator = createTerminalStructuralReplayCoordinator(pane.terminal) let connectFrame: number | null = null let connectFallbackTimer: ReturnType | null = null let startupGridSettleHandle: TerminalStartupGridSettleHandle | null = null @@ -970,6 +987,10 @@ export function connectPanePty( let connectStarted = false let unregisterBacklogRecovery: (() => void) | null = null let unregisterDocumentVisibilityRecovery: (() => void) | null = null + let cancelHiddenOutputSnapshotScrollRestore = (): void => {} + let pendingHiddenSnapshotFit: SafeFitContinuationHandle | null = null + let pendingReattachFit: SafeFitContinuationHandle | null = null + let cancelFreshSpawnFollowReset = (): void => {} let cleanupHiddenOutputRestoreDeferredRetry = (): void => {} let cleanupHiddenOutputRestoreForegroundDeadline = (): void => {} let cleanupHiddenOutputRestoreFloodRepaint = (): void => {} @@ -3166,8 +3187,13 @@ export function connectPanePty( const hadExistingPaneTransportAtConnect = deps.paneTransportsRef.current.size > 0 let lastTerminalInputAt = Number.NEGATIVE_INFINITY let hasReceivedPtyOutput = false - let deferredReattachLiveData: { data: string; meta?: PtyDataMeta }[] | null = null + let deferredReattachLiveData: + | { data: string; ptyId: string | null; streamGeneration: number; meta?: PtyDataMeta }[] + | null = null let deferredReattachLiveDataChars = 0 + let reattachLiveDataDeferralDepth = 0 + let deferredReattachLiveDataOwners = new Map() + let transportStreamGeneration = 0 const MAX_DEFERRED_REATTACH_LIVE_CHARS = 512 * 1024 const MAX_DEFERRED_REATTACH_LIVE_CHUNKS = 1_024 const markTerminalInputSent = (): void => { @@ -3633,7 +3659,7 @@ export function connectPanePty( getPtyId: () => transport.getPtyId(), isRemotePtyId: isRemoteRuntimePtyId, shouldSuppressDesktopResize: () => shouldSuppressDesktopPtyResize(), - fit: () => safeFit(pane), + fitAndRun: (continuation) => safeFitAndThen(pane, 'pty-size-reassertion', continuation), getTerminalDimensions: () => ({ cols: pane.terminal.cols, rows: pane.terminal.rows }), getAppliedSize: async (ptyId) => { // Why: e2e seam — delays the read past the reveal fit to reproduce the @@ -3716,6 +3742,18 @@ export function connectPanePty( let pendingPaneGeometryChanged = false const handleObservedPaneGeometry = (): void => { pendingGeometryReportRaf = null + if (disposed) { + return + } + if ( + deferTerminalGeometryMutationDuringRebuild( + pane.terminal, + 'observed-pane-geometry', + handleObservedPaneGeometry + ) + ) { + return + } const paneGeometryChanged = pendingPaneGeometryChanged pendingPaneGeometryChanged = false const currentPtyId = transport.getPtyId() @@ -3846,7 +3884,9 @@ export function connectPanePty( // the grid stabilizes. While hidden it keeps watching for a late settle. isAuthoritative: () => isRendererPtyResizeAuthoritative(), measure: () => { - safeFit(pane) + if (!safeFit(pane)) { + return null + } const cols = pane.terminal.cols const rows = pane.terminal.rows return cols > 0 && rows > 0 ? { cols, rows } : null @@ -3883,7 +3923,9 @@ export function connectPanePty( } } const measureStartupGrid = (): { cols: number; rows: number } | null => { - safeFit(pane) + if (!safeFit(pane)) { + return null + } const cols = pane.terminal.cols const rows = pane.terminal.rows return cols > 0 && rows > 0 ? { cols, rows } : null @@ -4036,7 +4078,7 @@ export function connectPanePty( () => { clearHiddenOutputRestoreState() discardTerminalOutput(pane.terminal) - pane.terminal.clear() + clearTerminalScrollbackAndFollowOutput(pane.terminal) } ) const unregisterTitleSource = registerPtyTitleSource(ptyId, (handler) => @@ -4464,12 +4506,60 @@ export function connectPanePty( }, 50) } + let freshSpawnFollowResetDisposables: IDisposable[] = [] + cancelFreshSpawnFollowReset = (): void => { + for (const disposable of freshSpawnFollowResetDisposables) { + disposable.dispose() + } + freshSpawnFollowResetDisposables = [] + } + const resetFreshSpawnFollowOutput = (): void => { + cancelFreshSpawnFollowReset() + markTerminalFollowOutput(pane.terminal) + let nativeFollowResetComplete = false + const tryResetNativeFollow = (): void => { + if ( + disposed || + getTerminalScrollIntentKind(pane.terminal) !== 'followOutput' || + deferTerminalGeometryMutationDuringRebuild( + pane.terminal, + 'fresh-spawn-follow-reset', + tryResetNativeFollow + ) + ) { + return + } + try { + pane.terminal.scrollToBottom() + nativeFollowResetComplete = true + cancelFreshSpawnFollowReset() + } catch (err) { + if (!(err instanceof TypeError && /dimensions/.test(err.message))) { + cancelFreshSpawnFollowReset() + throw err + } + } + } + tryResetNativeFollow() + if (!nativeFollowResetComplete) { + // Why: xterm's browser viewport can reject scrolling while its renderer + // is detached; the first render/resize is the earliest safe native retry. + freshSpawnFollowResetDisposables = [ + pane.terminal.onRender(tryResetNativeFollow), + pane.terminal.onResize(tryResetNativeFollow) + ] + } + } + const startFreshSpawn = ( startupOverride?: PendingStartupCommand | null, options: FreshSpawnOptions = {} ): Promise => { clearPaneMode2031State() clearHiddenOutputRestoreState() + // Why: a canceled old replay clear can preserve xterm's native + // isUserScrolling flag. A replacement shell must start in follow mode. + resetFreshSpawnFollowOutput() // Why: a fresh spawn is a new process with kitty keyboard flags at // zero. The exit-handler reset alone is not enough: a late exit from a // replaced PTY takes the stale-transport early return and skips it, so @@ -4497,6 +4587,7 @@ export function connectPanePty( : window.api.pty.declarePendingPaneSerializer(cacheKey).catch(() => null) transportConnectInFlightSince = Date.now() + const outputCallbacks = captureTransportOutputCallbacks(reportError) const spawnedRaw = transport.connect({ url: '', cols, @@ -4509,12 +4600,7 @@ export function connectPanePty( ...(coldRestoreOverride ? { launchToken: coldRestoreOverride.launchToken } : {}), ...(coldRestoreOverride ? { launchAgent: coldRestoreOverride.agent } : {}), ...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}), - callbacks: { - onConnect: reportRemoteRendererSerializerReady, - onData: dataCallback, - onReplayData: replayDataCallback, - onError: reportError - } + callbacks: outputCallbacks.callbacks }) void Promise.resolve(spawnedRaw) @@ -4524,6 +4610,13 @@ export function connectPanePty( }) const trackedPromise: Promise = Promise.resolve(spawnedRaw) .then(async (spawnedPtyId) => { + if (outputCallbacks.generation !== transportStreamGeneration) { + const gen = await preSignalPromise + if (typeof gen === 'number') { + void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) + } + return null + } const resolvedPtyId = spawnedPtyId && typeof spawnedPtyId === 'object' && 'id' in spawnedPtyId ? spawnedPtyId.id @@ -4578,10 +4671,7 @@ export function connectPanePty( reconcilePtySizeAfterSpawn(resolvedPtyId, cols, rows) } const gen = await preSignalPromise - if ( - resolvedPtyId && - (typeof gen === 'number' || isRemoteRuntimePtyId(resolvedPtyId)) - ) { + if (resolvedPtyId && (typeof gen === 'number' || isRemoteRuntimePtyId(resolvedPtyId))) { if (!isRemoteRuntimePtyId(resolvedPtyId) || !hasPtySerializer(resolvedPtyId)) { registerPaneSerializerFor(resolvedPtyId) } @@ -4790,10 +4880,18 @@ export function connectPanePty( writeFreshShellViewportBlanking() } - const sendFocusedReattachFocusInAfterReplay = (): void => { + const sendFocusedReattachFocusInAfterReplay = ( + expectedPtyId: string | null = transport.getPtyId(), + expectedStreamGeneration = transportStreamGeneration + ): void => { const scheduledGeneration = reattachReplayPayloadSignalGeneration void waitForTerminalOutputParsed(pane.terminal).then(() => { - if (disposed) { + const currentPtyId = transport.getPtyId() + if ( + disposed || + expectedStreamGeneration !== transportStreamGeneration || + currentPtyId !== expectedPtyId + ) { return } // Why: a newer replay frame owns the judgment; its own post-parse @@ -4835,20 +4933,53 @@ export function connectPanePty( type PendingReplayData = { data: string clearBeforeReplay: boolean + ptyId: string | null + generation: number + streamGeneration: number pendingEscapeTailAnsi?: string } let pendingReplayData: PendingReplayData | null = null + let replayPayloadGeneration = 0 let replayDrainQueued = false - const drainReplayDataQueue = async (): Promise => { + const drainReplayDataQueue = async ( + expectedPtyId: string | null, + expectedStreamGeneration: number + ): Promise => { + let appliedCurrentPayload = false while (pendingReplayData !== null) { - const { data, clearBeforeReplay, pendingEscapeTailAnsi } = pendingReplayData + if ( + pendingReplayData.ptyId !== expectedPtyId || + pendingReplayData.streamGeneration !== expectedStreamGeneration + ) { + return false + } + if ( + transport.getPtyId() !== expectedPtyId || + transportStreamGeneration !== expectedStreamGeneration + ) { + pendingReplayData = null + return false + } + const payload = pendingReplayData + const { data, clearBeforeReplay, pendingEscapeTailAnsi } = payload pendingReplayData = null + const isCurrentPayload = (): boolean => + !disposed && + payload.generation === replayPayloadGeneration && + payload.streamGeneration === transportStreamGeneration && + transport.getPtyId() === payload.ptyId + if (!isCurrentPayload()) { + continue + } // Relay replay buffers may overlap with content already rendered in // xterm. Local eager replay decides this earlier so metadata-only frames // can keep restored scrollback while still using the replay guard. if (clearBeforeReplay) { await writeReplayDataAsync('\x1b[2J\x1b[3J\x1b[H') + if (!isCurrentPayload()) { + continue + } } if (clearBeforeReplay || data.length > 0) { // Why: an empty clearing frame is still an authoritative repaint and @@ -4861,9 +4992,15 @@ export function connectPanePty( // apply as sets to keep the mirrored stack from accumulating frames. kittyKeyboardModes.scanReplay(data) await writeReplayDataAsync(data) + if (!isCurrentPayload()) { + continue + } if (clearBeforeReplay || data.length > 0) { await writeReplayDataAsync(reattachReplayResetSequence(data)) - sendFocusedReattachFocusInAfterReplay() + if (!isCurrentPayload()) { + continue + } + sendFocusedReattachFocusInAfterReplay(payload.ptyId, payload.streamGeneration) } // Why: the daemon could not serialize a PTY read that ended mid-escape, // so the emulator shipped the dangling partial separately. Write it LAST @@ -4873,43 +5010,113 @@ export function connectPanePty( if (pendingEscapeTailAnsi) { await writeReplayDataAsync(pendingEscapeTailAnsi) } - if (disposed) { - pendingReplayData = null - return + if (!isCurrentPayload()) { + continue } // Why: remote-runtime snapshots can arrive after WebGL attached to an // empty buffer; rebuilding after replay parses seeds the glyph atlas // from the now-populated xterm state. manager.rebuildPaneWebgl(pane.id) + appliedCurrentPayload = true } + return appliedCurrentPayload + } + const scheduleReplayDataDrain = (): void => { + if (replayDrainQueued) { + return + } + const scheduledPtyId = pendingReplayData?.ptyId ?? null + replayDrainQueued = true + // Why: live bytes are newer than the authoritative replay frame. Hold + // them until clear + replay + reset have all parsed, or replay can erase them. + const scheduledStreamGeneration = + pendingReplayData?.streamGeneration ?? transportStreamGeneration + beginReattachLiveDataDeferral(scheduledStreamGeneration) + let replayCompleted = false + replayWriteQueue = replayWriteQueue + .catch(() => undefined) + .then(() => + structuralReplayCoordinator.run( + async () => { + replayCompleted = await drainReplayDataQueue( + scheduledPtyId, + scheduledStreamGeneration + ) + }, + { + shouldRestore: () => + !disposed && + transport.getPtyId() === scheduledPtyId && + transportStreamGeneration === scheduledStreamGeneration + } + ) + ) + .then(() => { + replayCompleted &&= !disposed && transport.getPtyId() === scheduledPtyId + }) + .finally(() => { + replayDrainQueued = false + if (pendingReplayData !== null) { + // Why: preserve the PTY identity captured when the callback fired; + // re-reading it here could retag stale bytes for a replacement PTY. + scheduleReplayDataDrain() + } + finishReattachLiveDataDeferral(replayCompleted, scheduledStreamGeneration) + }) } const replayDataCallback = ( data: string, - meta: { clearBeforeReplay?: boolean; pendingEscapeTailAnsi?: string } = {} + meta: { clearBeforeReplay?: boolean; pendingEscapeTailAnsi?: string } = {}, + streamGeneration = transportStreamGeneration ): void => { pendingReplayData = { data, clearBeforeReplay: meta.clearBeforeReplay !== false, + ptyId: transport.getPtyId(), + generation: (replayPayloadGeneration += 1), + streamGeneration, ...(meta.pendingEscapeTailAnsi ? { pendingEscapeTailAnsi: meta.pendingEscapeTailAnsi } : {}) } - if (replayDrainQueued) { - return - } - replayDrainQueued = true - replayWriteQueue = replayWriteQueue - .catch(() => undefined) - .then(drainReplayDataQueue) - .finally(() => { - replayDrainQueued = false - if (pendingReplayData !== null) { - replayDataCallback(pendingReplayData.data, { - clearBeforeReplay: pendingReplayData.clearBeforeReplay, - ...(pendingReplayData.pendingEscapeTailAnsi - ? { pendingEscapeTailAnsi: pendingReplayData.pendingEscapeTailAnsi } - : {}) - }) + scheduleReplayDataDrain() + } + + const captureTransportOutputCallbacks = (onError: (message: string) => void) => { + // Why: a new stream generation cannot inherit an old replay's pending + // destination-grid fit or keep its live-data waiter open. + pendingHiddenSnapshotFit?.cancel() + pendingHiddenSnapshotFit = null + pendingReattachFit?.cancel() + pendingReattachFit = null + const generation = (transportStreamGeneration += 1) + const isCurrent = (): boolean => !disposed && generation === transportStreamGeneration + return { + generation, + callbacks: { + onConnect: (): void => { + if (isCurrent()) { + reportRemoteRendererSerializerReady() + } + }, + onData: (data: string, meta?: PtyDataMeta): void => { + if (isCurrent()) { + dataCallback(data, meta, generation) + } + }, + onReplayData: ( + data: string, + meta?: { clearBeforeReplay?: boolean; pendingEscapeTailAnsi?: string } + ): void => { + if (isCurrent()) { + replayDataCallback(data, meta, generation) + } + }, + onError: (message: string): void => { + if (isCurrent()) { + onError(message) + } } - }) + } + } } type PendingHiddenOutputRestoreChunk = { @@ -4929,6 +5136,12 @@ export function connectPanePty( let hiddenOutputRestoreDeferredRetryTimer: ReturnType | null = null let hiddenOutputRestoreForegroundDeadlineTimer: ReturnType | null = null let hiddenOutputRestoreDeferredRetryAttempts = 0 + let hiddenOutputSnapshotScrollRestore: { + ptyId: string | null + generation: number + valid: boolean + started: boolean + } | null = null // Why: hidden recovery state belongs to one PTY stream. Reattach/restart // can reuse the pane object for a different session before visibility. let hiddenOutputRestorePtyId: string | null = null @@ -6035,6 +6248,14 @@ export function connectPanePty( : hiddenOutputRestorePendingChunks.slice() const hadPendingOverflow = hiddenOutputRestorePendingOverflow hiddenOutputRestoreGeneration += 1 + if ( + hiddenOutputSnapshotScrollRestore?.valid && + hiddenOutputSnapshotScrollRestore.ptyId === expectedPtyId + ) { + // Why: same-PTY flood abandonment stops recovery bookkeeping, but its + // already-queued replay must keep the rebuild bracket and final pin. + hiddenOutputSnapshotScrollRestore.generation = hiddenOutputRestoreGeneration + } hiddenOutputRestoreInFlight = null hiddenOutputRestoreNeeded = false hiddenOutputRestorePtyId = null @@ -6099,6 +6320,7 @@ export function connectPanePty( } function clearHiddenOutputRestoreState(): void { + cancelSnapshotScrollRestore() clearPendingLiveChunksDuringRestore() hiddenStartupRendererQueryPending = '' hiddenRendererStateDirty = false @@ -6108,6 +6330,23 @@ export function connectPanePty( hiddenOutputRestoreGeneration += 1 } + function cancelSnapshotScrollRestore(): void { + pendingHiddenSnapshotFit?.cancel() + pendingHiddenSnapshotFit = null + const scrollRestore = hiddenOutputSnapshotScrollRestore + if (!scrollRestore) { + return + } + scrollRestore.valid = false + hiddenOutputSnapshotScrollRestore = null + if (scrollRestore.started) { + cancelTerminalScrollIntentBufferRebuildCompletions(pane.terminal) + } + // Why: invalidation suppresses restoration, but queued bytes still own + // the bracket until their FIFO sentinels prove parsing has finished. + } + cancelHiddenOutputSnapshotScrollRestore = cancelSnapshotScrollRestore + function clearPaneMode2031State(): void { deps.paneMode2031Ref.current.delete(pane.id) deps.paneLastThemeModeRef.current.delete(pane.id) @@ -6174,7 +6413,7 @@ export function connectPanePty( }) } - function applyMainBufferSnapshot(snapshot: { + async function applyMainBufferSnapshot(snapshot: { data: string cols: number rows: number @@ -6182,8 +6421,19 @@ export function connectPanePty( alternateScreen?: boolean scrollbackAnsi?: string pendingEscapeTailAnsi?: string - }): void { - const scrollIntent = captureTerminalWriteScrollIntent(pane.terminal) + }): Promise { + const restorePtyId = transport.getPtyId() + const restoreGeneration = hiddenOutputRestoreGeneration + if (hiddenOutputSnapshotScrollRestore) { + cancelSnapshotScrollRestore() + } + const scrollRestore = { + ptyId: restorePtyId, + generation: restoreGeneration, + valid: true, + started: false + } + hiddenOutputSnapshotScrollRestore = scrollRestore const colsBeforeReplay = pane.terminal.cols const rowsBeforeReplay = pane.terminal.rows const hasSnapshotDimensions = @@ -6191,83 +6441,135 @@ export function connectPanePty( Number.isFinite(snapshot.rows) && snapshot.cols > 0 && snapshot.rows > 0 - discardTerminalOutput(pane.terminal) - if ( - hasSnapshotDimensions && - (pane.terminal.cols !== snapshot.cols || pane.terminal.rows !== snapshot.rows) - ) { - // Why: serialized terminal snapshots encode layout at their source - // dimensions. Replay at those dimensions first, then fit back below. - // This xterm-only resize must not SIGWINCH the live TUI. - suppressSnapshotReplayPtyResize = true - try { - pane.terminal.resize(snapshot.cols, snapshot.rows) - } finally { - suppressSnapshotReplayPtyResize = false - } - } - if (!snapshot.alternateScreen) { - // Why: this clear (incl. \x1b[3J) wipes xterm's scrollback. Alt-screen - // TUIs (Claude Code, vim) keep their scroll history in xterm, so - // clearing on restore loses scroll-up after a hidden->visible return. - // Mirrors the attach-time guard in pty-transport.ts. - writeReplayData('\x1b[2J\x1b[3J\x1b[H') - } else if (snapshot.scrollbackAnsi !== undefined) { - // Why: SerializeAddon captures normal and alternate buffers together. - // Rebuild normal while it is active, then return to a clean alt frame. - writeReplayData('\x1b[?1049l\x1b[2J\x1b[3J\x1b[H') - writeReplayData(snapshot.scrollbackAnsi) - writeReplayData('\x1b[0m\x1b[?1049h\x1b[2J\x1b[H') - } else { - // Why: the snapshot's own ?1049h is a no-op when the pane is already on - // the alternate screen, and the serialized frame skips blank cells — so - // without clearing the alt screen the pre-hide frame bleeds through - // every cell the final frame leaves blank. \x1b[2J on the alt buffer - // does not touch the normal buffer's scrollback the TUI returns to. - writeReplayData('\x1b[0m\x1b[?1049h\x1b[2J\x1b[H') - } - writeReplayData(snapshot.data) - // Why: status/title-corroborated live agents own ?25l/?1004h (a forced - // ?1004l here would silence focus events until the agent restarts, since - // agents only enable focus reporting at startup). - writeReplayData( - hasLiveAgentReattachStatusOrTitleSignal() - ? POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET - : POST_REPLAY_LIVE_SNAPSHOT_RESET - ) - if (snapshot.pendingEscapeTailAnsi) { - // Why last: the snapshot was taken with main's emulator mid-escape; - // re-arming the dangling sequence must be the FINAL replay write (any - // later ESC — including the reset above — aborts it) so the racing - // live tail's continuation completes it exactly as live, instead of - // rendering literally (Bug E fix / #7329). - writeReplayData(snapshot.pendingEscapeTailAnsi) - } - hiddenRendererStateDirty = false - recordRendererOrderedSeq(snapshot) - resetHiddenRendererRiskState() - recordTerminalOutput(pane.terminal) - const currentPtyId = transport.getPtyId() - if (currentPtyId && !getFitOverrideForPty(currentPtyId)) { - safeFit(pane) - const replayChangedDimensions = hasSnapshotDimensions - ? pane.terminal.cols !== snapshot.cols || pane.terminal.rows !== snapshot.rows - : pane.terminal.cols !== colsBeforeReplay || pane.terminal.rows !== rowsBeforeReplay - if (replayChangedDimensions && isRendererPtyResizeAuthoritative()) { - transport.resize(pane.terminal.cols, pane.terminal.rows) - if (!isRemoteRuntimePtyId(currentPtyId)) { - // Why: redundant SIGWINCH can make alternate-screen TUIs rebuild - // their internal scroll viewport to the top on tab return. - window.api.pty.signal(currentPtyId, 'SIGWINCH') + try { + await structuralReplayCoordinator.run( + async () => { + if ( + !scrollRestore.valid || + disposed || + transport.getPtyId() !== scrollRestore.ptyId || + hiddenOutputRestoreGeneration !== scrollRestore.generation + ) { + return + } + scrollRestore.started = true + discardTerminalOutput(pane.terminal) + if ( + hasSnapshotDimensions && + (pane.terminal.cols !== snapshot.cols || pane.terminal.rows !== snapshot.rows) + ) { + // Why: xterm parses writes later. Keep snapshot dimensions until + // the FIFO sentinel completes so serialized wraps stay exact. + suppressSnapshotReplayPtyResize = true + try { + pane.terminal.resize(snapshot.cols, snapshot.rows) + } finally { + suppressSnapshotReplayPtyResize = false + } + } + if (!snapshot.alternateScreen) { + // Why: this clear (incl. \x1b[3J) wipes xterm's scrollback. Alt-screen + // TUIs (Claude Code, vim) keep their scroll history in xterm, so + // clearing on restore loses scroll-up after a hidden->visible return. + // Mirrors the attach-time guard in pty-transport.ts. + writeReplayData('\x1b[2J\x1b[3J\x1b[H') + } else if (snapshot.scrollbackAnsi !== undefined) { + // Why: SerializeAddon captures normal and alternate buffers together. + // Rebuild normal while it is active, then return to a clean alt frame. + writeReplayData('\x1b[?1049l\x1b[2J\x1b[3J\x1b[H') + writeReplayData(snapshot.scrollbackAnsi) + writeReplayData('\x1b[0m\x1b[?1049h\x1b[2J\x1b[H') + } else { + // Why: the snapshot's own ?1049h is a no-op when the pane is already on + // the alternate screen, and the serialized frame skips blank cells — so + // without clearing the alt screen the pre-hide frame bleeds through + // every cell the final frame leaves blank. \x1b[2J on the alt buffer + // does not touch the normal buffer's scrollback the TUI returns to. + writeReplayData('\x1b[0m\x1b[?1049h\x1b[2J\x1b[H') + } + writeReplayData(snapshot.data) + // Why: status/title-corroborated live agents own ?25l/?1004h (a forced + // ?1004l here would silence focus events until the agent restarts, since + // agents only enable focus reporting at startup). + writeReplayData( + hasLiveAgentReattachStatusOrTitleSignal() + ? POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET + : POST_REPLAY_LIVE_SNAPSHOT_RESET + ) + if (snapshot.pendingEscapeTailAnsi) { + // Why last: the snapshot was taken with main's emulator mid-escape; + // re-arming the dangling sequence must be the FINAL replay write (any + // later ESC — including the reset above — aborts it) so the racing + // live tail's continuation completes it exactly as live, instead of + // rendering literally (Bug E fix / #7329). + writeReplayData(snapshot.pendingEscapeTailAnsi) + } + hiddenRendererStateDirty = false + recordRendererOrderedSeq(snapshot) + resetHiddenRendererRiskState() + recordTerminalOutput(pane.terminal) + await waitForTerminalReplayWritesParsed(pane.terminal) + }, + { + shouldRestore: () => + scrollRestore.valid && + !disposed && + transport.getPtyId() === scrollRestore.ptyId && + hiddenOutputRestoreGeneration === scrollRestore.generation, + afterRestore: async () => { + const isCurrentRestore = (): boolean => + scrollRestore.valid && + !disposed && + transport.getPtyId() === scrollRestore.ptyId && + hiddenOutputRestoreGeneration === scrollRestore.generation + if (!isCurrentRestore()) { + return + } + const currentPtyId = transport.getPtyId() + if (!currentPtyId || getFitOverrideForPty(currentPtyId)) { + return + } + const fit = safeFitAndThen( + pane, + 'hidden-snapshot-pty-resize', + () => { + if (!isCurrentRestore() || transport.getPtyId() !== currentPtyId) { + return + } + const replayChangedDimensions = hasSnapshotDimensions + ? pane.terminal.cols !== snapshot.cols || pane.terminal.rows !== snapshot.rows + : pane.terminal.cols !== colsBeforeReplay || + pane.terminal.rows !== rowsBeforeReplay + if (replayChangedDimensions && isRendererPtyResizeAuthoritative()) { + transport.resize(pane.terminal.cols, pane.terminal.rows) + if (!isRemoteRuntimePtyId(currentPtyId)) { + // Why: redundant SIGWINCH can make alternate-screen TUIs rebuild + // their internal scroll viewport to the top on tab return. + window.api.pty.signal(currentPtyId, 'SIGWINCH') + } + } + }, + { shouldContinue: isCurrentRestore } + ) + pendingHiddenSnapshotFit = fit + try { + await fit.completion + } finally { + if (pendingHiddenSnapshotFit === fit) { + pendingHiddenSnapshotFit = null + } + } + if (isCurrentRestore()) { + scheduleReattachIdleAgentCursorReset() + } + } } + ) + } finally { + if (hiddenOutputSnapshotScrollRestore === scrollRestore) { + hiddenOutputSnapshotScrollRestore = null } - scheduleReattachIdleAgentCursorReset() } - // Why: snapshot replay clears and rebuilds xterm state; re-apply the - // user's scroll intent once so hidden catch-up cannot repin the viewport. - // Restore by bottom offset — the rebuilt buffer renumbers every row, so - // the pre-replay absolute viewport line points at arbitrary content. - enforceTerminalWriteScrollIntent(pane.terminal, scrollIntent, { restoreBy: 'bottomOffset' }) } function requestHiddenOutputRestoreIfNeeded(opts?: { bypassScheduler?: boolean }): boolean { @@ -6404,7 +6706,15 @@ export function connectPanePty( } hiddenOutputRestoreDeferredRetryAttempts = 0 restoreIterations += 1 - applyMainBufferSnapshot(snapshot) + await applyMainBufferSnapshot(snapshot) + if ( + disposed || + hiddenOutputRestoreGeneration !== restoreGeneration || + hiddenOutputRestorePtyId !== currentPtyId || + transport.getPtyId() !== currentPtyId + ) { + return + } // Why: everything at or before snapshot.seq is now painted; chunks // still draining from main's ACK backlog below that point are // duplicates the dataCallback reconciliation must suppress. @@ -6509,14 +6819,41 @@ export function connectPanePty( } } - const dataCallback = (data: string, meta?: PtyDataMeta): void => { + const dataCallback = ( + data: string, + meta?: PtyDataMeta, + streamGeneration = transportStreamGeneration + ): void => { + if (streamGeneration !== transportStreamGeneration) { + return + } if (deferredReattachLiveData !== null) { - deferredReattachLiveData.push({ data, ...(meta ? { meta } : {}) }) - deferredReattachLiveDataChars += data.length - let dropped = false + // Why: a replacement stream must not inherit either bytes or a gap + // marker from the replay owner it superseded. + deferredReattachLiveData = deferredReattachLiveData.filter( + (chunk) => chunk.streamGeneration === streamGeneration + ) + deferredReattachLiveDataChars = deferredReattachLiveData.reduce( + (total, chunk) => total + chunk.data.length, + 0 + ) + const oversized = data.length > MAX_DEFERRED_REATTACH_LIVE_CHARS + const deferredData = oversized ? data.slice(-MAX_DEFERRED_REATTACH_LIVE_CHARS) : data + deferredReattachLiveData.push({ + data: deferredData, + ptyId: transport.getPtyId(), + streamGeneration, + ...(meta ? { meta } : {}) + }) + deferredReattachLiveDataChars += deferredData.length + // Why: retaining one arbitrarily large IPC frame would bypass this + // queue's memory bound. Mark it as a stream gap so snapshot recovery + // replaces it instead of feeding a partial ANSI frame to xterm. + let dropped = oversized while ( - deferredReattachLiveData.length > MAX_DEFERRED_REATTACH_LIVE_CHUNKS || - deferredReattachLiveDataChars > MAX_DEFERRED_REATTACH_LIVE_CHARS + deferredReattachLiveData.length > 1 && + (deferredReattachLiveData.length > MAX_DEFERRED_REATTACH_LIVE_CHUNKS || + deferredReattachLiveDataChars > MAX_DEFERRED_REATTACH_LIVE_CHARS) ) { const removed = deferredReattachLiveData.shift() deferredReattachLiveDataChars -= removed?.data.length ?? 0 @@ -6719,34 +7056,72 @@ export function connectPanePty( } }) - const beginReattachLiveDataDeferral = (): void => { - deferredReattachLiveData = [] - deferredReattachLiveDataChars = 0 + const beginReattachLiveDataDeferral = (ownerGeneration = transportStreamGeneration): void => { + reattachLiveDataDeferralDepth += 1 + if (reattachLiveDataDeferralDepth === 1) { + deferredReattachLiveData = [] + deferredReattachLiveDataChars = 0 + deferredReattachLiveDataOwners = new Map() + } + if (!deferredReattachLiveDataOwners.has(ownerGeneration)) { + deferredReattachLiveDataOwners.set(ownerGeneration, { failed: false }) + } } - const finishReattachLiveDataDeferral = (deliver: boolean): void => { + const finishReattachLiveDataDeferral = ( + deliver: boolean, + acceptedGeneration = transportStreamGeneration + ): void => { + if (reattachLiveDataDeferralDepth <= 0) { + return + } + if (!deliver) { + const owner = deferredReattachLiveDataOwners.get(acceptedGeneration) + if (owner) { + owner.failed = true + } + } + reattachLiveDataDeferralDepth -= 1 + if (reattachLiveDataDeferralDepth > 0) { + return + } const chunks = deferredReattachLiveData deferredReattachLiveData = null deferredReattachLiveDataChars = 0 - if (!deliver || disposed || !chunks) { + const currentPtyId = transport.getPtyId() + const currentGeneration = transportStreamGeneration + const currentOwner = deferredReattachLiveDataOwners.get(currentGeneration) + deferredReattachLiveDataOwners = new Map() + if (disposed || !chunks) { return } // Why: createOrAttach snapshots precede bytes emitted before its IPC // reply. Paint the authoritative replay first, then admit those live // chunks so the replay clear cannot erase newer output. for (const chunk of chunks) { - dataCallback(chunk.data, chunk.meta) + if ( + chunk.ptyId !== currentPtyId || + chunk.streamGeneration !== currentGeneration || + currentOwner?.failed === true + ) { + continue + } + dataCallback(chunk.data, chunk.meta, chunk.streamGeneration) } } - const handleReattachResult = ( + const handleReattachResult = async ( result: PtyConnectResult | string | void, staleSessionId?: string | null, - coldRestoreStartup?: ColdRestoreAgentResumeStartup | null - ): boolean => { + coldRestoreStartup?: ColdRestoreAgentResumeStartup | null, + attemptGeneration = transportStreamGeneration + ): Promise => { if (disposed) { return false } + if (attemptGeneration !== transportStreamGeneration) { + return false + } const connectResult = result && typeof result === 'object' && 'id' in result ? (result as PtyConnectResult) : null @@ -6807,6 +7182,15 @@ export function connectPanePty( }) return false } + const isCurrentReattachPayload = (): boolean => { + const currentPtyId = transport.getPtyId() + return ( + !disposed && attemptGeneration === transportStreamGeneration && currentPtyId === ptyId + ) + } + if (!isCurrentReattachPayload()) { + return false + } setPanePtyFitBinding(ptyId) reportPanePtyVisibility(ptyId, deps.isVisibleRef.current) registerSideEffectFactConsumerForPty(ptyId) @@ -6832,125 +7216,179 @@ export function connectPanePty( // newer than disk-recorded scrollback. If we ever return all three, // the daemon and relay are by definition tracking the same session // and only the freshest source belongs on screen. - if (connectResult?.snapshot) { - rememberReattachPayloadAgentSignal(connectResult.snapshot, { fullScreenReplay: true }) - // Why: the daemon serializes its grid with soft-wrapped lines as - // continuous text. Replaying that at a different column count rewraps - // rows one cell early/late (bug #7279). Replay at the snapshot's own - // dimensions first; safeFit below fits the pane back and resizes the - // remote PTY. Suppress the xterm->PTY forward so this layout-only - // resize does not SIGWINCH the live remote TUI. Mirrors - // applyMainBufferSnapshot. - const snapshotCols = connectResult.snapshotCols - const snapshotRows = connectResult.snapshotRows - const hasSnapshotDimensions = - typeof snapshotCols === 'number' && - typeof snapshotRows === 'number' && - Number.isFinite(snapshotCols) && - Number.isFinite(snapshotRows) && - snapshotCols > 0 && - snapshotRows > 0 - if ( - hasSnapshotDimensions && - (pane.terminal.cols !== snapshotCols || pane.terminal.rows !== snapshotRows) - ) { - suppressSnapshotReplayPtyResize = true + const hasStructuralReplay = Boolean( + connectResult?.snapshot || connectResult?.replay || connectResult?.coldRestore + ) + let reattachPayloadApplied = !hasStructuralReplay + const applyReattachPayload = async (): Promise => { + if (!isCurrentReattachPayload()) { + return + } + if (connectResult?.snapshot) { + rememberReattachPayloadAgentSignal(connectResult.snapshot, { fullScreenReplay: true }) + // Why: the daemon serializes its grid with soft-wrapped lines as + // continuous text. Replaying that at a different column count rewraps + // rows one cell early/late (bug #7279). Replay at the snapshot's own + // dimensions first; safeFit below fits the pane back and resizes the + // remote PTY. Suppress the xterm->PTY forward so this layout-only + // resize does not SIGWINCH the live remote TUI. Mirrors + // applyMainBufferSnapshot. + const snapshotCols = connectResult.snapshotCols + const snapshotRows = connectResult.snapshotRows + const hasSnapshotDimensions = + typeof snapshotCols === 'number' && + typeof snapshotRows === 'number' && + Number.isFinite(snapshotCols) && + Number.isFinite(snapshotRows) && + snapshotCols > 0 && + snapshotRows > 0 + if ( + hasSnapshotDimensions && + (pane.terminal.cols !== snapshotCols || pane.terminal.rows !== snapshotRows) + ) { + suppressSnapshotReplayPtyResize = true + try { + pane.terminal.resize(snapshotCols, snapshotRows) + } finally { + suppressSnapshotReplayPtyResize = false + } + } + writeReplayData('\x1b[2J\x1b[3J\x1b[H') + // Why: the daemon snapshot's rehydrate preamble carries the live + // session's kitty keyboard flags; re-arm the mirror from it so Option + // chords keep their kitty encoding after a window reload. + kittyKeyboardModes.scanReplay(connectResult.snapshot) + writeReplayData(connectResult.snapshot) + // Snapshot reattach keeps a live session, so avoid the broader mode + // reset. We only drop renderer-owned state that should not leak from + // replay bytes into the restored renderer terminal. + writeReplayData(reattachReplayResetSequence(connectResult.snapshot)) + if (connectResult.pendingEscapeTailAnsi) { + // Why last: re-arm the daemon's dangling mid-escape sequence AFTER the + // reset (whose ESC would abort it) so the racing live continuation + // completes it instead of rendering literally (#7329). + writeReplayData(connectResult.pendingEscapeTailAnsi) + } + sendFocusedReattachFocusInAfterReplay(ptyId, attemptGeneration) + if (connectResult.coldRestore) { + // Snapshot superseded the cold-restore payload — ack it so the + // daemon does not redeliver it on the next reattach. + if (!isRemoteRuntimePtyId(ptyId)) { + window.api.pty.ackColdRestore(ptyId) + } + } + } else if (connectResult?.replay) { + rememberReattachPayloadAgentSignal(connectResult.replay, { fullScreenReplay: true }) + // Relay replay holds the last 100 KB of raw output. The xterm may + // already hold pre-disconnect content; clear first to avoid + // duplication. The reattach reset clears renderer-owned state without + // tearing down the still-running TUI's live modes. + writeReplayData('\x1b[2J\x1b[3J\x1b[H') + // Why: raw relay replay contains the application's own kitty pushes + // when they fall inside the retained window; re-arm the mirror with + // replay (set) semantics so redelivery cannot grow the stack. + kittyKeyboardModes.scanReplay(connectResult.replay) + writeReplayData(connectResult.replay) + writeReplayData(reattachReplayResetSequence(connectResult.replay)) + sendFocusedReattachFocusInAfterReplay(ptyId, attemptGeneration) + if (connectResult.coldRestore) { + if (!isRemoteRuntimePtyId(ptyId)) { + window.api.pty.ackColdRestore(ptyId) + } + } + } else if (connectResult?.coldRestore) { + // replayIntoTerminal: the recorded scrollback is raw PTY output that + // may contain query sequences the previous agent CLI emitted; + // writing them through xterm.write would trigger auto-replies that + // land in the new shell's stdin. See replay-guard.ts. + writeReplayData(connectResult.coldRestore.scrollback) + const preparedStartup = coldRestoreStartup ?? buildColdRestoreAgentResumeStartup() + const didPrepareResume = applyColdRestoreAgentResumeStartup(preparedStartup) + if (didPrepareResume) { + if (preparedStartup?.hasSleepingRecord) { + showSessionRestoredBanner() + } + clearSleepingRecordAfterColdRestoreSpawn(preparedStartup) + } + // Cold-restore means the daemon lost the session and spawned a + // fresh shell — no TUI is consuming the mode-setting bytes that a + // crashed TUI (e.g. Claude's \e[?1004h) left in the scrollback, so + // reset them to match the fresh shell's expectations. + writeReplayData(POST_REPLAY_MODE_RESET) + // Why: the dead run's scrollback was never scanned, and any kitty + // flags it pushed died with it — the fresh shell starts at zero. + kittyKeyboardModes.reset() + consumeRestoredViewportBlankingMarker() + writeFreshShellViewportBlanking() + if (!isRemoteRuntimePtyId(ptyId)) { + window.api.pty.ackColdRestore(ptyId) + } + if (didPrepareResume && !coldRestoreStartup) { + schedulePendingStartupCommandDelivery() + } + } + if (hasStructuralReplay) { + await waitForTerminalReplayWritesParsed(pane.terminal) + if (!isCurrentReattachPayload()) { + return + } + reattachPayloadApplied = true + } + } + + const fitAfterReattachRestore = async (): Promise => { + if (!isCurrentReattachPayload()) { + return + } + const reattachPtyId = transport.getPtyId() + if (!reattachPtyId) { + return + } + if (!getFitOverrideForPty(reattachPtyId)) { + const fit = safeFitAndThen( + pane, + 'reattach-pty-resize', + () => { + if (!isCurrentReattachPayload() || transport.getPtyId() !== reattachPtyId) { + return + } + const reattachCols = pane.terminal.cols + const reattachRows = pane.terminal.rows + if (reattachCols > 0 && reattachRows > 0) { + transport.resize(reattachCols, reattachRows) + } + // Why: POSIX only delivers SIGWINCH when terminal dimensions actually + // change. Sending it explicitly guarantees restored TUIs repaint at + // the correct cursor position after snapshot replay. + if (!isRemoteRuntimePtyId(reattachPtyId)) { + window.api.pty.signal(reattachPtyId, 'SIGWINCH') + } + }, + { shouldContinue: isCurrentReattachPayload } + ) + pendingReattachFit = fit try { - pane.terminal.resize(snapshotCols, snapshotRows) + await fit.completion } finally { - suppressSnapshotReplayPtyResize = false + if (pendingReattachFit === fit) { + pendingReattachFit = null + } } - } - writeReplayData('\x1b[2J\x1b[3J\x1b[H') - // Why: the daemon snapshot's rehydrate preamble carries the live - // session's kitty keyboard flags; re-arm the mirror from it so Option - // chords keep their kitty encoding after a window reload. - kittyKeyboardModes.scanReplay(connectResult.snapshot) - writeReplayData(connectResult.snapshot) - // Snapshot reattach keeps a live session, so avoid the broader mode - // reset. We only drop renderer-owned state that should not leak from - // replay bytes into the restored renderer terminal. - writeReplayData(reattachReplayResetSequence(connectResult.snapshot)) - if (connectResult.pendingEscapeTailAnsi) { - // Why last: re-arm the daemon's dangling mid-escape sequence AFTER the - // reset (whose ESC would abort it) so the racing live continuation - // completes it instead of rendering literally (#7329). - writeReplayData(connectResult.pendingEscapeTailAnsi) - } - sendFocusedReattachFocusInAfterReplay() - if (connectResult.coldRestore) { - // Snapshot superseded the cold-restore payload — ack it so the - // daemon does not redeliver it on the next reattach. - if (!isRemoteRuntimePtyId(ptyId)) { - window.api.pty.ackColdRestore(ptyId) - } - } - } else if (connectResult?.replay) { - rememberReattachPayloadAgentSignal(connectResult.replay, { fullScreenReplay: true }) - // Relay replay holds the last 100 KB of raw output. The xterm may - // already hold pre-disconnect content; clear first to avoid - // duplication. The reattach reset clears renderer-owned state without - // tearing down the still-running TUI's live modes. - writeReplayData('\x1b[2J\x1b[3J\x1b[H') - // Why: raw relay replay contains the application's own kitty pushes - // when they fall inside the retained window; re-arm the mirror with - // replay (set) semantics so redelivery cannot grow the stack. - kittyKeyboardModes.scanReplay(connectResult.replay) - writeReplayData(connectResult.replay) - writeReplayData(reattachReplayResetSequence(connectResult.replay)) - sendFocusedReattachFocusInAfterReplay() - if (connectResult.coldRestore) { - if (!isRemoteRuntimePtyId(ptyId)) { - window.api.pty.ackColdRestore(ptyId) - } - } - } else if (connectResult?.coldRestore) { - // replayIntoTerminal: the recorded scrollback is raw PTY output that - // may contain query sequences the previous agent CLI emitted; - // writing them through xterm.write would trigger auto-replies that - // land in the new shell's stdin. See replay-guard.ts. - writeReplayData(connectResult.coldRestore.scrollback) - const preparedStartup = coldRestoreStartup ?? buildColdRestoreAgentResumeStartup() - const didPrepareResume = applyColdRestoreAgentResumeStartup(preparedStartup) - if (didPrepareResume) { - if (preparedStartup?.hasSleepingRecord) { - showSessionRestoredBanner() - } - clearSleepingRecordAfterColdRestoreSpawn(preparedStartup) - } - // Cold-restore means the daemon lost the session and spawned a - // fresh shell — no TUI is consuming the mode-setting bytes that a - // crashed TUI (e.g. Claude's \e[?1004h) left in the scrollback, so - // reset them to match the fresh shell's expectations. - writeReplayData(POST_REPLAY_MODE_RESET) - // Why: the dead run's scrollback was never scanned, and any kitty - // flags it pushed died with it — the fresh shell starts at zero. - kittyKeyboardModes.reset() - consumeRestoredViewportBlankingMarker() - writeFreshShellViewportBlanking() - if (!isRemoteRuntimePtyId(ptyId)) { - window.api.pty.ackColdRestore(ptyId) - } - if (didPrepareResume && !coldRestoreStartup) { - schedulePendingStartupCommandDelivery() + } else if (isCurrentReattachPayload() && !isRemoteRuntimePtyId(reattachPtyId)) { + window.api.pty.signal(reattachPtyId, 'SIGWINCH') } } - // Why: when a mobile-fit override is active, skip sending desktop dims - // to the PTY — the PTY is already at phone dimensions and must stay there. - const reattachPtyId = transport.getPtyId() - if (!reattachPtyId || !getFitOverrideForPty(reattachPtyId)) { - safeFit(pane) - const reattachCols = pane.terminal.cols - const reattachRows = pane.terminal.rows - if (reattachCols > 0 && reattachRows > 0) { - transport.resize(reattachCols, reattachRows) - } + if (hasStructuralReplay) { + await structuralReplayCoordinator.run(applyReattachPayload, { + shouldRestore: isCurrentReattachPayload, + afterRestore: fitAfterReattachRestore + }) + } else { + await applyReattachPayload() + await fitAfterReattachRestore() } - // Why: POSIX only delivers SIGWINCH when terminal dimensions actually - // change. Sending it explicitly guarantees restored TUIs repaint at - // the correct cursor position after snapshot replay. - if (!isRemoteRuntimePtyId(ptyId)) { - window.api.pty.signal(ptyId, 'SIGWINCH') + if (!isCurrentReattachPayload() || !reattachPayloadApplied) { + return false } scheduleReattachIdleAgentCursorReset() @@ -7150,7 +7588,14 @@ export function connectPanePty( const coldRestoreStartup = buildColdRestoreAgentResumeStartup() clearPaneMode2031State() clearHiddenOutputRestoreState() - beginReattachLiveDataDeferral() + const outputCallbacks = captureTransportOutputCallbacks((message) => { + if (isSshSessionExpiredError(message)) { + expiredReattachError = true + return + } + reportError(message) + }) + beginReattachLiveDataDeferral(outputCallbacks.generation) transportConnectInFlightSince = Date.now() const reattachPromise = transport.connect({ url: '', @@ -7169,18 +7614,7 @@ export function connectPanePty( : {}), ...(coldRestoreStartup?.agent ? { launchAgent: coldRestoreStartup.agent } : {}), ...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}), - callbacks: { - onConnect: reportRemoteRendererSerializerReady, - onData: dataCallback, - onReplayData: replayDataCallback, - onError: (message) => { - if (isSshSessionExpiredError(message)) { - expiredReattachError = true - return - } - reportError(message) - } - } + callbacks: outputCallbacks.callbacks }) void Promise.resolve(reattachPromise) .catch(() => null) @@ -7189,6 +7623,14 @@ export function connectPanePty( }) void Promise.resolve(reattachPromise) .then(async (result) => { + if (outputCallbacks.generation !== transportStreamGeneration) { + finishReattachLiveDataDeferral(false, outputCallbacks.generation) + const gen = await preSignalPromise + if (typeof gen === 'number') { + void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) + } + return + } console.warn( `[pty-connection] Reattach result for tab=${deps.tabId}:`, result @@ -7199,7 +7641,7 @@ export function connectPanePty( : 'undefined' ) if (!result && expiredReattachError) { - finishReattachLiveDataDeferral(false) + finishReattachLiveDataDeferral(false, outputCallbacks.generation) const gen = await preSignalPromise if (typeof gen === 'number') { void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) @@ -7214,8 +7656,13 @@ export function connectPanePty( }) return } - const accepted = handleReattachResult(result, pendingSessionId, coldRestoreStartup) - finishReattachLiveDataDeferral(accepted) + const accepted = await handleReattachResult( + result, + pendingSessionId, + coldRestoreStartup, + outputCallbacks.generation + ) + finishReattachLiveDataDeferral(accepted, outputCallbacks.generation) const gen = await preSignalPromise if (typeof gen === 'number') { if (!isRemoteRuntimePtyId(pendingSessionId)) { @@ -7234,13 +7681,13 @@ export function connectPanePty( } }) .catch(async (err) => { - finishReattachLiveDataDeferral(false) + finishReattachLiveDataDeferral(false, outputCallbacks.generation) const gen = await preSignalPromise if (typeof gen === 'number') { void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) } console.warn(`[pty-connection] Reattach FAILED for tab=${deps.tabId}:`, err) - if (disposed) { + if (disposed || outputCallbacks.generation !== transportStreamGeneration) { return } if (isSshSessionExpiredError(err)) { @@ -7357,7 +7804,14 @@ export function connectPanePty( let expiredReattachError = false const coldRestoreStartup = buildColdRestoreAgentResumeStartup() - beginReattachLiveDataDeferral() + const outputCallbacks = captureTransportOutputCallbacks((message) => { + if (isSshSessionExpiredError(message)) { + expiredReattachError = true + return + } + reportError(message) + }) + beginReattachLiveDataDeferral(outputCallbacks.generation) transportConnectInFlightSince = Date.now() const reattachPromise = transport.connect({ url: '', @@ -7374,18 +7828,7 @@ export function connectPanePty( ...(coldRestoreStartup?.launchToken ? { launchToken: coldRestoreStartup.launchToken } : {}), ...(coldRestoreStartup?.agent ? { launchAgent: coldRestoreStartup.agent } : {}), ...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}), - callbacks: { - onConnect: reportRemoteRendererSerializerReady, - onData: dataCallback, - onReplayData: replayDataCallback, - onError: (message) => { - if (isSshSessionExpiredError(message)) { - expiredReattachError = true - return - } - reportError(message) - } - } + callbacks: outputCallbacks.callbacks }) void Promise.resolve(reattachPromise) @@ -7395,8 +7838,16 @@ export function connectPanePty( }) void Promise.resolve(reattachPromise) .then(async (result) => { + if (outputCallbacks.generation !== transportStreamGeneration) { + finishReattachLiveDataDeferral(false, outputCallbacks.generation) + const gen = await preSignalPromise + if (typeof gen === 'number') { + void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) + } + return + } if (!result && expiredReattachError) { - finishReattachLiveDataDeferral(false) + finishReattachLiveDataDeferral(false, outputCallbacks.generation) const gen = await preSignalPromise if (typeof gen === 'number') { void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) @@ -7411,12 +7862,13 @@ export function connectPanePty( }) return } - const accepted = handleReattachResult( + const accepted = await handleReattachResult( result, deferredReattachSessionId, - coldRestoreStartup + coldRestoreStartup, + outputCallbacks.generation ) - finishReattachLiveDataDeferral(accepted) + finishReattachLiveDataDeferral(accepted, outputCallbacks.generation) const gen = await preSignalPromise if (typeof gen === 'number') { if (!isRemoteRuntimePtyId(deferredReattachSessionId)) { @@ -7435,12 +7887,15 @@ export function connectPanePty( } }) .catch(async (err) => { - finishReattachLiveDataDeferral(false) + finishReattachLiveDataDeferral(false, outputCallbacks.generation) const gen = await preSignalPromise if (typeof gen === 'number') { void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) } const message = err instanceof Error ? err.message : String(err) + if (outputCallbacks.generation !== transportStreamGeneration) { + return + } warnTerminalLifecycleAnomaly('restored PTY reattach threw', { tabId: deps.tabId, worktreeId: deps.worktreeId, @@ -7484,16 +7939,12 @@ export function connectPanePty( try { clearPaneMode2031State() clearHiddenOutputRestoreState() + const outputCallbacks = captureTransportOutputCallbacks(reportError) transport.attach({ existingPtyId: attachPtyId, cols, rows, - callbacks: { - onConnect: reportRemoteRendererSerializerReady, - onData: dataCallback, - onReplayData: replayDataCallback, - onError: reportError - } + callbacks: outputCallbacks.callbacks }) const attachedPtyId = transport.getPtyId() ?? attachPtyId bindActivePanePty(attachedPtyId, { @@ -7541,16 +7992,12 @@ export function connectPanePty( } clearPaneMode2031State() clearHiddenOutputRestoreState() + const outputCallbacks = captureTransportOutputCallbacks(reportError) transport.attach({ existingPtyId: spawnedPtyId, cols, rows, - callbacks: { - onConnect: reportRemoteRendererSerializerReady, - onData: dataCallback, - onReplayData: replayDataCallback, - onError: reportError - } + callbacks: outputCallbacks.callbacks }) const attachedPtyId = transport.getPtyId() ?? spawnedPtyId // Why: this path reuses a PTY spawned by an earlier mount, so no @@ -7738,10 +8185,16 @@ export function connectPanePty( reconcileIfSessionMissing, dispose() { disposed = true + cancelPendingSafeFitContinuations(pane) + pendingHiddenSnapshotFit = null + pendingReattachFit = null // A normal park/reconnect/remount does not advance the recovery epoch; // invalidate this concrete xterm so its delayed retry cannot hit the next. terminalRecoveryInstance.unregister() unregisterUndeliverableWriteHandler() + cancelHiddenOutputSnapshotScrollRestore() + structuralReplayCoordinator.dispose() + cancelFreshSpawnFollowReset() // Why: the post-spawn reconcile polls across frames; cancel its pending // rAF so a torn-down pane cannot keep fitting/resizing after disposal. ptySizeReconcileHandle?.cancel() diff --git a/src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts b/src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts index 3b22b2d9acf..59286263364 100644 --- a/src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts @@ -15,7 +15,7 @@ describe('createPtySizeReassertion', () => { getPtyId: () => 'pty-1', isRemotePtyId: () => false, shouldSuppressDesktopResize: () => false, - fit: vi.fn(), + fitAndRun: (continuation) => continuation(), getTerminalDimensions: () => ({ cols: 82, rows: 30 }), getAppliedSize: vi.fn(async () => ({ cols: 120, rows: 30 })), forwardResize @@ -34,7 +34,7 @@ describe('createPtySizeReassertion', () => { getPtyId: () => 'pty-1', isRemotePtyId: () => false, shouldSuppressDesktopResize: () => false, - fit: vi.fn(), + fitAndRun: (continuation) => continuation(), getTerminalDimensions: () => ({ cols: 82, rows: 30 }), getAppliedSize: vi.fn(async () => ({ cols: 82, rows: 30 })), forwardResize @@ -53,8 +53,9 @@ describe('createPtySizeReassertion', () => { getPtyId: () => 'pty-1', isRemotePtyId: () => false, shouldSuppressDesktopResize: () => false, - fit: vi.fn(() => { + fitAndRun: vi.fn((continuation) => { calls.push('fit') + continuation() }), getTerminalDimensions: vi.fn(() => { calls.push('measure') @@ -81,8 +82,9 @@ describe('createPtySizeReassertion', () => { getPtyId: () => 'pty-1', isRemotePtyId: () => false, shouldSuppressDesktopResize: () => false, - fit: vi.fn(() => { + fitAndRun: vi.fn((continuation) => { forwardResize(82, 30) + continuation() }), getTerminalDimensions: () => ({ cols: 82, rows: 30 }), getAppliedSize: vi.fn(async () => ({ cols: 82, rows: 30 })), @@ -97,14 +99,14 @@ describe('createPtySizeReassertion', () => { }) it('can verify current dimensions without fitting again', async () => { - const fit = vi.fn() + const fitAndRun = vi.fn((continuation: () => void) => continuation()) const forwardResize = vi.fn() const reassertion = createPtySizeReassertion({ isDisposed: () => false, getPtyId: () => 'pty-1', isRemotePtyId: () => false, shouldSuppressDesktopResize: () => false, - fit, + fitAndRun, getTerminalDimensions: () => ({ cols: 82, rows: 30 }), getAppliedSize: vi.fn(async () => ({ cols: 120, rows: 30 })), forwardResize @@ -113,7 +115,7 @@ describe('createPtySizeReassertion', () => { reassertion.request({ fit: false }) await flushAsyncTicks() - expect(fit).not.toHaveBeenCalled() + expect(fitAndRun).not.toHaveBeenCalled() expect(forwardResize).toHaveBeenCalledWith(82, 30) }) @@ -124,7 +126,7 @@ describe('createPtySizeReassertion', () => { getPtyId: () => 'remote:terminal-1', isRemotePtyId: () => true, shouldSuppressDesktopResize: () => false, - fit: vi.fn(), + fitAndRun: (continuation) => continuation(), getTerminalDimensions: () => ({ cols: 82, rows: 30 }), getAppliedSize, forwardResize: vi.fn() @@ -134,7 +136,7 @@ describe('createPtySizeReassertion', () => { getPtyId: () => 'pty-1', isRemotePtyId: () => false, shouldSuppressDesktopResize: () => true, - fit: vi.fn(), + fitAndRun: (continuation) => continuation(), getTerminalDimensions: () => ({ cols: 82, rows: 30 }), getAppliedSize, forwardResize: vi.fn() @@ -164,7 +166,7 @@ describe('createPtySizeReassertion', () => { getPtyId: () => 'pty-1', isRemotePtyId: () => false, shouldSuppressDesktopResize: () => false, - fit: vi.fn(), + fitAndRun: (continuation) => continuation(), getTerminalDimensions: () => ({ cols: 82, rows: 30 }), getAppliedSize, forwardResize @@ -201,7 +203,7 @@ describe('createPtySizeReassertion', () => { getPtyId: () => 'pty-1', isRemotePtyId: () => false, shouldSuppressDesktopResize: () => false, - fit: vi.fn(), + fitAndRun: (continuation) => continuation(), getTerminalDimensions: () => ({ cols: targetCols, rows: 40 }), getAppliedSize, forwardResize @@ -240,7 +242,7 @@ describe('createPtySizeReassertion', () => { getPtyId: () => 'pty-1', isRemotePtyId: () => false, shouldSuppressDesktopResize: () => false, - fit: vi.fn(), + fitAndRun: (continuation) => continuation(), getTerminalDimensions: () => dims, getAppliedSize, forwardResize @@ -273,7 +275,7 @@ describe('createPtySizeReassertion', () => { getPtyId: () => 'pty-1', isRemotePtyId: () => false, shouldSuppressDesktopResize: () => false, - fit: vi.fn(), + fitAndRun: (continuation) => continuation(), getTerminalDimensions: () => dims, getAppliedSize, forwardResize @@ -313,7 +315,7 @@ describe('createPtySizeReassertion', () => { getPtyId: () => 'pty-1', isRemotePtyId: () => false, shouldSuppressDesktopResize: () => false, - fit: vi.fn(), + fitAndRun: (continuation) => continuation(), getTerminalDimensions: () => dims, getAppliedSize, forwardResize @@ -346,7 +348,7 @@ describe('createPtySizeReassertion', () => { getPtyId: () => 'pty-1', isRemotePtyId: () => false, shouldSuppressDesktopResize: () => false, - fit: vi.fn(), + fitAndRun: (continuation) => continuation(), getTerminalDimensions: () => dims, getAppliedSize, forwardResize @@ -369,7 +371,7 @@ describe('createPtySizeReassertion', () => { getPtyId: () => 'pty-1', isRemotePtyId: () => false, shouldSuppressDesktopResize: () => false, - fit: vi.fn(), + fitAndRun: (continuation) => continuation(), getTerminalDimensions: () => ({ cols: 82, rows: 30 }), getAppliedSize: vi.fn(async () => { throw new Error('unavailable') diff --git a/src/renderer/src/components/terminal-pane/pty-size-reassertion.ts b/src/renderer/src/components/terminal-pane/pty-size-reassertion.ts index 7f127b67083..6c5db87d04c 100644 --- a/src/renderer/src/components/terminal-pane/pty-size-reassertion.ts +++ b/src/renderer/src/components/terminal-pane/pty-size-reassertion.ts @@ -5,7 +5,7 @@ export type PtySizeReassertionOptions = { getPtyId: () => string | null isRemotePtyId: (ptyId: string) => boolean shouldSuppressDesktopResize: () => boolean - fit: () => void + fitAndRun: (continuation: () => void) => void getTerminalDimensions: () => PtySizeReassertionDimensions getAppliedSize: (ptyId: string) => Promise forwardResize: (cols: number, rows: number) => void @@ -46,7 +46,8 @@ export function createPtySizeReassertion(options: PtySizeReassertionOptions): Pt return } if (shouldFit) { - options.fit() + options.fitAndRun(() => run(false)) + return } const target = options.getTerminalDimensions() if (!dimensionsAreUsable(target)) { diff --git a/src/renderer/src/components/terminal-pane/replay-guard.test.ts b/src/renderer/src/components/terminal-pane/replay-guard.test.ts index 8f5d99ecbcb..beddf35e1a1 100644 --- a/src/renderer/src/components/terminal-pane/replay-guard.test.ts +++ b/src/renderer/src/components/terminal-pane/replay-guard.test.ts @@ -4,6 +4,7 @@ import { isPaneReplaying, replayIntoTerminal, replayIntoTerminalAsync, + waitForTerminalReplayWritesParsed, type ReplayingPanesRef } from './replay-guard' import { configureLazyArabicShapingJoiner } from '@/lib/pane-manager/terminal-arabic-shaping-joiner' @@ -418,6 +419,27 @@ describe('replay-guard', () => { }) describe('replay-guard stall handling (probe-certified release)', () => { + it('waits for the FIFO replay sentinel without releasing on elapsed time', async () => { + vi.useFakeTimers() + const { terminal } = makeFakePane(1) + let resolved = false + + void waitForTerminalReplayWritesParsed(terminal, { stallCheckMs: 1_000 }).then(() => { + resolved = true + }) + expect(terminal.lastData).toEqual(['']) + + vi.advanceTimersByTime(1_000) + expect(terminal.lastData).toEqual(['', '']) + expect(resolved).toBe(false) + vi.advanceTimersByTime(60_000) + expect(resolved).toBe(false) + + terminal.flush() + await Promise.resolve() + expect(resolved).toBe(true) + }) + it('HOLDS the guard while a slow replay is still parsing — a probe is queued, never a blind release', () => { // Why this is the load-bearing safety test: a time-based release here // would leak xterm auto-replies into the shell (and a leaked ESC into an diff --git a/src/renderer/src/components/terminal-pane/replay-guard.ts b/src/renderer/src/components/terminal-pane/replay-guard.ts index 1e1042de088..6711390d851 100644 --- a/src/renderer/src/components/terminal-pane/replay-guard.ts +++ b/src/renderer/src/components/terminal-pane/replay-guard.ts @@ -247,3 +247,49 @@ export function replayIntoTerminalAsync( }) }) } + +/** Resolves after every replay write already queued on this terminal has + * parsed. A delayed FIFO probe covers a lost sentinel callback without ever + * treating elapsed time alone as proof that parsing finished. */ +export function waitForTerminalReplayWritesParsed( + terminal: ReplayGuardWriteTarget, + options: Pick = {} +): Promise { + return new Promise((resolve) => { + let finished = false + let stallTimer: ReturnType | null = null + const finish = (): void => { + if (finished) { + return + } + finished = true + if (stallTimer !== null) { + clearTimeout(stallTimer) + stallTimer = null + } + resolve() + } + const queueProbe = (): void => { + if (finished) { + return + } + try { + // Why: an empty write is FIFO with earlier replay bytes. Its callback + // can recover a lost sentinel callback without changing parser state. + terminal.write('', finish) + } catch { + // A disposed terminal cannot parse any remaining replay bytes. + finish() + } + } + stallTimer = setTimeout(queueProbe, options.stallCheckMs ?? REPLAY_GUARD_STALL_CHECK_MS) + try { + // Why empty: pendingEscapeTailAnsi must remain the final replay bytes; + // xterm still orders this completion after every earlier write. + terminal.write('', finish) + } catch { + // A disposed terminal cannot parse any remaining replay bytes. + finish() + } + }) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.ts index d1af2c9f1af..cf92bca5c65 100644 --- a/src/renderer/src/components/terminal-pane/terminal-appearance.ts +++ b/src/renderer/src/components/terminal-pane/terminal-appearance.ts @@ -10,7 +10,7 @@ import { } from '@/lib/terminal-theme' import { buildFontFamily } from './layout-serialization' import { guardParserHandler } from './terminal-parser-handler-guard' -import { captureScrollState, restoreScrollState, safeFit } from '@/lib/pane-manager/pane-tree-ops' +import { safeFit, safeFitAndThen } from '@/lib/pane-manager/pane-tree-ops' import { normalizeTerminalFastScrollSensitivity, normalizeTerminalScrollSensitivity, @@ -300,21 +300,26 @@ export function applyTerminalAppearance( // separate hook and lets live toggles (settings change, font swap) // land immediately. manager.setPaneLigaturesEnabled(pane.id, ligaturesEnabled) - try { - const state = captureScrollState(pane.terminal) - safeFit(pane) - restoreScrollState(pane.terminal, state) - } catch { - /* ignore */ - } const transport = paneTransports.get(pane.id) // Why: skip PTY resize when a mobile-fit override is active — the PTY // is already at the correct phone dimensions and must not be resized // back to desktop dimensions by an appearance change. const appearancePtyId = transport?.getPtyId() if (transport?.isConnected() && (!appearancePtyId || !getFitOverrideForPty(appearancePtyId))) { - transport.resize(pane.terminal.cols, pane.terminal.rows) maybePushMode2031Flip(pane.id, appearance.mode, transport, paneMode2031, paneLastThemeMode) + safeFitAndThen(pane, 'appearance-pty-resize', () => { + const currentTransport = paneTransports.get(pane.id) + if ( + currentTransport !== transport || + !transport.isConnected() || + transport.getPtyId() !== appearancePtyId + ) { + return + } + transport.resize(pane.terminal.cols, pane.terminal.rows) + }) + } else { + safeFit(pane) } } 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 b42304cb851..21716851899 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 @@ -13,7 +13,8 @@ vi.mock('@/lib/pane-manager/pane-terminal-output-scheduler', () => ({ requestTerminalBacklogRecovery: vi.fn() })) vi.mock('@/lib/pane-manager/terminal-scroll-intent', () => ({ - enforceTerminalCurrentScrollIntent: vi.fn() + enforceTerminalCurrentScrollIntent: vi.fn(), + syncTerminalScrollIntentFromViewport: vi.fn() })) vi.mock('./pane-helpers', () => ({ fitAndFocusPanes: vi.fn(), @@ -73,6 +74,22 @@ describe('resumeTerminalVisibility reveal repaint', () => { expect(scheduleTabRevealWebglAtlasRecovery).toHaveBeenCalledTimes(1) }) + it('captures native trim movement before enforcing viewport intent', async () => { + const terminal = { name: 'trimmed-terminal' } + const manager = createManager() + manager.getPanes.mockReturnValue([{ terminal }]) + const { enforceTerminalCurrentScrollIntent, syncTerminalScrollIntentFromViewport } = vi.mocked( + await import('@/lib/pane-manager/terminal-scroll-intent') + ) + + resumeTerminalVisibility(resumeArgs(manager, true)) + + expect(syncTerminalScrollIntentFromViewport).toHaveBeenCalledWith(terminal) + expect(syncTerminalScrollIntentFromViewport.mock.invocationCallOrder[0]).toBeLessThan( + enforceTerminalCurrentScrollIntent.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY + ) + }) + 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 d1a0cc9e20f..5892dead9d7 100644 --- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts @@ -5,7 +5,10 @@ import { flushTerminalOutput, requestTerminalBacklogRecovery } from '@/lib/pane-manager/pane-terminal-output-scheduler' -import { enforceTerminalCurrentScrollIntent } from '@/lib/pane-manager/terminal-scroll-intent' +import { + enforceTerminalCurrentScrollIntent, + syncTerminalScrollIntentFromViewport +} from '@/lib/pane-manager/terminal-scroll-intent' import { fitAndFocusPanes, fitPanes, focusActivePane } from './pane-helpers' import { scheduleTabRevealWebglAtlasRecovery } from './terminal-webgl-atlas-recovery' @@ -51,6 +54,7 @@ export function resumeTerminalVisibility({ captureViewportPositions, withSuppressedScrollTracking }: ResumeTerminalVisibilityArgs): void { + syncTerminalViewportIntents(manager) // Why: WebGL resume can disturb xterm's viewport bookkeeping before the // post-resume fit runs. Capture numeric viewport positions first; the // restore path avoids content matching so duplicate agent log lines do @@ -133,6 +137,7 @@ export function recoverVisibleTerminalWindowWake({ requestTerminalBacklogRecovery(pane.terminal) flushTerminalOutput(pane.terminal, { maxChars: WINDOW_WAKE_FLUSH_CHARS }) } + syncTerminalViewportIntents(manager) manager.resumeRendering() if (isActive) { fitAndFocusPanes(manager) @@ -170,6 +175,7 @@ function resumeTerminalVisibilityHeavy(manager: PaneManager, isActive: boolean): requestTerminalBacklogRecovery(pane.terminal) flushTerminalOutput(pane.terminal, { maxChars: VISIBLE_RESUME_FLUSH_CHARS }) } + syncTerminalViewportIntents(manager) // Resume WebGL immediately so the terminal shows its last-known state // on the first painted frame. macOS context creation is ~5 ms; on // Windows (ANGLE -> D3D11) it can be 100-500 ms but a deferred resume @@ -190,3 +196,11 @@ function enforceTerminalViewportIntents(manager: PaneManager): void { enforceTerminalCurrentScrollIntent(pane.terminal) } } + +function syncTerminalViewportIntents(manager: PaneManager): void { + for (const pane of manager.getPanes()) { + // Why: native scrollback trimming moves a pinned viewport content-stably. + // Capture that live position before resume/fit can disturb it. + syncTerminalScrollIntentFromViewport(pane.terminal) + } +} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts index 4587aac9354..de537af3d91 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts @@ -20,6 +20,7 @@ const mocks = vi.hoisted(() => ({ getTerminalOutputEpoch: vi.fn(() => 0), handleTerminalFileDrop: vi.fn(), enforceTerminalCurrentScrollIntent: vi.fn(), + syncTerminalScrollIntentFromViewport: vi.fn(), pasteTerminalText: vi.fn(), recordTerminalUserInputForLeaf: vi.fn(), requestTerminalBacklogRecovery: vi.fn(), @@ -79,7 +80,8 @@ vi.mock('@/lib/pane-manager/pane-scroll', () => ({ })) vi.mock('@/lib/pane-manager/terminal-scroll-intent', () => ({ - enforceTerminalCurrentScrollIntent: mocks.enforceTerminalCurrentScrollIntent + enforceTerminalCurrentScrollIntent: mocks.enforceTerminalCurrentScrollIntent, + syncTerminalScrollIntentFromViewport: mocks.syncTerminalScrollIntentFromViewport })) vi.mock('./terminal-drop-handler', () => ({ diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index c2c355a8e9b..e1fe139d445 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -106,10 +106,8 @@ import { getConnectionId } from '@/lib/connection-context' import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' import { isPaneReplaying, type ReplayingPanesRef } from './replay-guard' import { fitAndFocusPanes, fitPanes } from './pane-helpers' -import { - markTerminalPinnedViewport, - syncTerminalScrollIntentSoon -} from '@/lib/pane-manager/terminal-scroll-intent' +import { markTerminalPinnedViewport } from '@/lib/pane-manager/terminal-scroll-intent' +import { syncTerminalScrollIntentSoon } from '@/lib/pane-manager/terminal-scroll-intent-settle' import { registerRuntimeTerminalTab, scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' import { captureParkedTerminalPaneCandidates } from './terminal-parked-tab-watchers' import { e2eConfig } from '@/lib/e2e-config' @@ -1024,11 +1022,20 @@ export function useTerminalPaneLifecycle({ } if (e.type === 'keydown') { + const shouldSyncCurrentTerminal = (): boolean => + managerRef.current + ?.getPanes() + .some((candidate) => candidate.terminal === pane.terminal) === true if (e.key === 'PageUp' || e.key === 'Home') { markTerminalPinnedViewport(pane.terminal) - syncTerminalScrollIntentSoon(pane.terminal, { preservePinnedAtBottom: true }) + syncTerminalScrollIntentSoon(pane.terminal, { + preservePinnedAtBottom: true, + shouldSync: shouldSyncCurrentTerminal + }) } else if (e.key === 'PageDown' || e.key === 'End') { - syncTerminalScrollIntentSoon(pane.terminal) + syncTerminalScrollIntentSoon(pane.terminal, { + shouldSync: shouldSyncCurrentTerminal + }) } } diff --git a/src/renderer/src/components/terminal-pane/useTerminalFontZoom.ts b/src/renderer/src/components/terminal-pane/useTerminalFontZoom.ts index da714e9bfe4..1d2bd6c22a0 100644 --- a/src/renderer/src/components/terminal-pane/useTerminalFontZoom.ts +++ b/src/renderer/src/components/terminal-pane/useTerminalFontZoom.ts @@ -1,7 +1,7 @@ import { useEffect } from 'react' import type { PaneManager } from '@/lib/pane-manager/pane-manager' import { dispatchZoomLevelChanged } from '@/lib/zoom-events' -import { captureScrollState, restoreScrollState, safeFit } from '@/lib/pane-manager/pane-tree-ops' +import { safeFit } from '@/lib/pane-manager/pane-tree-ops' import { getPaneOwnedActiveHelperTextarea } from './regular-terminal-focus-ownership' type FontZoomDeps = { @@ -57,13 +57,7 @@ export function useTerminalFontZoom({ } pane.terminal.options.fontSize = nextSize - try { - const state = captureScrollState(pane.terminal) - safeFit(pane) - restoreScrollState(pane.terminal, state) - } catch { - /* ignore */ - } + safeFit(pane) const percent = Math.round((nextSize / globalSize) * 100) dispatchZoomLevelChanged('terminal', percent) 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 daf76998950..b4675047d68 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 @@ -5,6 +5,10 @@ import { detachPaneFitResizeObserver, requestStablePaneFit } from './pane-fit-resize-observer' +import { + beginTerminalScrollIntentBufferRebuild, + endTerminalScrollIntentBufferRebuild +} from './terminal-scroll-intent-rebuild' type ResizeObserverCallbackLike = ConstructorParameters[0] @@ -174,6 +178,23 @@ describe('attachPaneFitResizeObserver', () => { expect(onSettled).toHaveBeenCalledTimes(1) }) + it('does not notify settled callbacks until a replay-deferred fit completes', async () => { + const onSettled = vi.fn() + const pane = createPane() + beginTerminalScrollIntentBufferRebuild(pane.terminal) + + requestStablePaneFit(pane, onSettled) + flushAnimationFrames() + + expect(pane.fitAddon.fit).not.toHaveBeenCalled() + expect(onSettled).not.toHaveBeenCalled() + endTerminalScrollIntentBufferRebuild(pane.terminal) + await Promise.resolve() + + expect(pane.fitAddon.fit).toHaveBeenCalledTimes(1) + expect(onSettled).toHaveBeenCalledTimes(1) + }) + it('skips observer fits while the terminal has no visible geometry', () => { const pane = createPane(() => ({ cols: 80, rows: 24 }), { rect: { width: 0, height: 0 } 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 867a2b5dd18..16d1a0a5b15 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 @@ -1,5 +1,5 @@ import type { ManagedPane, ManagedPaneInternal } from './pane-manager-types' -import { safeFit } from './pane-tree-ops' +import { cancelPendingSafeFitContinuations, safeFitAndThen } from './pane-tree-ops' type ProposedDimensions = { cols: number @@ -77,7 +77,8 @@ function flushStableFitCallbacks(pane: StableFitPane): void { function finishStableFit(pane: StableFitPane, shouldFit: boolean): void { setPendingObservedFitRafId(pane, null) if (shouldFit) { - safeFit(pane) + safeFitAndThen(pane, 'stable-pane-fit', () => flushStableFitCallbacks(pane)) + return } flushStableFitCallbacks(pane) } @@ -164,4 +165,5 @@ export function detachPaneFitResizeObserver(pane: ManagedPaneInternal): void { setPendingObservedFitRafId(pane, null) } stableFitCallbacks.delete(pane) + cancelPendingSafeFitContinuations(pane) } diff --git a/src/renderer/src/lib/pane-manager/pane-fit.ts b/src/renderer/src/lib/pane-manager/pane-fit.ts new file mode 100644 index 00000000000..573ba6ab7ed --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-fit.ts @@ -0,0 +1,257 @@ +import type { ManagedPane, ManagedPaneInternal, ScrollState } from './pane-manager-types' +import { getFitOverrideForPty } from './mobile-fit-overrides' +import { + captureTerminalStructuralScrollIntent, + isTerminalStructuralScrollIntentCurrent, + markTerminalPinnedViewport, + restoreTerminalStructuralScrollIntent +} from './terminal-scroll-intent' +import { + captureScrollState, + releaseScrollStateMarker, + restoreScrollStateAfterFit, + resumePendingFitScrollRestoreAfterFit +} from './pane-scroll' +import { + deferTerminalGeometryMutationDuringRebuild, + isTerminalScrollIntentRebuildInFlight +} from './terminal-scroll-intent-rebuild' + +const MIN_PANE_FIT_WIDTH_PX = 48 +const MIN_PANE_FIT_HEIGHT_PX = 24 +const MIN_PANE_FIT_COLS = 8 +const MIN_PANE_FIT_ROWS = 4 + +export type SafeFitContinuationHandle = { + completion: Promise + cancel: () => void +} + +type PendingSafeFitContinuation = { + continuation: () => void + shouldContinue: () => boolean + resolve: (completed: boolean) => void +} + +const pendingSafeFitContinuations = new WeakMap< + ManagedPane, + Map +>() + +function getProposedDimensions(pane: ManagedPane): { cols: number; rows: number } | null { + try { + return pane.fitAddon.proposeDimensions() ?? null + } catch { + return null + } +} + +function canMeasurePaneForFit(pane: ManagedPane): boolean { + const measure = pane.container?.getBoundingClientRect + if (typeof measure === 'function') { + const rect = measure.call(pane.container) + if (rect.width < MIN_PANE_FIT_WIDTH_PX || rect.height < MIN_PANE_FIT_HEIGHT_PX) { + return false + } + } + const dims = getProposedDimensions(pane) + if (!dims) { + return false + } + // Why: worktree switches can briefly measure a near-zero overlay before + // fallback positioning lands. Fitting there pins the PTY at ~2 cols. + return dims.cols >= MIN_PANE_FIT_COLS && dims.rows >= MIN_PANE_FIT_ROWS +} + +function canPreserveScrollIntentForFit(pane: ManagedPane): boolean { + // Why: split reparent has its own delayed restore; restoring here can fight that timer. + return !( + 'pendingSplitScrollState' in pane && (pane as ManagedPaneInternal).pendingSplitScrollState + ) +} + +function performSafeFit(pane: ManagedPane): boolean { + if (deferTerminalGeometryMutationDuringRebuild(pane.terminal, 'safe-fit', () => safeFit(pane))) { + return false + } + if (!canMeasurePaneForFit(pane)) { + return false + } + let scrollIntent = null as ReturnType + let pinnedScrollState: ScrollState | null = null + let shouldRestoreScroll = false + const captureScrollForFit = (): void => { + scrollIntent = captureTerminalStructuralScrollIntent(pane.terminal) + // Why: fit can reflow and renumber every buffer row; a marker tracks the + // pinned content itself, while a numeric line would point elsewhere after. + pinnedScrollState = + scrollIntent?.kind === 'pinnedViewport' ? captureScrollState(pane.terminal) : null + shouldRestoreScroll = true + } + try { + // Why: a mobile-owned PTY must stay at its phone grid on passive desktop panes. + const ptyId = pane.container?.dataset?.ptyId + const override = ptyId ? getFitOverrideForPty(ptyId) : null + if (override) { + if (pane.terminal.cols !== override.cols || pane.terminal.rows !== override.rows) { + if (canPreserveScrollIntentForFit(pane)) { + captureScrollForFit() + } + pane.terminal.resize(override.cols, override.rows) + } else { + resumePendingFitScrollRestoreAfterFit(pane.terminal) + } + return true + } + + const dims = getProposedDimensions(pane) + if (dims && dims.cols === pane.terminal.cols && dims.rows === pane.terminal.rows) { + // Why: divider drags often stay within one cell; avoid needless clear/refresh churn. + resumePendingFitScrollRestoreAfterFit(pane.terminal) + return true + } + if (canPreserveScrollIntentForFit(pane)) { + captureScrollForFit() + } + pane.fitAddon.fit() + return true + } catch { + // Container may not have dimensions yet. + return false + } finally { + if (shouldRestoreScroll) { + try { + if (resumePendingFitScrollRestoreAfterFit(pane.terminal)) { + } else if (pinnedScrollState) { + const state: ScrollState = pinnedScrollState + pinnedScrollState = null + restoreScrollStateAfterFit(pane.terminal, state, { + onRestored: () => { + // Why: do not replace a durable pre-replay pin with transient 0/0 geometry. + if (!state.wasAtBottom) { + markTerminalPinnedViewport(pane.terminal) + } + }, + shouldRestore: () => + !isTerminalScrollIntentRebuildInFlight(pane.terminal) && + isTerminalStructuralScrollIntentCurrent(pane.terminal, scrollIntent) + }) + } else { + restoreTerminalStructuralScrollIntent(pane.terminal, scrollIntent) + } + } catch { + // Why: SSH reattach can briefly expose xterm without renderer dimensions. + } finally { + if (pinnedScrollState) { + releaseScrollStateMarker(pinnedScrollState) + } + } + } + } +} + +function settlePendingSafeFitContinuation( + pane: ManagedPane, + operationKey: string, + pending: PendingSafeFitContinuation, + completed: boolean +): void { + const operations = pendingSafeFitContinuations.get(pane) + if (operations?.get(operationKey) !== pending) { + return + } + operations.delete(operationKey) + if (operations.size === 0) { + pendingSafeFitContinuations.delete(pane) + } + pending.resolve(completed) +} + +function flushPendingSafeFitContinuations(pane: ManagedPane): void { + const operations = pendingSafeFitContinuations.get(pane) + if (!operations) { + return + } + for (const [operationKey, pending] of operations) { + if (!pending.shouldContinue()) { + settlePendingSafeFitContinuation(pane, operationKey, pending, false) + continue + } + try { + pending.continuation() + settlePendingSafeFitContinuation(pane, operationKey, pending, true) + } catch { + settlePendingSafeFitContinuation(pane, operationKey, pending, false) + } + } +} + +export function safeFit(pane: ManagedPane): boolean { + const completed = performSafeFit(pane) + if (completed) { + // Why: replay transactions may be waiting for renderer dimensions; any + // successful ordinary fit is the event that makes their PTY grid authoritative. + flushPendingSafeFitContinuations(pane) + } + return completed +} + +export function cancelPendingSafeFitContinuations(pane: ManagedPane): void { + const operations = pendingSafeFitContinuations.get(pane) + if (!operations) { + return + } + pendingSafeFitContinuations.delete(pane) + for (const pending of operations.values()) { + pending.resolve(false) + } +} + +// Why: callers that forward xterm's grid to a PTY must wait for a measurable +// fit or explicit lifecycle cancellation instead of observing replay dimensions. +export function safeFitAndThen( + pane: ManagedPane, + operationKey: string, + continuation: () => void, + options: { shouldContinue?: () => boolean } = {} +): SafeFitContinuationHandle { + const operations = pendingSafeFitContinuations.get(pane) ?? new Map() + const replaced = operations.get(operationKey) + if (replaced) { + settlePendingSafeFitContinuation(pane, operationKey, replaced, false) + } + let resolveCompletion = (_completed: boolean): void => {} + const completion = new Promise((resolve) => { + resolveCompletion = resolve + }) + const pending: PendingSafeFitContinuation = { + continuation, + shouldContinue: options.shouldContinue ?? (() => true), + resolve: resolveCompletion + } + const currentOperations = pendingSafeFitContinuations.get(pane) ?? operations + currentOperations.set(operationKey, pending) + pendingSafeFitContinuations.set(pane, currentOperations) + const cancel = (): void => { + settlePendingSafeFitContinuation(pane, operationKey, pending, false) + } + if (!pending.shouldContinue()) { + cancel() + return { completion, cancel } + } + if ( + deferTerminalGeometryMutationDuringRebuild( + pane.terminal, + `safe-fit-and-then:${operationKey}`, + () => { + if (pendingSafeFitContinuations.get(pane)?.get(operationKey) === pending) { + safeFit(pane) + } + } + ) + ) { + return { completion, cancel } + } + safeFit(pane) + return { completion, cancel } +} diff --git a/src/renderer/src/lib/pane-manager/pane-initial-fit-lifecycle.test.ts b/src/renderer/src/lib/pane-manager/pane-initial-fit-lifecycle.test.ts index 4d08013372f..81baa63f5c6 100644 --- a/src/renderer/src/lib/pane-manager/pane-initial-fit-lifecycle.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-initial-fit-lifecycle.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { ManagedPaneInternal } from './pane-manager-types' import { disposePane } from './pane-lifecycle' +import { restoreScrollStateAfterFit } from './pane-scroll' function createPane(pendingInitialFitRafId: number | null): ManagedPaneInternal { const leafId = '11111111-1111-4111-8111-111111111111' as never @@ -54,4 +55,31 @@ describe('pane initial fit lifecycle', () => { expect(pane.pendingInitialFitRafId).toBeNull() expect(panes.has(pane.id)).toBe(false) }) + + it('cancels pending fit scroll restoration before terminal disposal', () => { + const cancelAnimationFrame = vi.fn() + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn(() => 23) + ) + vi.stubGlobal('cancelAnimationFrame', cancelAnimationFrame) + const pane = createPane(null) + const marker = { line: 42, isDisposed: false, dispose: vi.fn() } + + restoreScrollStateAfterFit( + pane.terminal, + { + bufferType: 'normal', + wasAtBottom: false, + viewportY: 42, + baseY: 100, + firstVisibleLineMarker: marker as never + }, + { onRestored: vi.fn(), shouldRestore: () => true } + ) + disposePane(pane, new Map([[pane.id, pane]])) + + expect(cancelAnimationFrame).toHaveBeenCalledWith(23) + expect(marker.dispose).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts index 4b0fef324ac..a18e432d065 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts @@ -46,6 +46,7 @@ function createPane(): ManagedPaneInternal { loadAddon: vi.fn(), attachCustomWheelEventHandler: vi.fn(), refresh: vi.fn(), + cols: 80, rows: 24 } as never, container: {} as never, @@ -57,7 +58,8 @@ function createPane(): ManagedPaneInternal { webglDisabledAfterContextLoss: false, hasComplexScriptOutput: false, fitAddon: { - fit: vi.fn() + fit: vi.fn(), + proposeDimensions: vi.fn(() => ({ cols: 80, rows: 23 })) } as never, fitResizeObserver: null, pendingObservedFitRafId: null, diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts index adc43af01c1..ea938f9a351 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts @@ -5,9 +5,10 @@ import { detachPaneFitResizeObserver } from './pane-fit-resize-observer' import { clearPendingSplitScrollRestore } from './pane-split-scroll' +import { cancelDeferredScrollRestore } from './pane-scroll' import { activateOrcaTerminalUnicodeProvider } from '../../../../shared/terminal-unicode-provider' import { attachTerminalMouseWheelMultiplier } from './pane-terminal-mouse-wheel' -import { attachTerminalScrollIntentTracking } from './terminal-scroll-intent' +import { attachTerminalScrollIntentTracking } from './terminal-scroll-intent-dom-tracking' import { attachDomRendererFocusClassSync } from './pane-dom-focus-class-sync' import { attachWebgl, cancelPendingWebglRefresh, disposeWebgl } from './pane-webgl-renderer' import { configureLazyArabicShapingJoiner } from './terminal-arabic-shaping-joiner' @@ -244,6 +245,13 @@ export function disposePane( } catch { /* ignore */ } + try { + // Why: fit retries own xterm markers and frame callbacks independently of + // split restoration; both must be released before terminal disposal. + cancelDeferredScrollRestore(pane.terminal) + } catch { + /* ignore */ + } try { pane.ligaturesAddon?.dispose() } catch { diff --git a/src/renderer/src/lib/pane-manager/pane-manager-types.ts b/src/renderer/src/lib/pane-manager/pane-manager-types.ts index 8218de5e856..5f2c3d5fcdb 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-types.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-types.ts @@ -124,6 +124,8 @@ export type ScrollState = { viewportY: number baseY: number firstVisibleLineMarker?: IMarker + firstVisibleLogicalLineMarker?: IMarker + firstVisibleLogicalCellOffset?: number } export type ManagedPaneInternal = { diff --git a/src/renderer/src/lib/pane-manager/pane-scroll.test.ts b/src/renderer/src/lib/pane-manager/pane-scroll.test.ts index 9d1317d8ed4..9378945e40d 100644 --- a/src/renderer/src/lib/pane-manager/pane-scroll.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-scroll.test.ts @@ -1,10 +1,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import { Terminal as HeadlessTerminal } from '@xterm/headless' import type { IMarker, Terminal } from '@xterm/xterm' import { captureScrollState, getTerminalOutputEpoch, recordTerminalOutput, restoreScrollState, + restoreScrollStateAfterFit, restoreScrollStateAfterLayout } from './pane-scroll' import type { ScrollState } from './pane-manager-types' @@ -59,6 +61,24 @@ function setMarkerLine(marker: IMarker, line: number): void { mutableMarker.line = line } +function writeHeadless(terminal: HeadlessTerminal, data: string): Promise { + return new Promise((resolve) => terminal.write(data, resolve)) +} + +function findBufferLineContaining(terminal: HeadlessTerminal, text: string): number { + for (let lineY = 0; lineY < terminal.buffer.active.length; lineY += 1) { + if (terminal.buffer.active.getLine(lineY)?.translateToString(true).includes(text)) { + return lineY + } + } + return -1 +} + +function makeHeadlessRestorable(terminal: HeadlessTerminal): Terminal { + Object.defineProperty(terminal, 'element', { configurable: true, value: {} }) + return terminal as unknown as Terminal +} + describe('scroll state', () => { afterEach(() => { vi.useRealTimers() @@ -286,6 +306,61 @@ describe('scroll state', () => { expect(terminal.buffer.active.viewportY).toBe(30) }) + it('releases fit markers when restoration throws an unexpected error', () => { + const terminal = createTerminal({ viewportY: 10, baseY: 100 }) + const marker = createMarker(42) + vi.mocked(terminal.scrollToLine).mockImplementation(() => { + throw new Error('unexpected renderer failure') + }) + const state: ScrollState = { + bufferType: 'normal', + wasAtBottom: false, + viewportY: 42, + baseY: 100, + firstVisibleLineMarker: marker + } + + expect(() => + restoreScrollStateAfterFit(terminal, state, { + onRestored: vi.fn(), + shouldRestore: () => true + }) + ).toThrow('unexpected renderer failure') + expect(marker.isDisposed).toBe(true) + }) + + it('releases fit markers when an asynchronous retry throws', () => { + const frameCallbacks: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frameCallbacks.push(callback) + return frameCallbacks.length + }) + const terminal = createTerminal({ viewportY: 10, baseY: 100 }) + const marker = createMarker(42) + vi.mocked(terminal.scrollToLine) + .mockImplementationOnce(() => { + throw new TypeError("Cannot read properties of undefined (reading 'dimensions')") + }) + .mockImplementationOnce(() => { + throw new Error('unexpected asynchronous renderer failure') + }) + + restoreScrollStateAfterFit( + terminal, + { + bufferType: 'normal', + wasAtBottom: false, + viewportY: 42, + baseY: 100, + firstVisibleLineMarker: marker + }, + { onRestored: vi.fn(), shouldRestore: () => true } + ) + + expect(() => frameCallbacks.shift()?.(0)).toThrow('unexpected asynchronous renderer failure') + expect(marker.isDisposed).toBe(true) + }) + it('scrolls to the current bottom when the pane was previously at bottom', () => { const terminal = createTerminal({ viewportY: 10, baseY: 250 }) const state: ScrollState = { @@ -316,4 +391,142 @@ describe('scroll state', () => { expect(terminal.scrollToLine).not.toHaveBeenCalled() expect(terminal.buffer.active.viewportY).toBe(10) }) + + it.each([ + { + fromCols: 10, + toCols: 20, + pinnedText: 'abcdefghij', + expectedTop: 'ABCDEFGHIJabcdefghij' + }, + { fromCols: 20, toCols: 7, pinnedText: 'KLMNOPQRSTuvwxyz', expectedTop: 'efghijK' } + ])( + 'restores the same logical cells through real xterm reflow ($fromCols->$toCols)', + async ({ fromCols, toCols, pinnedText, expectedTop }) => { + const headless = new HeadlessTerminal({ + cols: fromCols, + rows: 5, + scrollback: 1000, + allowProposedApi: true + }) + try { + await writeHeadless(headless, 'prefix\r\n') + await writeHeadless(headless, 'ABCDEFGHIJabcdefghijKLMNOPQRSTuvwxyz\r\n') + for (let index = 0; index < 10; index += 1) { + await writeHeadless(headless, `tail-${index}\r\n`) + } + const pinnedLine = findBufferLineContaining(headless, pinnedText) + expect(pinnedLine).toBeGreaterThan(0) + headless.scrollToLine(pinnedLine) + const terminal = makeHeadlessRestorable(headless) + const state = captureScrollState(terminal) + + headless.resize(toCols, 5) + expect(state.firstVisibleLogicalLineMarker?.isDisposed).toBe(false) + expect(restoreScrollState(terminal, state)).toBe(true) + + expect( + headless.buffer.active.getLine(headless.buffer.active.viewportY)?.translateToString(true) + ).toBe(expectedTop) + } finally { + headless.dispose() + } + } + ) + + it('uses a logical marker for backend-only ConPTY compatibility', async () => { + const headless = new HeadlessTerminal({ + cols: 10, + rows: 5, + scrollback: 1000, + allowProposedApi: true, + windowsPty: { backend: 'conpty' } + }) + try { + await writeHeadless(headless, 'prefix\r\n') + await writeHeadless(headless, 'ABCDEFGHIJabcdefghijKLMNOPQRSTuvwxyz\r\n') + for (let index = 0; index < 10; index += 1) { + await writeHeadless(headless, `tail-${index}\r\n`) + } + const pinnedLine = findBufferLineContaining(headless, 'abcdefghij') + headless.scrollToLine(pinnedLine) + const terminal = makeHeadlessRestorable(headless) + const state = captureScrollState(terminal) + + expect(state.firstVisibleLogicalLineMarker).toBeDefined() + headless.resize(20, 5) + expect(restoreScrollState(terminal, state)).toBe(true) + expect( + headless.buffer.active.getLine(headless.buffer.active.viewportY)?.translateToString(true) + ).toBe('ABCDEFGHIJabcdefghij') + } finally { + headless.dispose() + } + }) + + it('keeps a physical marker for the default non-reflowing cursor line', async () => { + const headless = new HeadlessTerminal({ + cols: 10, + rows: 3, + scrollback: 100, + allowProposedApi: true + }) + try { + await writeHeadless(headless, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') + const terminal = makeHeadlessRestorable(headless) + headless.scrollLines(-1) + const state = captureScrollState(terminal) + + expect(state.firstVisibleLineMarker).toBeDefined() + expect(state.firstVisibleLogicalLineMarker).toBeUndefined() + headless.resize(20, 3) + expect(restoreScrollState(terminal, state)).toBe(true) + } finally { + headless.dispose() + } + }) + + it('uses physical markers for legacy ConPTY and logical markers for modern ConPTY', async () => { + const captureForBuild = async (buildNumber: number): Promise => { + const headless = new HeadlessTerminal({ + cols: 10, + rows: 3, + scrollback: 100, + allowProposedApi: true, + windowsPty: { backend: 'conpty', buildNumber } + }) + await writeHeadless(headless, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789\r\n') + await writeHeadless(headless, 'tail-1\r\ntail-2\r\ntail-3\r\n') + const pinnedLine = findBufferLineContaining(headless, 'KLMNOPQRST') + headless.scrollToLine(pinnedLine) + const state = captureScrollState(makeHeadlessRestorable(headless)) + headless.dispose() + return state + } + + const legacy = await captureForBuild(19045) + const modern = await captureForBuild(26100) + + expect(legacy.firstVisibleLogicalLineMarker).toBeUndefined() + expect(modern.firstVisibleLogicalLineMarker).toBeDefined() + }) + + it('counts a wide glyph wrap placeholder as zero logical cells', async () => { + const headless = new HeadlessTerminal({ + cols: 10, + rows: 3, + scrollback: 100, + allowProposedApi: true + }) + try { + await writeHeadless(headless, '123456789界abcdefghij\r\ntail-1\r\ntail-2\r\ntail-3\r\n') + const pinnedLine = findBufferLineContaining(headless, '界') + headless.scrollToLine(pinnedLine) + const state = captureScrollState(makeHeadlessRestorable(headless)) + + expect(state.firstVisibleLogicalCellOffset).toBe(9) + } finally { + headless.dispose() + } + }) }) diff --git a/src/renderer/src/lib/pane-manager/pane-scroll.ts b/src/renderer/src/lib/pane-manager/pane-scroll.ts index 083c41b4d31..bb79faa4556 100644 --- a/src/renderer/src/lib/pane-manager/pane-scroll.ts +++ b/src/renderer/src/lib/pane-manager/pane-scroll.ts @@ -1,9 +1,14 @@ import type { Terminal } from '@xterm/xterm' import type { ScrollState } from './pane-manager-types' +import { + captureLogicalLineAnchor, + resolveLogicalCellOffsetLine +} from './terminal-reflow-scroll-anchor' +import { forceTerminalViewportScrollbarSync } from './terminal-viewport-scrollbar-sync' const terminalOutputEpochs = new WeakMap() const deferredScrollRestores = new WeakMap< - Terminal, + object, { cancelled: boolean rafIds: number[] @@ -11,6 +16,19 @@ const deferredScrollRestores = new WeakMap< timeoutIds: ReturnType[] } >() +const pendingFitScrollRestores = new WeakMap< + object, + { + cancelled: boolean + rafId: number | null + retryAfterFit: () => boolean + shouldRestore: () => boolean + state: ScrollState + } +>() +const FIT_SCROLL_RESTORE_MAX_FRAMES = 2 + +type ScrollRestoreResult = 'restored' | 'retry' | 'skipped' export function recordTerminalOutput(terminal: Terminal): void { terminalOutputEpochs.set(terminal, getTerminalOutputEpoch(terminal) + 1) @@ -20,7 +38,8 @@ export function getTerminalOutputEpoch(terminal: Terminal): number { return terminalOutputEpochs.get(terminal) ?? 0 } -export function cancelDeferredScrollRestore(terminal: Terminal): void { +export function cancelDeferredScrollRestore(terminal: object): void { + cancelPendingFitScrollRestore(terminal) const pending = deferredScrollRestores.get(terminal) if (!pending) { return @@ -42,24 +61,138 @@ export function captureScrollState(terminal: Terminal): ScrollState { const buf = terminal.buffer.active const viewportY = buf.viewportY const wasAtBottom = viewportY >= buf.baseY + const logicalAnchor = + !wasAtBottom && buf.type === 'normal' + ? captureLogicalLineAnchor(terminal, viewportY) + : undefined + const firstVisibleLineMarker = + !wasAtBottom && buf.type === 'normal' + ? terminal.registerMarker?.(viewportY - (buf.baseY + buf.cursorY)) + : undefined return { bufferType: buf.type, wasAtBottom, viewportY, baseY: buf.baseY, - // Why: xterm markers track the same buffer line through resize reflow; - // a numeric viewport line alone can point at different content afterward. - firstVisibleLineMarker: - !wasAtBottom && buf.type === 'normal' - ? terminal.registerMarker?.(viewportY - (buf.baseY + buf.cursorY)) - : undefined + // Why: continuation-row markers can be deleted or drift during reflow. + // Keep the physical marker for no-reflow ConPTY/cursor-line cases, and + // anchor reflowing content at the logical line's stable first row. + firstVisibleLineMarker, + firstVisibleLogicalLineMarker: + logicalAnchor?.lineY === viewportY + ? firstVisibleLineMarker + : logicalAnchor + ? terminal.registerMarker?.(logicalAnchor.lineY - (buf.baseY + buf.cursorY)) + : undefined, + firstVisibleLogicalCellOffset: logicalAnchor?.cellOffset } } -export function restoreScrollState(terminal: Terminal, state: ScrollState): void { +export function restoreScrollState(terminal: Terminal, state: ScrollState): boolean { cancelDeferredScrollRestore(terminal) - restoreScrollStateNow(terminal, state) - releaseScrollStateMarker(state) + try { + return restoreScrollStateNow(terminal, state) === 'restored' + } finally { + releaseScrollStateMarker(state) + } +} + +export function restoreScrollStateAfterFit( + terminal: Terminal, + state: ScrollState, + options: { onRestored: () => void; shouldRestore: () => boolean } +): void { + cancelDeferredScrollRestore(terminal) + if (!options.shouldRestore()) { + releaseScrollStateMarker(state) + return + } + let initialResult: ScrollRestoreResult + try { + initialResult = restoreScrollStateNow(terminal, state) + } catch (error) { + releaseScrollStateMarker(state) + throw error + } + if (initialResult !== 'retry' || typeof requestAnimationFrame !== 'function') { + releaseScrollStateMarker(state) + if (initialResult === 'restored') { + options.onRestored() + } + return + } + + const pending = { + cancelled: false, + rafId: null as number | null, + retryAfterFit: (): boolean => false, + shouldRestore: options.shouldRestore, + state + } + let remainingFrames = FIT_SCROLL_RESTORE_MAX_FRAMES + const finish = (restored: boolean): void => { + if (pending.cancelled) { + return + } + pending.cancelled = true + pendingFitScrollRestores.delete(terminal) + releaseScrollStateMarker(state) + if (restored && options.shouldRestore()) { + options.onRestored() + } + } + const retry = (): boolean => { + pending.rafId = null + if (pending.cancelled || !options.shouldRestore()) { + finish(false) + return false + } + let result: ScrollRestoreResult + try { + result = restoreScrollStateNow(terminal, state) + } catch (error) { + finish(false) + throw error + } + if (result === 'restored') { + finish(true) + return true + } + remainingFrames -= 1 + if (result !== 'retry') { + finish(false) + return false + } + if (remainingFrames <= 0) { + // Why: background/WebGL teardown can outlast a bounded frame retry. + // Keep the content marker parked for the next real fit/reveal. + return true + } + pending.rafId = requestAnimationFrame(retry) + return true + } + pending.retryAfterFit = () => { + if (pending.rafId !== null && typeof cancelAnimationFrame === 'function') { + cancelAnimationFrame(pending.rafId) + pending.rafId = null + } + remainingFrames = FIT_SCROLL_RESTORE_MAX_FRAMES + 1 + return retry() + } + pendingFitScrollRestores.set(terminal, pending) + pending.rafId = requestAnimationFrame(retry) +} + +export function resumePendingFitScrollRestoreAfterFit(terminal: Terminal): boolean { + const pending = pendingFitScrollRestores.get(terminal) + if (!pending) { + return false + } + if (!pending.shouldRestore()) { + cancelPendingFitScrollRestore(terminal) + return false + } + return pending.retryAfterFit() } export function restoreScrollStateAfterLayout(terminal: Terminal, state: ScrollState): void { @@ -114,13 +247,13 @@ export function restoreScrollStateAfterLayout(terminal: Terminal, state: ScrollS deferredScrollRestores.set(terminal, pending) } -function restoreScrollStateNow(terminal: Terminal, state: ScrollState): void { +function restoreScrollStateNow(terminal: Terminal, state: ScrollState): ScrollRestoreResult { if (!terminal.element) { - return + return 'retry' } const buf = terminal.buffer.active if (state.bufferType === 'alternate' || buf.type !== state.bufferType) { - return + return 'skipped' } // Why: WebGL suspend disposes xterm's render service while leaving @@ -129,24 +262,42 @@ function restoreScrollStateNow(terminal: Terminal, state: ScrollState): void { // window quietly — the next visibility flip re-fits and re-restores. if (state.wasAtBottom) { if (safeScrollCall(() => terminal.scrollToBottom())) { - forceViewportScrollbarSync(terminal) + forceTerminalViewportScrollbarSync(terminal) + return 'restored' } - return + return 'retry' } + const logicalMarkerLine = + state.firstVisibleLogicalLineMarker && !state.firstVisibleLogicalLineMarker.isDisposed + ? state.firstVisibleLogicalLineMarker.line + : -1 const markerLine = state.firstVisibleLineMarker && !state.firstVisibleLineMarker.isDisposed ? state.firstVisibleLineMarker.line : -1 - const targetLine = Math.min(markerLine >= 0 ? markerLine : state.viewportY, buf.baseY) + const logicalTargetLine = + logicalMarkerLine >= 0 && state.firstVisibleLogicalCellOffset !== undefined + ? resolveLogicalCellOffsetLine( + terminal, + logicalMarkerLine, + state.firstVisibleLogicalCellOffset + ) + : null + const targetLine = Math.min( + logicalTargetLine ?? (markerLine >= 0 ? markerLine : state.viewportY), + buf.baseY + ) state.viewportY = targetLine // Why: deferred rAF/timeout restores re-invoke this function after xterm // reflow settles; keep the marker alive so each call consults the live // line. Callers (restoreScrollState, the timeout in // restoreScrollStateAfterLayout, cancelDeferredScrollRestore) own disposal. if (safeScrollCall(() => terminal.scrollToLine(targetLine))) { - forceViewportScrollbarSync(terminal) + forceTerminalViewportScrollbarSync(terminal) + return 'restored' } + return 'retry' } function safeScrollCall(fn: () => void): boolean { @@ -166,23 +317,21 @@ function safeScrollCall(fn: () => void): boolean { export function releaseScrollStateMarker(state: ScrollState): void { state.firstVisibleLineMarker?.dispose() - state.firstVisibleLineMarker = undefined + if (state.firstVisibleLogicalLineMarker !== state.firstVisibleLineMarker) { + state.firstVisibleLogicalLineMarker?.dispose() + } + state.firstVisibleLineMarker = state.firstVisibleLogicalLineMarker = undefined } -// Why: xterm 6 can leave its scrollbar thumb stale when ydisp is unchanged. -// A synchronous one-line jiggle updates the scrollbar without a visible paint. -function forceViewportScrollbarSync(terminal: Terminal): void { - const buf = terminal.buffer.active - if (buf.viewportY >= buf.baseY) { - // Why: jiggle-scrolling at bottom makes xterm stop following active output - // after split-pane resizes; scrollToBottom already places the thumb there. +function cancelPendingFitScrollRestore(terminal: object): void { + const pending = pendingFitScrollRestores.get(terminal) + if (!pending) { return } - if (buf.viewportY > 0) { - safeScrollCall(() => terminal.scrollLines(-1)) - safeScrollCall(() => terminal.scrollLines(1)) - } else if (buf.viewportY < buf.baseY) { - safeScrollCall(() => terminal.scrollLines(1)) - safeScrollCall(() => terminal.scrollLines(-1)) + pending.cancelled = true + if (pending.rafId !== null && typeof cancelAnimationFrame === 'function') { + cancelAnimationFrame(pending.rafId) } + releaseScrollStateMarker(pending.state) + pendingFitScrollRestores.delete(terminal) } diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts index cd5c046b226..c57175bb463 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts @@ -7,10 +7,6 @@ import { writeForegroundTerminalChunk, type ForegroundTerminalOutputTarget } from './pane-terminal-foreground-render-settle' -import { - captureTerminalWriteScrollIntent, - enforceTerminalWriteScrollIntent -} from './terminal-scroll-intent' import { runGuardedWriteCompletionStep } from './xterm-write-callback-guard' import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder' import { @@ -833,6 +829,9 @@ function hasDrainableBacklog(): boolean { return false } +// Why no per-write scroll enforcement: xterm's BufferService.isUserScrolling +// natively owns live follow/pin semantics. App-side intent enforcement is +// limited to structural operations xterm cannot identify, such as replay. function writeBackgroundTerminalChunk( terminal: TerminalOutputTarget, data: string, @@ -848,29 +847,13 @@ function writeBackgroundTerminalChunk( const runOnWriteFailure = onWriteFailure ? (): void => runGuardedWriteCompletionStep('background-on-write-failure', onWriteFailure) : undefined - const scrollIntent = captureTerminalWriteScrollIntent(terminal) try { - if (!scrollIntent) { - if (!runOnParsed || terminal.write.length < 2) { - terminal.write(data) - runOnParsed?.() - return true - } - terminal.write(data, runOnParsed) - return true - } - const runScrollIntentThenParsed = (): void => { - runGuardedWriteCompletionStep('background-scroll-intent', () => - enforceTerminalWriteScrollIntent(terminal, scrollIntent) - ) - runOnParsed?.() - } - if (terminal.write.length < 2) { + if (!runOnParsed || terminal.write.length < 2) { terminal.write(data) - runScrollIntentThenParsed() + runOnParsed?.() return true } - terminal.write(data, runScrollIntentThenParsed) + terminal.write(data, runOnParsed) return true } catch { runOnWriteFailure?.() @@ -878,32 +861,6 @@ function writeBackgroundTerminalChunk( } } -function writeForegroundTerminalChunkWithIntent( - terminal: TerminalOutputTarget, - data: string, - options: { - forceViewportRefresh: boolean - followupViewportRefresh: boolean - shouldRefreshViewportSynchronously: ForegroundRefreshSyncResolver - onParsed?: TerminalOutputParsedCallback - onWriteFailure?: () => void - } -): boolean { - const scrollIntent = captureTerminalWriteScrollIntent(terminal) - return writeForegroundTerminalChunk(terminal, data, { - forceViewportRefresh: options.forceViewportRefresh, - followupViewportRefresh: options.followupViewportRefresh, - shouldRefreshViewportSynchronously: options.shouldRefreshViewportSynchronously, - onParsed: () => { - // Why: recovery must repaint from the scrolled buffer state that xterm - // will keep, not from a pre-intent-restored viewport snapshot. - enforceTerminalWriteScrollIntent(terminal, scrollIntent) - options.onParsed?.() - }, - onWriteFailure: options.onWriteFailure - }) -} - function takeNextDrainableEntry(): QueueEntry | null { let largeBacklogEntry: QueueEntry | null = null for (const entry of queuedByTerminal.values()) { @@ -1015,7 +972,7 @@ function writeQueuedChunk(entry: QueueEntry): 'foreground' | 'background' | null try { queuedWrite.beforeWrite?.(queuedWrite.data) const writeAccepted = queuedWrite.foreground - ? writeForegroundTerminalChunkWithIntent( + ? writeForegroundTerminalChunk( entry.terminal, queuedWrite.stripTransientCursorShows ? removeTransientCursorShowSequences(queuedWrite.data) @@ -1052,8 +1009,8 @@ function writeQueuedChunk(entry: QueueEntry): 'foreground' | 'background' | null return null } } catch { - // Why: beforeWrite or pre-write viewport capture can fail before xterm owns - // the bytes. Cancel the armed watch without claiming parser failure. + // Why: beforeWrite or write setup can fail before xterm owns the bytes. + // Cancel the armed watch without claiming parser failure. cancelTerminalWriteStallWatch(entry.terminal) ackCreditsParsed?.() fireQueuedAckCredits(entry) @@ -1305,7 +1262,7 @@ export function writeTerminalOutput( }) try { options.beforeWrite?.(data) - writeForegroundTerminalChunkWithIntent( + writeForegroundTerminalChunk( terminal, options.stripTransientCursorShows ? removeTransientCursorShowSequences(data) : data, { @@ -1399,7 +1356,7 @@ export function flushTerminalOutput( try { queuedWrite.beforeWrite?.(queuedWrite.data) const writeAccepted = queuedWrite.foreground - ? writeForegroundTerminalChunkWithIntent( + ? writeForegroundTerminalChunk( terminal, queuedWrite.stripTransientCursorShows ? removeTransientCursorShowSequences(queuedWrite.data) @@ -1431,7 +1388,7 @@ export function flushTerminalOutput( return } } catch { - // Why: pre-write hooks/capture failed before xterm owned these bytes. + // Why: pre-write hooks/setup failed before xterm owned these bytes. // Cancel the watch; consumed + abandoned chunks still credit delivery. cancelTerminalWriteStallWatch(terminal) ackCreditsParsed?.() diff --git a/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts b/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts index 16a4e0092b6..66c3ed3a17e 100644 --- a/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts @@ -1,7 +1,23 @@ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' -import { equalizePaneSplitSizes, safeFit } from './pane-tree-ops' +import { + cancelPendingSafeFitContinuations, + equalizePaneSplitSizes, + safeFit, + safeFitAndThen +} from './pane-tree-ops' import type { ManagedPaneInternal, ScrollState } from './pane-manager-types' import { setFitOverride, hydrateOverrides } from './mobile-fit-overrides' +import { + captureTerminalStructuralScrollIntent, + enforceTerminalCurrentScrollIntent, + markTerminalPinnedViewport, + restoreTerminalStructuralScrollIntent +} from './terminal-scroll-intent' +import { + beginTerminalScrollIntentBufferRebuild, + cancelTerminalScrollIntentBufferRebuildCompletions, + endTerminalScrollIntentBufferRebuild +} from './terminal-scroll-intent-rebuild' class MockHTMLElement { classList: { contains: (cls: string) => boolean } @@ -21,6 +37,7 @@ beforeAll(() => { afterEach(() => { hydrateOverrides([]) + vi.unstubAllGlobals() }) function createPane({ @@ -178,6 +195,54 @@ describe('safeFit', () => { expect(pane.fitAddon.fit).toHaveBeenCalledTimes(1) }) + it('coalesces fits until replay parsing and scroll restoration complete', async () => { + const pane = createPane({ + proposedCols: 100, + proposedRows: 32, + terminalCols: 120, + terminalRows: 32 + }) + const activeBuffer = pane.terminal.buffer.active as { viewportY: number; baseY: number } + activeBuffer.viewportY = 80 + activeBuffer.baseY = 100 + markTerminalPinnedViewport(pane.terminal) + const intent = captureTerminalStructuralScrollIntent(pane.terminal) + beginTerminalScrollIntentBufferRebuild(pane.terminal) + activeBuffer.viewportY = 0 + activeBuffer.baseY = 0 + + safeFit(pane) + safeFit(pane) + expect(pane.fitAddon.fit).not.toHaveBeenCalled() + + activeBuffer.viewportY = 200 + activeBuffer.baseY = 200 + vi.mocked(pane.fitAddon.fit).mockImplementation(() => { + expect(activeBuffer.viewportY).toBe(180) + }) + endTerminalScrollIntentBufferRebuild(pane.terminal) + restoreTerminalStructuralScrollIntent(pane.terminal, intent, { restoreBy: 'bottomOffset' }) + await Promise.resolve() + + expect(pane.fitAddon.fit).toHaveBeenCalledTimes(1) + }) + + it('drops a deferred fit when a replay rebuild is canceled', async () => { + const pane = createPane({ + proposedCols: 100, + proposedRows: 32, + terminalCols: 120, + terminalRows: 32 + }) + beginTerminalScrollIntentBufferRebuild(pane.terminal) + safeFit(pane) + cancelTerminalScrollIntentBufferRebuildCompletions(pane.terminal) + endTerminalScrollIntentBufferRebuild(pane.terminal) + await Promise.resolve() + + expect(pane.fitAddon.fit).not.toHaveBeenCalled() + }) + it('restores the viewport if fit clobbers it during resize', () => { const pane = createPane({ proposedCols: 100, @@ -231,6 +296,66 @@ describe('safeFit', () => { expect(marker.dispose).toHaveBeenCalled() }) + it('records the restored post-reflow pin when widening lowers baseY', () => { + const pane = createPane({ + proposedCols: 160, + proposedRows: 32, + terminalCols: 80, + terminalRows: 32 + }) + const activeBuffer = pane.terminal.buffer.active as { + viewportY: number + baseY: number + cursorY?: number + } + activeBuffer.viewportY = 42 + activeBuffer.baseY = 100 + activeBuffer.cursorY = 0 + const marker = { line: 42, isDisposed: false, dispose: vi.fn() } + ;(pane.terminal as unknown as { registerMarker: unknown }).registerMarker = vi.fn(() => marker) + markTerminalPinnedViewport(pane.terminal) + vi.mocked(pane.fitAddon.fit).mockImplementation(() => { + activeBuffer.baseY = 70 + activeBuffer.viewportY = 0 + marker.line = 30 + }) + + safeFit(pane) + activeBuffer.viewportY = 0 + vi.mocked(pane.terminal.scrollToLine).mockClear() + enforceTerminalCurrentScrollIntent(pane.terminal) + + expect(pane.terminal.scrollToLine).toHaveBeenLastCalledWith(30) + }) + + it('preserves a durable pin when the remounted fit buffer is still empty', () => { + const pane = createPane({ + proposedCols: 100, + proposedRows: 32, + terminalCols: 80, + terminalRows: 24 + }) + const activeBuffer = pane.terminal.buffer.active as { + viewportY: number + baseY: number + cursorY?: number + } + activeBuffer.viewportY = 42 + activeBuffer.baseY = 100 + activeBuffer.cursorY = 0 + markTerminalPinnedViewport(pane.terminal) + + activeBuffer.viewportY = 0 + activeBuffer.baseY = 0 + safeFit(pane) + + activeBuffer.viewportY = 0 + activeBuffer.baseY = 80 + vi.mocked(pane.terminal.scrollToLine).mockClear() + enforceTerminalCurrentScrollIntent(pane.terminal) + expect(pane.terminal.scrollToLine).toHaveBeenLastCalledWith(22) + }) + it('keeps a follow-output pane at the bottom through fit', () => { const pane = createPane({ proposedCols: 100, @@ -251,22 +376,236 @@ describe('safeFit', () => { expect(pane.terminal.scrollToBottom).toHaveBeenCalled() }) - it('does not throw when xterm rejects scroll restoration during layout', () => { + it('retries a transient dimensions failure before recording the post-fit pin', () => { + const frameCallbacks: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frameCallbacks.push(callback) + return frameCallbacks.length + }) const pane = createPane({ proposedCols: 100, proposedRows: 32, terminalCols: 120, terminalRows: 32 }) - const activeBuffer = pane.terminal.buffer.active as { viewportY: number; baseY: number } + const activeBuffer = pane.terminal.buffer.active as { + viewportY: number + baseY: number + cursorY?: number + } activeBuffer.viewportY = 42 activeBuffer.baseY = 100 - vi.mocked(pane.terminal.scrollToLine).mockImplementation(() => { - throw new TypeError("Cannot read properties of undefined (reading 'dimensions')") + activeBuffer.cursorY = 0 + const marker = { line: 42, isDisposed: false, dispose: vi.fn() } + ;(pane.terminal as unknown as { registerMarker: unknown }).registerMarker = vi.fn(() => marker) + markTerminalPinnedViewport(pane.terminal) + vi.mocked(pane.fitAddon.fit).mockImplementation(() => { + activeBuffer.baseY = 70 + activeBuffer.viewportY = 0 + marker.line = 30 }) + vi.mocked(pane.terminal.scrollToLine) + .mockImplementationOnce(() => { + throw new TypeError("Cannot read properties of undefined (reading 'dimensions')") + }) + .mockImplementation((line: number) => { + activeBuffer.viewportY = line + }) expect(() => safeFit(pane)).not.toThrow() expect(pane.fitAddon.fit).toHaveBeenCalledTimes(1) + expect(activeBuffer.viewportY).toBe(0) + expect(marker.dispose).not.toHaveBeenCalled() + + frameCallbacks.shift()?.(0) + expect(activeBuffer.viewportY).toBe(30) + expect(marker.dispose).toHaveBeenCalled() + + activeBuffer.viewportY = 0 + vi.mocked(pane.terminal.scrollToLine).mockClear() + enforceTerminalCurrentScrollIntent(pane.terminal) + expect(pane.terminal.scrollToLine).toHaveBeenLastCalledWith(30) + }) + + it('cancels a pending fit retry when snapshot replay starts', () => { + const frameCallbacks: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frameCallbacks.push(callback) + return frameCallbacks.length + }) + const pane = createPane({ + proposedCols: 100, + proposedRows: 32, + terminalCols: 120, + terminalRows: 32 + }) + const activeBuffer = pane.terminal.buffer.active as { + viewportY: number + baseY: number + cursorY?: number + } + activeBuffer.viewportY = 42 + activeBuffer.baseY = 100 + activeBuffer.cursorY = 0 + const marker = { line: 30, isDisposed: false, dispose: vi.fn() } + ;(pane.terminal as unknown as { registerMarker: unknown }).registerMarker = vi.fn(() => marker) + markTerminalPinnedViewport(pane.terminal) + vi.mocked(pane.fitAddon.fit).mockImplementation(() => { + activeBuffer.baseY = 70 + activeBuffer.viewportY = 0 + }) + vi.mocked(pane.terminal.scrollToLine).mockImplementationOnce(() => { + throw new TypeError("Cannot read properties of undefined (reading 'dimensions')") + }) + + safeFit(pane) + beginTerminalScrollIntentBufferRebuild(pane.terminal) + frameCallbacks.shift()?.(0) + + expect(pane.terminal.scrollToLine).toHaveBeenCalledTimes(1) + expect(marker.dispose).toHaveBeenCalledTimes(1) + endTerminalScrollIntentBufferRebuild(pane.terminal) + }) + + it('carries the original fit marker across another fit before retry', () => { + const frameCallbacks: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frameCallbacks.push(callback) + return frameCallbacks.length + }) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + const pane = createPane({ + proposedCols: 100, + proposedRows: 32, + terminalCols: 120, + terminalRows: 32 + }) + const activeBuffer = pane.terminal.buffer.active as { + viewportY: number + baseY: number + cursorY?: number + } + activeBuffer.viewportY = 42 + activeBuffer.baseY = 100 + activeBuffer.cursorY = 0 + const originalMarker = { line: 30, isDisposed: false, dispose: vi.fn() } + const replacementMarker = { line: 5, isDisposed: false, dispose: vi.fn() } + ;(pane.terminal as unknown as { registerMarker: unknown }).registerMarker = vi + .fn() + .mockReturnValueOnce(originalMarker) + .mockReturnValueOnce(replacementMarker) + vi.mocked(pane.fitAddon.fit).mockImplementation(() => { + activeBuffer.baseY = 70 + activeBuffer.viewportY = 0 + }) + vi.mocked(pane.terminal.scrollToLine) + .mockImplementationOnce(() => { + throw new TypeError("Cannot read properties of undefined (reading 'dimensions')") + }) + .mockImplementation((line: number) => { + activeBuffer.viewportY = line + }) + markTerminalPinnedViewport(pane.terminal) + + safeFit(pane) + safeFit(pane) + + expect(activeBuffer.viewportY).toBe(30) + expect(originalMarker.dispose).toHaveBeenCalledTimes(1) + expect(replacementMarker.dispose).toHaveBeenCalledTimes(1) + expect(frameCallbacks).toHaveLength(1) + }) + + it('resumes an exhausted dimensions retry on a same-grid reveal fit', () => { + const frameCallbacks: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frameCallbacks.push(callback) + return frameCallbacks.length + }) + const pane = createPane({ + proposedCols: 100, + proposedRows: 32, + terminalCols: 120, + terminalRows: 32 + }) + const activeBuffer = pane.terminal.buffer.active as { + viewportY: number + baseY: number + cursorY?: number + } + activeBuffer.viewportY = 42 + activeBuffer.baseY = 100 + activeBuffer.cursorY = 0 + const marker = { line: 30, isDisposed: false, dispose: vi.fn() } + ;(pane.terminal as unknown as { registerMarker: unknown }).registerMarker = vi.fn(() => marker) + vi.mocked(pane.fitAddon.fit).mockImplementation(() => { + activeBuffer.baseY = 70 + activeBuffer.viewportY = 0 + }) + vi.mocked(pane.terminal.scrollToLine).mockImplementation(() => { + throw new TypeError("Cannot read properties of undefined (reading 'dimensions')") + }) + markTerminalPinnedViewport(pane.terminal) + + safeFit(pane) + frameCallbacks.shift()?.(0) + frameCallbacks.shift()?.(0) + expect(marker.dispose).not.toHaveBeenCalled() + + vi.mocked(pane.terminal.scrollToLine).mockImplementation((line: number) => { + activeBuffer.viewportY = line + }) + ;(pane.terminal as unknown as { cols: number }).cols = 100 + safeFit(pane) + expect(activeBuffer.viewportY).toBe(30) + expect(marker.dispose).toHaveBeenCalledTimes(1) + }) + + it('releases a replacement marker when a resumed retry throws', () => { + const frameCallbacks: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frameCallbacks.push(callback) + return frameCallbacks.length + }) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + const pane = createPane({ + proposedCols: 100, + proposedRows: 32, + terminalCols: 120, + terminalRows: 32 + }) + const activeBuffer = pane.terminal.buffer.active as { + viewportY: number + baseY: number + cursorY?: number + } + activeBuffer.viewportY = 42 + activeBuffer.baseY = 100 + activeBuffer.cursorY = 0 + const originalMarker = { line: 30, isDisposed: false, dispose: vi.fn() } + const replacementMarker = { line: 5, isDisposed: false, dispose: vi.fn() } + ;(pane.terminal as unknown as { registerMarker: unknown }).registerMarker = vi + .fn() + .mockReturnValueOnce(originalMarker) + .mockReturnValueOnce(replacementMarker) + vi.mocked(pane.fitAddon.fit).mockImplementation(() => { + activeBuffer.baseY = 70 + activeBuffer.viewportY = 0 + }) + vi.mocked(pane.terminal.scrollToLine) + .mockImplementationOnce(() => { + throw new TypeError("Cannot read properties of undefined (reading 'dimensions')") + }) + .mockImplementationOnce(() => { + throw new Error('unexpected resumed restore failure') + }) + markTerminalPinnedViewport(pane.terminal) + + safeFit(pane) + expect(() => safeFit(pane)).not.toThrow() + + expect(originalMarker.dispose).toHaveBeenCalledTimes(1) + expect(replacementMarker.dispose).toHaveBeenCalledTimes(1) }) it('still refits when a split-scroll lock is active and the grid changed', () => { @@ -396,6 +735,73 @@ describe('safeFit', () => { expect(paneB.fitAddon.fit).toHaveBeenCalledTimes(1) expect(paneB.terminal.resize).not.toHaveBeenCalled() }) + + it('runs an authoritative continuation only after a deferred replay fit', async () => { + const pane = createPane({ + proposedCols: 100, + proposedRows: 32, + terminalCols: 80, + terminalRows: 24 + }) + vi.mocked(pane.fitAddon.fit).mockImplementation(() => { + pane.terminal.resize(100, 32) + }) + const observedDimensions: { cols: number; rows: number }[] = [] + beginTerminalScrollIntentBufferRebuild(pane.terminal) + + safeFitAndThen(pane, 'pty-resize', () => { + observedDimensions.push({ cols: pane.terminal.cols, rows: pane.terminal.rows }) + }) + + expect(pane.fitAddon.fit).not.toHaveBeenCalled() + expect(observedDimensions).toEqual([]) + endTerminalScrollIntentBufferRebuild(pane.terminal) + await Promise.resolve() + + expect(pane.fitAddon.fit).toHaveBeenCalledTimes(1) + expect(observedDimensions).toEqual([{ cols: 100, rows: 32 }]) + }) + + it('retains an authoritative continuation until a later measurable fit succeeds', async () => { + const pane = createPane({ + proposedCols: 100, + proposedRows: 32, + terminalCols: 80, + terminalRows: 24 + }) + vi.mocked(pane.fitAddon.proposeDimensions).mockReturnValue(undefined) + const continuation = vi.fn() + + const pending = safeFitAndThen(pane, 'pty-resize', continuation) + + expect(continuation).not.toHaveBeenCalled() + vi.mocked(pane.fitAddon.proposeDimensions).mockReturnValue({ cols: 100, rows: 32 }) + safeFit(pane) + + await expect(pending.completion).resolves.toBe(true) + expect(continuation).toHaveBeenCalledTimes(1) + }) + + it('cancels an authoritative fit continuation disposed before its post-replay microtask', async () => { + const pane = createPane({ + proposedCols: 100, + proposedRows: 32, + terminalCols: 80, + terminalRows: 24 + }) + const continuation = vi.fn() + beginTerminalScrollIntentBufferRebuild(pane.terminal) + const pending = safeFitAndThen(pane, 'pty-resize', continuation) + + endTerminalScrollIntentBufferRebuild(pane.terminal) + cancelTerminalScrollIntentBufferRebuildCompletions(pane.terminal) + cancelPendingSafeFitContinuations(pane) + await Promise.resolve() + + expect(pane.fitAddon.fit).not.toHaveBeenCalled() + expect(continuation).not.toHaveBeenCalled() + await expect(pending.completion).resolves.toBe(false) + }) }) describe('equalizePaneSplitSizes', () => { diff --git a/src/renderer/src/lib/pane-manager/pane-tree-ops.ts b/src/renderer/src/lib/pane-manager/pane-tree-ops.ts index d069244cf4f..833eb1ff822 100644 --- a/src/renderer/src/lib/pane-manager/pane-tree-ops.ts +++ b/src/renderer/src/lib/pane-manager/pane-tree-ops.ts @@ -6,16 +6,15 @@ import type { PaneStyleOptions } from './pane-manager-types' import { createDivider, disposeDivider } from './pane-divider' -import { getFitOverrideForPty } from './mobile-fit-overrides' import { disposeWebgl, attachWebgl } from './pane-webgl-renderer' -import { - captureTerminalWriteScrollIntent, - enforceTerminalWriteScrollIntent, - syncTerminalScrollIntentFromViewport -} from './terminal-scroll-intent' -import { captureScrollState, releaseScrollStateMarker, restoreScrollState } from './pane-scroll' -import type { ScrollState } from './pane-manager-types' +import { safeFit } from './pane-fit' +export { + cancelPendingSafeFitContinuations, + safeFit, + safeFitAndThen, + type SafeFitContinuationHandle +} from './pane-fit' export { captureScrollState, restoreScrollState } from './pane-scroll' // --------------------------------------------------------------------------- @@ -33,111 +32,6 @@ type TreeOpsCallbacks = { requestPaneReparentFrame?: (callback: FrameRequestCallback) => void } -const MIN_PANE_FIT_WIDTH_PX = 48 -const MIN_PANE_FIT_HEIGHT_PX = 24 -const MIN_PANE_FIT_COLS = 8 -const MIN_PANE_FIT_ROWS = 4 - -function getProposedDimensions(pane: ManagedPane): { cols: number; rows: number } | null { - try { - return pane.fitAddon.proposeDimensions() ?? null - } catch { - return null - } -} - -function canMeasurePaneForFit(pane: ManagedPane): boolean { - const measure = pane.container.getBoundingClientRect - if (typeof measure === 'function') { - const rect = measure.call(pane.container) - if (rect.width < MIN_PANE_FIT_WIDTH_PX || rect.height < MIN_PANE_FIT_HEIGHT_PX) { - return false - } - } - const dims = getProposedDimensions(pane) - if (!dims) { - return false - } - // Why: worktree switches can briefly measure a near-zero overlay before - // fallback positioning lands. Fitting there pins the PTY at ~2 cols until - // the next user-driven resize. - return dims.cols >= MIN_PANE_FIT_COLS && dims.rows >= MIN_PANE_FIT_ROWS -} - -function canPreserveScrollIntentForFit(pane: ManagedPane): boolean { - // Why: split reparent has its own delayed restore; restoring here can fight that timer. - return !( - 'pendingSplitScrollState' in pane && (pane as ManagedPaneInternal).pendingSplitScrollState - ) -} - -export function safeFit(pane: ManagedPane): void { - if (!canMeasurePaneForFit(pane)) { - return - } - let scrollIntent = null as ReturnType - let pinnedScrollState: ScrollState | null = null - let shouldRestoreScroll = false - const captureScrollForFit = (): void => { - scrollIntent = captureTerminalWriteScrollIntent(pane.terminal) - // Why: fit can reflow and renumber every buffer row; a marker tracks the - // pinned content itself, while a numeric line would point elsewhere after. - pinnedScrollState = - scrollIntent?.kind === 'pinnedViewport' ? captureScrollState(pane.terminal) : null - shouldRestoreScroll = true - } - try { - // Why: when a mobile client has resized this PTY to phone dimensions, - // the desktop must keep xterm at those dimensions instead of fitting to - // the desktop pane geometry. This prevents desktop auto-fit from undoing - // the mobile resize. Uses data-pty-id (set by bindPanePtyId) to look up - // the override by ptyId directly, avoiding pane ID collisions across tabs. - const ptyId = pane.container.dataset.ptyId - const override = ptyId ? getFitOverrideForPty(ptyId) : null - if (override) { - if (pane.terminal.cols !== override.cols || pane.terminal.rows !== override.rows) { - if (canPreserveScrollIntentForFit(pane)) { - captureScrollForFit() - } - pane.terminal.resize(override.cols, override.rows) - } - return - } - - const dims = getProposedDimensions(pane) - if (dims && dims.cols === pane.terminal.cols && dims.rows === pane.terminal.rows) { - // Why: divider drags fire refits every frame, but most frames do not - // cross a cell boundary. Skipping those avoids FitAddon.clear()+refresh() - // churn, which was causing visible terminal blinking while resizing. - return - } - if (canPreserveScrollIntentForFit(pane)) { - captureScrollForFit() - } - pane.fitAddon.fit() - } catch { - // Container may not have dimensions yet - } finally { - if (shouldRestoreScroll) { - try { - if (pinnedScrollState) { - restoreScrollState(pane.terminal, pinnedScrollState) - syncTerminalScrollIntentFromViewport(pane.terminal) - } else { - enforceTerminalWriteScrollIntent(pane.terminal, scrollIntent) - } - } catch { - // Why: xterm can temporarily expose a terminal whose renderer has not - // initialized dimensions yet during SSH reattach/layout. Fit is best-effort. - } finally { - if (pinnedScrollState) { - releaseScrollStateMarker(pinnedScrollState) - } - } - } - } -} - export function fitAllPanesInternal(panes: Map): void { for (const pane of panes.values()) { safeFit(pane) diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts b/src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts index 91b95f2caf2..f2a07f8751f 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts @@ -3,6 +3,10 @@ import type { ManagedPaneInternal } from './pane-manager-types' import { disposePane } from './pane-lifecycle' import { suspendPaneRendering } from './pane-rendering-control' import { disposeWebgl } from './pane-webgl-renderer' +import { + beginTerminalScrollIntentBufferRebuild, + endTerminalScrollIntentBufferRebuild +} from './terminal-scroll-intent-rebuild' function createPane( overrides: Partial> = {} @@ -14,11 +18,17 @@ function createPane( stablePaneId: leafId, terminal: { element: null, + cols: 80, rows: 24, + buffer: { active: { type: 'normal', viewportY: 0, baseY: 0 } }, refresh: vi.fn(), + resize: vi.fn(), dispose: vi.fn() } as never, - container: {} as never, + container: { + dataset: {}, + getBoundingClientRect: () => ({ width: 800, height: 600 }) + } as never, xtermContainer: {} as never, linkTooltip: {} as never, terminalGpuAcceleration: 'off', @@ -28,6 +38,7 @@ function createPane( hasComplexScriptOutput: false, fitAddon: { fit: vi.fn(), + proposeDimensions: vi.fn(() => ({ cols: 100, rows: 24 })), dispose: vi.fn() } as never, fitResizeObserver: null, @@ -66,6 +77,26 @@ describe('pane WebGL refresh lifecycle', () => { expect(pane.pendingWebglRefreshRafId).toBe(29) }) + it('defers the DOM-renderer refit until structural replay completes', async () => { + const refreshFrame: { current: FrameRequestCallback | null } = { current: null } + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + refreshFrame.current = callback + return 29 + }) + const pane = createPane() + beginTerminalScrollIntentBufferRebuild(pane.terminal) + + disposeWebgl(pane, { refreshDimensions: true }) + refreshFrame.current?.(0) + expect(pane.fitAddon.fit).not.toHaveBeenCalled() + expect(pane.terminal.refresh).not.toHaveBeenCalled() + + endTerminalScrollIntentBufferRebuild(pane.terminal) + await Promise.resolve() + expect(pane.fitAddon.fit).toHaveBeenCalledTimes(1) + expect(pane.terminal.refresh).toHaveBeenCalledTimes(1) + }) + it('actively releases the xterm WebGL context before disposing the addon', () => { const loseContext = vi.fn() const canvas = { width: 120, height: 40 } diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts index 7ae295b4a9d..790ce3a74d7 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts @@ -6,6 +6,7 @@ import { getTerminalWebglAutoDecision, resetTerminalWebglAutoDecision } from './terminal-webgl-auto-policy' +import { safeFitAndThen } from './pane-fit' export const ENABLE_WEBGL_RENDERER = true let suggestedRendererType: 'dom' | undefined @@ -90,8 +91,11 @@ export function disposeWebgl( pane.pendingWebglRefreshRafId = requestAnimationFrame(() => { pane.pendingWebglRefreshRafId = null try { - pane.fitAddon.fit() - pane.terminal.refresh(0, pane.terminal.rows - 1) + // Why: context loss can coincide with snapshot parsing; refresh only + // after the replay-aware fit has authoritative renderer dimensions. + safeFitAndThen(pane, 'webgl-fallback-refresh', () => { + pane.terminal.refresh(0, pane.terminal.rows - 1) + }) } catch { /* ignore — pane may have been disposed in the meantime */ } diff --git a/src/renderer/src/lib/pane-manager/terminal-reflow-scroll-anchor.ts b/src/renderer/src/lib/pane-manager/terminal-reflow-scroll-anchor.ts new file mode 100644 index 00000000000..c7ed2bcce17 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-reflow-scroll-anchor.ts @@ -0,0 +1,137 @@ +import type { Terminal } from '@xterm/xterm' + +type ReflowLineReader = { + getCellMetrics: (lineY: number, column: number) => { code: number; width: number } | undefined + isWrapped: (lineY: number) => boolean +} + +type TerminalWithInternalBufferLines = Terminal & { + _core?: { + _bufferService?: { + buffer?: { + lines?: { + get: (lineY: number) => + | { + getCodePoint: (column: number) => number + getWidth: (column: number) => number + isWrapped: boolean + length: number + } + | undefined + } + } + } + } +} + +export function captureLogicalLineAnchor( + terminal: Terminal, + viewportY: number +): { cellOffset: number; lineY: number } | undefined { + const buf = terminal.buffer.active + if (typeof buf.getLine !== 'function' || shouldKeepPhysicalResizeAnchor(terminal)) { + return undefined + } + const lines = createReflowLineReader(terminal) + let lineY = viewportY + while (lineY > 0 && lines.isWrapped(lineY)) { + lineY -= 1 + } + const cursorLineY = buf.baseY + buf.cursorY + if (terminal.options?.reflowCursorLine !== true && lineContainsLine(lines, lineY, cursorLineY)) { + return undefined + } + let cellOffset = 0 + for (let currentLineY = lineY; currentLineY < viewportY; currentLineY += 1) { + cellOffset += readReflowedRowCellCount(terminal, lines, currentLineY) + } + return { cellOffset, lineY } +} + +function shouldKeepPhysicalResizeAnchor(terminal: Terminal): boolean { + const windowsPty = terminal.options?.windowsPty + if (!windowsPty?.buildNumber) { + return false + } + // Why: xterm disables reflow only when an explicit legacy build is present; + // Orca's backend-only fallback for an unknown Windows build still reflows. + return windowsPty.backend !== 'conpty' || windowsPty.buildNumber < 21376 +} + +function lineContainsLine( + lines: ReflowLineReader, + logicalStartY: number, + targetY: number +): boolean { + if (targetY < logicalStartY) { + return false + } + for (let lineY = logicalStartY + 1; lineY <= targetY; lineY += 1) { + if (!lines.isWrapped(lineY)) { + return false + } + } + return true +} + +export function resolveLogicalCellOffsetLine( + terminal: Terminal, + logicalStartY: number, + cellOffset: number +): number { + const buf = terminal.buffer.active + const lines = createReflowLineReader(terminal) + let lineY = logicalStartY + let remainingCells = cellOffset + while (lineY < buf.baseY && lines.isWrapped(lineY + 1)) { + const rowCells = readReflowedRowCellCount(terminal, lines, lineY) + if (remainingCells < rowCells) { + break + } + remainingCells -= rowCells + lineY += 1 + } + return lineY +} + +function readReflowedRowCellCount( + terminal: Terminal, + lines: ReflowLineReader, + lineY: number +): number { + const cols = Math.max(terminal.cols, 1) + const lastCell = lines.getCellMetrics(lineY, cols - 1) + const nextFirstCell = lines.getCellMetrics(lineY + 1, 0) + // Why: xterm wraps a width-2 glyph one cell early when only the last column + // remains. That placeholder is not part of the logical cell offset. + return lastCell?.code === 0 && lastCell.width === 1 && nextFirstCell?.width === 2 + ? cols - 1 + : cols +} + +function createReflowLineReader(terminal: Terminal): ReflowLineReader { + const internalLines = (terminal as TerminalWithInternalBufferLines)._core?._bufferService?.buffer + ?.lines + if (internalLines) { + // Why: public getLine/getCell allocate wrapper objects per row/cell. The + // pinned xterm core exposes the same active lines without resize-path GC. + return { + isWrapped: (lineY) => internalLines.get(lineY)?.isWrapped ?? false, + getCellMetrics: (lineY, column) => { + const line = internalLines.get(lineY) + if (!line || column < 0 || column >= line.length) { + return undefined + } + return { code: line.getCodePoint(column), width: line.getWidth(column) } + } + } + } + const buffer = terminal.buffer.active + return { + isWrapped: (lineY) => buffer.getLine(lineY)?.isWrapped ?? false, + getCellMetrics: (lineY, column) => { + const cell = buffer.getLine(lineY)?.getCell(column) + return cell ? { code: cell.getCode(), width: cell.getWidth() } : undefined + } + } +} diff --git a/src/renderer/src/lib/pane-manager/terminal-scroll-buffer-snapshot.ts b/src/renderer/src/lib/pane-manager/terminal-scroll-buffer-snapshot.ts new file mode 100644 index 00000000000..730adc5a97b --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-scroll-buffer-snapshot.ts @@ -0,0 +1,53 @@ +export type TerminalScrollBufferType = 'normal' | 'alternate' + +export type TerminalScrollBufferTarget = { + buffer?: { + active?: { + type?: string + viewportY?: number + baseY?: number + } + } +} + +export type TerminalScrollBufferSnapshot = { + bufferType: TerminalScrollBufferType + viewportY: number + baseY: number +} + +export function readTerminalScrollBufferSnapshot( + terminal: TerminalScrollBufferTarget +): TerminalScrollBufferSnapshot | null { + const buffer = terminal.buffer?.active + const viewportY = buffer?.viewportY + const baseY = buffer?.baseY + if (typeof viewportY !== 'number' || typeof baseY !== 'number') { + return null + } + return { + bufferType: buffer?.type === 'alternate' ? 'alternate' : 'normal', + viewportY, + baseY + } +} + +export function isTerminalViewportAtBottom(viewportY: number, baseY: number): boolean { + return viewportY >= baseY +} + +export function clampTerminalViewportY(viewportY: number, baseY: number): number { + return Math.max(0, Math.min(viewportY, baseY)) +} + +export function safeTerminalScrollCall(scroll: () => void): boolean { + try { + scroll() + return true + } catch (err) { + if (err instanceof TypeError && /dimensions/.test(err.message)) { + return false + } + throw err + } +} diff --git a/src/renderer/src/lib/pane-manager/terminal-scroll-intent-dom-tracking.ts b/src/renderer/src/lib/pane-manager/terminal-scroll-intent-dom-tracking.ts new file mode 100644 index 00000000000..9545615705c --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-scroll-intent-dom-tracking.ts @@ -0,0 +1,254 @@ +import type { IDisposable } from '@xterm/xterm' +import { + bindTerminalScrollIntentKey, + enforceTerminalCurrentScrollIntent, + getTerminalScrollIntentKind, + isTerminalScrollIntentKeyBindingCurrent, + markTerminalPinnedViewport, + syncTerminalScrollIntentFromViewport +} from './terminal-scroll-intent' +import { syncTerminalScrollIntentSoon } from './terminal-scroll-intent-settle' +import type { TerminalScrollIntentKey, TerminalScrollIntentTarget } from './terminal-scroll-intent' +import { + isTerminalScrollIntentRebuildInFlight, + onTerminalScrollIntentBufferRebuildComplete +} from './terminal-scroll-intent-rebuild' + +const XTERM_SCROLL_INTENT_POINTER_TARGET_CLASSES = [ + 'xterm-viewport', + 'xterm-scrollbar', + 'xterm-slider' +] as const +const XTERM_SCROLL_INTENT_POINTER_TARGET_SELECTOR = XTERM_SCROLL_INTENT_POINTER_TARGET_CLASSES.map( + (className) => `.${className}` +).join(',') + +function isTerminalScrollIntentPointerTarget(target: EventTarget | null): target is Element { + if (typeof Element === 'undefined' || !(target instanceof Element)) { + return false + } + // xterm's custom scrollbar uses separate thumb/track nodes from the viewport. + return target.closest(XTERM_SCROLL_INTENT_POINTER_TARGET_SELECTOR) !== null +} + +type TerminalWithOnData = { + onData?: (listener: (data: string) => void) => { dispose?: unknown } | undefined + _core?: { + coreService?: { + onUserInput?: (listener: () => void) => { dispose?: unknown } | undefined + } + } +} + +// Mouse reports (SGR "\x1b[ boolean, + captureInteractionRevision: () => number, + resyncUserInput: (interactionRevision: number) => void +): { dispose: () => void } | null { + const terminalWithInput = terminal as TerminalWithOnData + const onData = terminalWithInput.onData + if (typeof onData !== 'function') { + return null + } + const onUserInput = terminalWithInput._core?.coreService?.onUserInput + let pendingUserInputRevision: number | null = null + try { + const dataSubscription = onData((data: string) => { + if (isMouseReportInput(data)) { + pendingUserInputRevision = null + if (isActive() && getTerminalScrollIntentKind(terminal) === 'pinnedViewport') { + // Why: xterm treats mouse reports as user input and scrolls bottom + // before onData. Restore the reading position before output follows. + enforceTerminalCurrentScrollIntent(terminal) + } + return + } + if (typeof onUserInput === 'function') { + const interactionRevision = pendingUserInputRevision + pendingUserInputRevision = null + if (interactionRevision !== null && isActive()) { + resyncUserInput(interactionRevision) + } + } else if (isActive()) { + // Compatibility fallback for test doubles or an unexpected xterm + // shape; pinned production xterm uses onUserInput below. + resyncUserInput(captureInteractionRevision()) + } + }) + const userInputSubscription = onUserInput?.(() => { + // Why: xterm emits onUserInput immediately before its matching onData. + // Reserve order here, then let onData classify typing versus mouse. + pendingUserInputRevision = captureInteractionRevision() + }) + return { + dispose: () => { + if (dataSubscription && typeof dataSubscription.dispose === 'function') { + dataSubscription.dispose() + } + if (userInputSubscription && typeof userInputSubscription.dispose === 'function') { + userInputSubscription.dispose() + } + } + } + } catch { + return null + } +} + +/** Wires the user-driven scroll signals (wheel, scrollbar pointer drags) that + * are allowed to change a terminal's scroll intent. Output-driven scroll + * events deliberately do not update intent (see terminal-scroll-intent.ts). */ +export function attachTerminalScrollIntentTracking( + terminal: TerminalScrollIntentTarget, + host: HTMLElement, + intentKey?: TerminalScrollIntentKey +): IDisposable { + if (!bindTerminalScrollIntentKey(terminal, intentKey)) { + syncTerminalScrollIntentFromViewport(terminal) + } + let disposed = false + const isActive = (): boolean => !disposed + let pointerScrollActive = false + let cancelPostRebuildSync: (() => void) | null = null + let nextInteractionRevision = 0 + let latestCommittedInteractionRevision = 0 + let postRebuildSync: { revision: number; mode: 'sample' | 'preservePinnedAtBottom' } | null = null + const captureInteractionRevision = (): number => (nextInteractionRevision += 1) + + const syncFromViewportOrAfterRebuild = ( + mode: 'sample' | 'preservePinnedAtBottom' = 'sample', + interactionRevision = captureInteractionRevision() + ): boolean => { + if (interactionRevision < latestCommittedInteractionRevision) { + return false + } + latestCommittedInteractionRevision = interactionRevision + if (!isTerminalScrollIntentRebuildInFlight(terminal)) { + syncTerminalScrollIntentFromViewport(terminal, { allowBufferShrink: true }) + return true + } + postRebuildSync = { revision: interactionRevision, mode } + if (!cancelPostRebuildSync) { + cancelPostRebuildSync = onTerminalScrollIntentBufferRebuildComplete(terminal, (completed) => { + cancelPostRebuildSync = null + const pendingSync = postRebuildSync + postRebuildSync = null + if ( + completed && + isActive() && + pendingSync && + pendingSync.revision === latestCommittedInteractionRevision + ) { + // Why: wheel/scrollbar movement during replay must be sampled from + // the completed buffer, never from its transient cleared rows. + const preservePinnedAtBottom = pendingSync.mode === 'preservePinnedAtBottom' + if ( + preservePinnedAtBottom && + getTerminalScrollIntentKind(terminal) !== 'pinnedViewport' + ) { + markTerminalPinnedViewport(terminal) + } + syncTerminalScrollIntentFromViewport(terminal, { + allowBufferShrink: true, + preservePinnedAtBottom + }) + if (preservePinnedAtBottom) { + // Why: an upward wheel or scrollbar gesture against the cleared 0/0 + // buffer must not erase the durable pin. Settle after restoration so + // a real move wins and a no-op gesture can still return to follow. + syncTerminalScrollIntentSoon(terminal, { + allowBufferShrink: true, + preservePinnedAtBottom: true, + shouldSync: isActive + }) + } + } + }) + } + return false + } + const userInputResync = subscribeScrollIntentUserInputResync( + terminal, + isActive, + captureInteractionRevision, + (interactionRevision) => syncFromViewportOrAfterRebuild('sample', interactionRevision) + ) + + const onWheel = (event: WheelEvent): void => { + if (!syncFromViewportOrAfterRebuild(event.deltaY < 0 ? 'preservePinnedAtBottom' : 'sample')) { + return + } + if (event.deltaY < 0) { + markTerminalPinnedViewport(terminal) + syncTerminalScrollIntentSoon(terminal, { + preservePinnedAtBottom: true, + shouldSync: isActive + }) + return + } + syncTerminalScrollIntentSoon(terminal, { shouldSync: isActive }) + } + + const onPointerDown = (event: PointerEvent): void => { + pointerScrollActive = isTerminalScrollIntentPointerTarget(event.target) + } + + const onPointerDone = (): void => { + if (!pointerScrollActive) { + return + } + pointerScrollActive = false + syncFromViewportOrAfterRebuild('preservePinnedAtBottom') + } + + const onScroll = (): void => { + if (pointerScrollActive) { + syncFromViewportOrAfterRebuild('preservePinnedAtBottom') + } + } + + host.addEventListener('wheel', onWheel, { capture: true, passive: true }) + host.addEventListener('pointerdown', onPointerDown, true) + host.addEventListener('scroll', onScroll, true) + globalThis.addEventListener?.('pointerup', onPointerDone, true) + globalThis.addEventListener?.('pointercancel', onPointerDone, true) + return { + dispose: () => { + // Why: native pinned output can grow baseY without a DOM scroll event; + // persist that geometry before remount, but never let an old instance + // overwrite a successor already bound to the same leaf key. + if (isTerminalScrollIntentKeyBindingCurrent(terminal)) { + syncTerminalScrollIntentFromViewport(terminal) + } + disposed = true + cancelPostRebuildSync?.() + cancelPostRebuildSync = null + postRebuildSync = null + userInputResync?.dispose() + host.removeEventListener('wheel', onWheel, true) + host.removeEventListener('pointerdown', onPointerDown, true) + host.removeEventListener('scroll', onScroll, true) + globalThis.removeEventListener?.('pointerup', onPointerDone, true) + globalThis.removeEventListener?.('pointercancel', onPointerDone, true) + } + } +} diff --git a/src/renderer/src/lib/pane-manager/terminal-scroll-intent-input-resync.test.ts b/src/renderer/src/lib/pane-manager/terminal-scroll-intent-input-resync.test.ts new file mode 100644 index 00000000000..c3739b5c470 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-scroll-intent-input-resync.test.ts @@ -0,0 +1,298 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { attachTerminalScrollIntentTracking } from './terminal-scroll-intent-dom-tracking' +import { + captureTerminalStructuralScrollIntent, + enforceTerminalCurrentScrollIntent, + getTerminalScrollIntentKind, + markTerminalPinnedViewport, + restoreTerminalStructuralScrollIntent +} from './terminal-scroll-intent' +import { + beginTerminalScrollIntentBufferRebuild, + endTerminalScrollIntentBufferRebuild +} from './terminal-scroll-intent-rebuild' + +function createTerminal(viewportY: number, baseY: number) { + const terminal = { + buffer: { active: { type: 'normal' as const, viewportY, baseY } }, + scrollToBottom: vi.fn(() => { + terminal.buffer.active.viewportY = terminal.buffer.active.baseY + }), + scrollToLine: vi.fn((line: number) => { + terminal.buffer.active.viewportY = line + }), + onData: undefined as + | ((listener: (data: string) => void) => { dispose: () => void }) + | undefined, + _core: undefined as + | { coreService: { onUserInput: (listener: () => void) => { dispose: () => void } } } + | undefined + } + return terminal +} + +class TestElement extends EventTarget { + parentElement: TestElement | null = null + readonly classList = { + contains: (className: string): boolean => this.className.split(/\s+/).includes(className) + } + + constructor(public className = '') { + super() + } + + closest(selector: string): TestElement | null { + for (const candidate of selector.split(',')) { + const trimmed = candidate.trim() + if (trimmed.startsWith('.') && this.classList.contains(trimmed.slice(1))) { + return this + } + } + return this.parentElement?.closest(selector) ?? null + } +} + +function createTerminalWithInputCapture(viewportY: number, baseY: number) { + const capturedInput: { listener: ((data: string) => void) | null } = { listener: null } + const capturedUserInput: { listener: (() => void) | null } = { listener: null } + const terminal = createTerminal(viewportY, baseY) + terminal.onData = (listener: (data: string) => void) => { + capturedInput.listener = listener + return { dispose: vi.fn() } + } + terminal._core = { + coreService: { + onUserInput: (listener: () => void) => { + capturedUserInput.listener = listener + return { dispose: vi.fn() } + } + } + } + return { terminal, capturedInput, capturedUserInput } +} + +afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +describe('terminal scroll-intent input resync', () => { + it('heals a stale pin when typing scrolls the terminal to the bottom', () => { + vi.stubGlobal('requestAnimationFrame', () => 0) + vi.stubGlobal('Element', TestElement) + const { terminal, capturedInput, capturedUserInput } = createTerminalWithInputCapture(42, 100) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + + markTerminalPinnedViewport(terminal) + terminal.buffer.active.viewportY = terminal.buffer.active.baseY + capturedUserInput.listener?.() + capturedInput.listener?.('a') + + expect(getTerminalScrollIntentKind(terminal)).toBe('followOutput') + disposable.dispose() + }) + + it('heals a stale pre-reflow pin when typing reaches a shorter buffer bottom', () => { + vi.stubGlobal('requestAnimationFrame', () => 0) + vi.stubGlobal('Element', TestElement) + const { terminal, capturedInput, capturedUserInput } = createTerminalWithInputCapture(42, 100) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + + markTerminalPinnedViewport(terminal) + terminal.buffer.active.baseY = 70 + terminal.buffer.active.viewportY = 70 + capturedUserInput.listener?.() + capturedInput.listener?.('a') + + expect(getTerminalScrollIntentKind(terminal)).toBe('followOutput') + disposable.dispose() + }) + + it('keeps a real pin when app-consumed input does not move the viewport', () => { + vi.stubGlobal('requestAnimationFrame', () => 0) + vi.stubGlobal('Element', TestElement) + const { terminal, capturedInput, capturedUserInput } = createTerminalWithInputCapture(42, 100) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + + markTerminalPinnedViewport(terminal) + capturedUserInput.listener?.() + capturedInput.listener?.('\x1b[5~') + + expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport') + terminal.buffer.active.viewportY = 0 + enforceTerminalCurrentScrollIntent(terminal) + expect(terminal.scrollToLine).toHaveBeenLastCalledWith(42) + disposable.dispose() + }) + + it('does not reclassify a pin from mouse reports even when they scroll to bottom', () => { + vi.stubGlobal('requestAnimationFrame', () => 0) + vi.stubGlobal('Element', TestElement) + const { terminal, capturedInput, capturedUserInput } = createTerminalWithInputCapture(42, 100) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + + markTerminalPinnedViewport(terminal) + terminal.buffer.active.viewportY = terminal.buffer.active.baseY + capturedUserInput.listener?.() + capturedInput.listener?.('\x1b[<35;10;5M') + + expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport') + expect(terminal.buffer.active.viewportY).toBe(42) + expect(terminal.scrollToLine).toHaveBeenLastCalledWith(42) + disposable.dispose() + }) + + it('does not let a focus reply make a following mouse report reclassify the pin', () => { + vi.stubGlobal('requestAnimationFrame', () => 0) + vi.stubGlobal('Element', TestElement) + const { terminal, capturedInput, capturedUserInput } = createTerminalWithInputCapture(42, 100) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + + markTerminalPinnedViewport(terminal) + capturedInput.listener?.('\x1b[I') + terminal.buffer.active.viewportY = terminal.buffer.active.baseY + capturedUserInput.listener?.() + capturedInput.listener?.('\x1b[<0;10;5M') + + expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport') + disposable.dispose() + }) + + it('ignores a parser reply that has no matching user-input signal', () => { + vi.stubGlobal('requestAnimationFrame', () => 0) + vi.stubGlobal('Element', TestElement) + const { terminal, capturedInput } = createTerminalWithInputCapture(42, 100) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + + markTerminalPinnedViewport(terminal) + terminal.buffer.active.viewportY = terminal.buffer.active.baseY + capturedInput.listener?.('\x1b[1;1R') + + expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport') + disposable.dispose() + }) + + it('does not apply input resync after tracking is disposed', () => { + vi.stubGlobal('requestAnimationFrame', () => 0) + vi.stubGlobal('Element', TestElement) + const { terminal, capturedInput, capturedUserInput } = createTerminalWithInputCapture(42, 100) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + + markTerminalPinnedViewport(terminal) + capturedUserInput.listener?.() + disposable.dispose() + terminal.buffer.active.viewportY = terminal.buffer.active.baseY + capturedInput.listener?.('a') + + expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport') + }) + + it('defers real typing intent until a snapshot rebuild completes', () => { + vi.stubGlobal('requestAnimationFrame', () => 0) + vi.stubGlobal('Element', TestElement) + const { terminal, capturedInput, capturedUserInput } = createTerminalWithInputCapture(42, 100) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + markTerminalPinnedViewport(terminal) + const staleIntent = captureTerminalStructuralScrollIntent(terminal) + beginTerminalScrollIntentBufferRebuild(terminal) + + terminal.buffer.active.baseY = 5 + terminal.buffer.active.viewportY = 5 + capturedUserInput.listener?.() + capturedInput.listener?.('a') + terminal.buffer.active.baseY = 200 + terminal.buffer.active.viewportY = 200 + endTerminalScrollIntentBufferRebuild(terminal) + restoreTerminalStructuralScrollIntent(terminal, staleIntent, { restoreBy: 'bottomOffset' }) + + expect(getTerminalScrollIntentKind(terminal)).toBe('followOutput') + disposable.dispose() + }) + + it('lets later typing supersede a wheel-up pin during snapshot replay', () => { + vi.stubGlobal('requestAnimationFrame', () => 0) + vi.stubGlobal('Element', TestElement) + const { terminal, capturedInput, capturedUserInput } = createTerminalWithInputCapture(80, 100) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + markTerminalPinnedViewport(terminal) + const staleIntent = captureTerminalStructuralScrollIntent(terminal) + beginTerminalScrollIntentBufferRebuild(terminal) + + terminal.buffer.active.viewportY = 0 + terminal.buffer.active.baseY = 0 + const wheel = new Event('wheel') as WheelEvent + Object.defineProperty(wheel, 'deltaY', { value: -10 }) + host.dispatchEvent(wheel) + capturedUserInput.listener?.() + capturedInput.listener?.('a') + terminal.buffer.active.viewportY = 200 + terminal.buffer.active.baseY = 200 + endTerminalScrollIntentBufferRebuild(terminal) + restoreTerminalStructuralScrollIntent(terminal, staleIntent, { restoreBy: 'bottomOffset' }) + + expect(getTerminalScrollIntentKind(terminal)).toBe('followOutput') + disposable.dispose() + }) + + it('ignores parser auto-replies while a snapshot rebuild is partial', () => { + vi.stubGlobal('requestAnimationFrame', () => 0) + vi.stubGlobal('Element', TestElement) + const { terminal, capturedInput } = createTerminalWithInputCapture(42, 100) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + markTerminalPinnedViewport(terminal) + const intent = captureTerminalStructuralScrollIntent(terminal) + beginTerminalScrollIntentBufferRebuild(terminal) + + terminal.buffer.active.baseY = 5 + terminal.buffer.active.viewportY = 5 + capturedInput.listener?.('\x1b[1;1R') + terminal.buffer.active.baseY = 200 + terminal.buffer.active.viewportY = 200 + endTerminalScrollIntentBufferRebuild(terminal) + restoreTerminalStructuralScrollIntent(terminal, intent, { restoreBy: 'bottomOffset' }) + + expect(terminal.scrollToLine).toHaveBeenLastCalledWith(142) + disposable.dispose() + }) + + it('supports bottom-offset restore for structural buffer rebuilds', () => { + const terminal = createTerminal(550, 600) + markTerminalPinnedViewport(terminal) + const snapshot = captureTerminalStructuralScrollIntent(terminal) + terminal.buffer.active.baseY = 80 + terminal.buffer.active.viewportY = 80 + + restoreTerminalStructuralScrollIntent(terminal, snapshot, { restoreBy: 'bottomOffset' }) + + expect(terminal.scrollToLine).toHaveBeenLastCalledWith(30) + expect(terminal.buffer.active.viewportY).toBe(30) + }) + + it('retains the intended bottom-offset pin when renderer dimensions reject restore', () => { + const terminal = createTerminal(80, 100) + markTerminalPinnedViewport(terminal) + const snapshot = captureTerminalStructuralScrollIntent(terminal) + terminal.buffer.active.baseY = 200 + terminal.buffer.active.viewportY = 200 + terminal.scrollToLine.mockImplementationOnce(() => { + throw new TypeError("Cannot read properties of undefined (reading 'dimensions')") + }) + + restoreTerminalStructuralScrollIntent(terminal, snapshot, { restoreBy: 'bottomOffset' }) + expect(terminal.buffer.active.viewportY).toBe(200) + + terminal.buffer.active.viewportY = 0 + enforceTerminalCurrentScrollIntent(terminal) + expect(terminal.scrollToLine).toHaveBeenLastCalledWith(180) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/terminal-scroll-intent-rebuild.ts b/src/renderer/src/lib/pane-manager/terminal-scroll-intent-rebuild.ts new file mode 100644 index 00000000000..3de8e79a9f1 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-scroll-intent-rebuild.ts @@ -0,0 +1,125 @@ +// Why: buffer rebuilds (snapshot replay clear + rewrite) parse asynchronously. +// Until the rebuild's bytes have parsed, viewportY/baseY describe a transient +// half-cleared buffer; any intent capture/enforce latched from it pins the +// terminal at line 0. Callers bracket the rebuild and re-apply intent once +// after parse (see terminal-scroll-intent.ts). +const terminalScrollIntentRebuilds = new WeakMap() +const terminalScrollIntentRebuildCompletions = new WeakMap< + object, + Set<(completed: boolean) => void> +>() +const deferredTerminalGeometryMutations = new WeakMap< + object, + { + mutations: Map void> + } +>() + +function notifyRebuildCompletions( + completions: Set<(completed: boolean) => void> | undefined, + completed: boolean +): void { + for (const completion of completions ?? []) { + try { + completion(completed) + } catch (error) { + // Why: one optional observer must not strand the rebuild or prevent the + // coordinator from restoring the authoritative viewport. + console.error('[terminal] scroll-intent rebuild completion failed', error) + } + } +} + +export function beginTerminalScrollIntentBufferRebuild(terminal: object): void { + terminalScrollIntentRebuilds.set(terminal, (terminalScrollIntentRebuilds.get(terminal) ?? 0) + 1) +} + +export function endTerminalScrollIntentBufferRebuild(terminal: object): void { + const count = terminalScrollIntentRebuilds.get(terminal) ?? 0 + if (count <= 1) { + terminalScrollIntentRebuilds.delete(terminal) + const completions = terminalScrollIntentRebuildCompletions.get(terminal) + terminalScrollIntentRebuildCompletions.delete(terminal) + notifyRebuildCompletions(completions, true) + return + } + terminalScrollIntentRebuilds.set(terminal, count - 1) +} + +export function isTerminalScrollIntentRebuildInFlight(terminal: object): boolean { + return (terminalScrollIntentRebuilds.get(terminal) ?? 0) > 0 +} + +export function onTerminalScrollIntentBufferRebuildComplete( + terminal: object, + completion: (completed: boolean) => void +): () => void { + if (!isTerminalScrollIntentRebuildInFlight(terminal)) { + completion(true) + return () => {} + } + let completions = terminalScrollIntentRebuildCompletions.get(terminal) + if (!completions) { + completions = new Set() + terminalScrollIntentRebuildCompletions.set(terminal, completions) + } + completions.add(completion) + return () => { + completions?.delete(completion) + if (completions?.size === 0) { + terminalScrollIntentRebuildCompletions.delete(terminal) + } + } +} + +// Why: source-dimension replay must finish and restore its viewport before +// unrelated fit/resize work is allowed to reflow the rebuilt buffer. +export function deferTerminalGeometryMutationDuringRebuild( + terminal: object, + operationKey: string, + mutation: () => void +): boolean { + if (!isTerminalScrollIntentRebuildInFlight(terminal)) { + return false + } + const existing = deferredTerminalGeometryMutations.get(terminal) + if (existing) { + existing.mutations.set(operationKey, mutation) + return true + } + const mutations = new Map([[operationKey, mutation]]) + const deferred = { mutations } + deferredTerminalGeometryMutations.set(terminal, deferred) + onTerminalScrollIntentBufferRebuildComplete(terminal, (completed) => { + if (deferredTerminalGeometryMutations.get(terminal) !== deferred) { + return + } + if (!completed) { + deferredTerminalGeometryMutations.delete(terminal) + return + } + // Why: rebuild completion listeners run before the coordinator restores + // intent; the microtask makes every geometry mutation post-restore. + queueMicrotask(() => { + if (deferredTerminalGeometryMutations.get(terminal) !== deferred) { + return + } + // Keep the entry cancellable until execution begins; disposal may land + // after rebuild completion but before this post-restore microtask. + deferredTerminalGeometryMutations.delete(terminal) + for (const [key, pendingMutation] of mutations) { + if (!deferTerminalGeometryMutationDuringRebuild(terminal, key, pendingMutation)) { + pendingMutation() + } + } + }) + }) + return true +} + +export function cancelTerminalScrollIntentBufferRebuildCompletions(terminal: object): void { + const completions = terminalScrollIntentRebuildCompletions.get(terminal) + terminalScrollIntentRebuildCompletions.delete(terminal) + notifyRebuildCompletions(completions, false) + deferredTerminalGeometryMutations.delete(terminal) +} diff --git a/src/renderer/src/lib/pane-manager/terminal-scroll-intent-settle.ts b/src/renderer/src/lib/pane-manager/terminal-scroll-intent-settle.ts new file mode 100644 index 00000000000..a4962dff8cf --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-scroll-intent-settle.ts @@ -0,0 +1,33 @@ +import { + syncTerminalScrollIntentFromViewport, + type TerminalScrollIntentTarget +} from './terminal-scroll-intent' + +export function syncTerminalScrollIntentSoon( + terminal: TerminalScrollIntentTarget, + options: { + allowBufferShrink?: boolean + preservePinnedAtBottom?: boolean + shouldSync?: () => boolean + } = {} +): void { + const sync = (): void => { + if (options.shouldSync?.() === false) { + return + } + syncTerminalScrollIntentFromViewport(terminal, options) + } + queueMicrotask(sync) + requestAnimationFrame(sync) + requestAnimationFrame(() => requestAnimationFrame(sync)) + // Why: preservePinnedAtBottom only bridges xterm's async scroll application. + // The settle tick must reclassify from the real viewport, otherwise a wheel + // the viewport never followed latches a phantom pin at the bottom. + setTimeout(() => { + if (options.shouldSync?.() !== false) { + syncTerminalScrollIntentFromViewport(terminal, { + allowBufferShrink: options.allowBufferShrink + }) + } + }, 80) +} diff --git a/src/renderer/src/lib/pane-manager/terminal-scroll-intent-structural-transitions.test.ts b/src/renderer/src/lib/pane-manager/terminal-scroll-intent-structural-transitions.test.ts new file mode 100644 index 00000000000..4e6c2b66626 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-scroll-intent-structural-transitions.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it, vi } from 'vitest' +import { + bindTerminalScrollIntentKey, + captureTerminalStructuralScrollIntent, + markTerminalFollowOutput, + markTerminalPinnedViewport, + restoreTerminalStructuralScrollIntent +} from './terminal-scroll-intent' + +type BufferType = 'normal' | 'alternate' + +function createTerminal(viewportY: number, baseY: number, type: BufferType = 'normal') { + const terminal = { + buffer: { active: { type, viewportY, baseY } }, + scrollToBottom: vi.fn(() => { + terminal.buffer.active.viewportY = terminal.buffer.active.baseY + }), + scrollToLine: vi.fn((line: number) => { + terminal.buffer.active.viewportY = line + }) + } + return terminal +} + +describe('terminal structural scroll-intent transitions', () => { + it.each([ + { + name: 'live pinned growth', + storedKind: 'pinnedViewport' as const, + live: { viewportY: 76, baseY: 120, type: 'normal' as const }, + expected: { kind: 'pinnedViewport', viewportY: 76, baseY: 120, bufferType: 'normal' } + }, + { + name: 'empty pinned remount', + storedKind: 'pinnedViewport' as const, + live: { viewportY: 0, baseY: 0, type: 'normal' as const }, + expected: { kind: 'pinnedViewport', viewportY: 76, baseY: 100, bufferType: 'normal' } + }, + { + name: 'shorter pinned remount', + storedKind: 'pinnedViewport' as const, + live: { viewportY: 20, baseY: 30, type: 'normal' as const }, + expected: { kind: 'pinnedViewport', viewportY: 76, baseY: 100, bufferType: 'normal' } + }, + { + name: 'alternate buffer entered from a normal-buffer pin', + storedKind: 'pinnedViewport' as const, + live: { viewportY: 0, baseY: 0, type: 'alternate' as const }, + expected: { kind: 'pinnedViewport', viewportY: 76, baseY: 100, bufferType: 'normal' } + }, + { + name: 'untracked return to bottom', + storedKind: 'pinnedViewport' as const, + live: { viewportY: 100, baseY: 100, type: 'normal' as const }, + expected: { kind: 'followOutput', viewportY: 100, baseY: 100, bufferType: 'normal' } + }, + { + name: 'empty follow-output remount', + storedKind: 'followOutput' as const, + live: { viewportY: 0, baseY: 0, type: 'normal' as const }, + expected: { kind: 'followOutput', viewportY: 0, baseY: 0, bufferType: 'normal' } + } + ])('captures the authoritative coordinates for $name', ({ storedKind, live, expected }) => { + const key = `structural-${storedKind}-${live.type}-${live.viewportY}-${live.baseY}` + const original = createTerminal(76, 100) + bindTerminalScrollIntentKey(original, key) + if (storedKind === 'pinnedViewport') { + markTerminalPinnedViewport(original) + } else { + original.buffer.active.viewportY = original.buffer.active.baseY + markTerminalFollowOutput(original) + } + + const current = createTerminal(live.viewportY, live.baseY, live.type) + bindTerminalScrollIntentKey(current, key) + + expect(captureTerminalStructuralScrollIntent(current)).toMatchObject(expected) + }) + + it('restores a durable remount pin by bottom offset without overwriting newer intent', () => { + const original = createTerminal(76, 100) + bindTerminalScrollIntentKey(original, 'structural-remount-revision') + markTerminalPinnedViewport(original) + const remounted = createTerminal(0, 0) + bindTerminalScrollIntentKey(remounted, 'structural-remount-revision') + const staleIntent = captureTerminalStructuralScrollIntent(remounted) + + remounted.buffer.active.viewportY = 200 + remounted.buffer.active.baseY = 200 + markTerminalFollowOutput(remounted) + restoreTerminalStructuralScrollIntent(remounted, staleIntent, { restoreBy: 'bottomOffset' }) + + expect(remounted.scrollToLine).not.toHaveBeenCalled() + expect(remounted.buffer.active.viewportY).toBe(200) + }) + + it('keeps a normal-buffer pin dormant while replay restores an alternate buffer', () => { + const original = createTerminal(76, 100) + bindTerminalScrollIntentKey(original, 'structural-buffer-switch') + markTerminalPinnedViewport(original) + const remounted = createTerminal(0, 0, 'alternate') + bindTerminalScrollIntentKey(remounted, 'structural-buffer-switch') + const intent = captureTerminalStructuralScrollIntent(remounted) + + remounted.buffer.active.baseY = 40 + remounted.buffer.active.viewportY = 40 + restoreTerminalStructuralScrollIntent(remounted, intent, { restoreBy: 'bottomOffset' }) + expect(remounted.scrollToLine).not.toHaveBeenCalled() + + remounted.buffer.active.type = 'normal' + remounted.buffer.active.baseY = 140 + remounted.buffer.active.viewportY = 140 + restoreTerminalStructuralScrollIntent(remounted, intent, { restoreBy: 'bottomOffset' }) + expect(remounted.scrollToLine).toHaveBeenLastCalledWith(116) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts b/src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts index 205bd8ecaa9..5d3c9f26aa9 100644 --- a/src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts +++ b/src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts @@ -1,15 +1,22 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { - attachTerminalScrollIntentTracking, - captureTerminalWriteScrollIntent, + bindTerminalScrollIntentKey, + captureTerminalStructuralScrollIntent, enforceTerminalCurrentScrollIntent, - enforceTerminalWriteScrollIntent, getTerminalScrollIntentKind, markTerminalFollowOutput, markTerminalPinnedViewport, syncTerminalScrollIntentFromViewport, - syncTerminalScrollIntentSoon + restoreTerminalStructuralScrollIntent } from './terminal-scroll-intent' +import { syncTerminalScrollIntentSoon } from './terminal-scroll-intent-settle' +import { clearTerminalScrollbackAndFollowOutput } from './terminal-scrollback-clear' +import { attachTerminalScrollIntentTracking } from './terminal-scroll-intent-dom-tracking' +import { + beginTerminalScrollIntentBufferRebuild, + cancelTerminalScrollIntentBufferRebuildCompletions, + endTerminalScrollIntentBufferRebuild +} from './terminal-scroll-intent-rebuild' function createTerminal({ viewportY, @@ -99,14 +106,39 @@ describe('terminal scroll intent', () => { expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport') }) + it('treats a viewport exactly one row above bottom as pinned', () => { + const terminal = createTerminal({ viewportY: 99, baseY: 100 }) + + expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport') + syncTerminalScrollIntentFromViewport(terminal) + expect(captureTerminalStructuralScrollIntent(terminal)?.kind).toBe('pinnedViewport') + }) + + it('clears a pinned scrollback into follow-output state', () => { + const terminal = { + ...createTerminal({ viewportY: 42, baseY: 100 }), + clear: vi.fn() + } + markTerminalPinnedViewport(terminal) + + clearTerminalScrollbackAndFollowOutput(terminal) + + expect(terminal.clear).toHaveBeenCalledOnce() + expect(terminal.scrollToBottom).toHaveBeenCalledOnce() + expect(terminal.clear.mock.invocationCallOrder[0]).toBeLessThan( + terminal.scrollToBottom.mock.invocationCallOrder[0] + ) + expect(getTerminalScrollIntentKind(terminal)).toBe('followOutput') + }) + it('preserves a pinned viewport after output moves xterm to bottom', () => { const terminal = createTerminal({ viewportY: 42, baseY: 100 }) markTerminalPinnedViewport(terminal) - const snapshot = captureTerminalWriteScrollIntent(terminal) + const snapshot = captureTerminalStructuralScrollIntent(terminal) terminal.buffer.active.baseY = 125 terminal.buffer.active.viewportY = 125 - enforceTerminalWriteScrollIntent(terminal, snapshot) + restoreTerminalStructuralScrollIntent(terminal, snapshot) expect(terminal.scrollToLine).toHaveBeenCalledWith(42) expect(terminal.buffer.active.viewportY).toBe(42) @@ -116,11 +148,11 @@ describe('terminal scroll intent', () => { it('follows output after output advances while following', () => { const terminal = createTerminal({ viewportY: 100, baseY: 100 }) markTerminalFollowOutput(terminal) - const snapshot = captureTerminalWriteScrollIntent(terminal) + const snapshot = captureTerminalStructuralScrollIntent(terminal) terminal.buffer.active.baseY = 125 terminal.buffer.active.viewportY = 0 - enforceTerminalWriteScrollIntent(terminal, snapshot) + restoreTerminalStructuralScrollIntent(terminal, snapshot) expect(terminal.scrollToBottom).toHaveBeenCalledTimes(1) expect(terminal.buffer.active.viewportY).toBe(125) @@ -129,16 +161,30 @@ describe('terminal scroll intent', () => { it('does not preserve across buffer type changes', () => { const terminal = createTerminal({ viewportY: 42, baseY: 100 }) markTerminalPinnedViewport(terminal) - const snapshot = captureTerminalWriteScrollIntent(terminal) + const snapshot = captureTerminalStructuralScrollIntent(terminal) terminal.buffer.active.type = 'alternate' terminal.buffer.active.viewportY = 0 - enforceTerminalWriteScrollIntent(terminal, snapshot) + restoreTerminalStructuralScrollIntent(terminal, snapshot) expect(terminal.scrollToLine).not.toHaveBeenCalled() expect(terminal.buffer.active.viewportY).toBe(0) }) + it('does not enforce a captured intent after newer user intent supersedes it', () => { + const terminal = createTerminal({ viewportY: 42, baseY: 100 }) + markTerminalPinnedViewport(terminal) + const staleSnapshot = captureTerminalStructuralScrollIntent(terminal) + + terminal.buffer.active.viewportY = terminal.buffer.active.baseY + markTerminalFollowOutput(terminal) + terminal.buffer.active.baseY = 125 + restoreTerminalStructuralScrollIntent(terminal, staleSnapshot) + + expect(terminal.scrollToLine).not.toHaveBeenCalled() + expect(getTerminalScrollIntentKind(terminal)).toBe('followOutput') + }) + it('syncs intent from the current viewport after user scroll settles', () => { const terminal = createTerminal({ viewportY: 100, baseY: 100 }) @@ -148,6 +194,20 @@ describe('terminal scroll intent', () => { expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport') }) + it('records xterm native scrollback-trim movement before structural enforcement', () => { + const terminal = createTerminal({ viewportY: 10, baseY: 20 }) + markTerminalPinnedViewport(terminal) + + // At scrollback capacity xterm keeps baseY fixed and walks viewportY up + // as old rows trim, preserving the visible content without app help. + terminal.buffer.active.viewportY = 5 + syncTerminalScrollIntentFromViewport(terminal) + terminal.buffer.active.viewportY = 0 + enforceTerminalCurrentScrollIntent(terminal) + + expect(terminal.scrollToLine).toHaveBeenLastCalledWith(5) + }) + it('tracks upward wheel immediately and records the settled viewport', async () => { const frameCallbacks: FrameRequestCallback[] = [] vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { @@ -253,6 +313,131 @@ describe('terminal scroll intent', () => { remountedDisposable.dispose() }) + it('captures durable pinned coordinates before replaying into an empty remount', () => { + vi.stubGlobal('Element', TestElement) + const firstTerminal = createTerminal({ viewportY: 76, baseY: 100 }) + const firstHost = new TestElement() as unknown as HTMLElement + const firstDisposable = attachTerminalScrollIntentTracking( + firstTerminal, + firstHost, + 'leaf-remount-replay' + ) + markTerminalPinnedViewport(firstTerminal) + + const remountedTerminal = createTerminal({ viewportY: 0, baseY: 0 }) + const remountedHost = new TestElement() as unknown as HTMLElement + const remountedDisposable = attachTerminalScrollIntentTracking( + remountedTerminal, + remountedHost, + 'leaf-remount-replay' + ) + const intent = captureTerminalStructuralScrollIntent(remountedTerminal) + + expect(intent).toMatchObject({ + kind: 'pinnedViewport', + viewportY: 76, + baseY: 100 + }) + remountedTerminal.buffer.active.viewportY = 100 + remountedTerminal.buffer.active.baseY = 100 + restoreTerminalStructuralScrollIntent(remountedTerminal, intent, { + restoreBy: 'bottomOffset' + }) + + expect(remountedTerminal.scrollToLine).toHaveBeenLastCalledWith(76) + expect(remountedTerminal.buffer.active.viewportY).toBe(76) + firstDisposable.dispose() + remountedDisposable.dispose() + }) + + it('refreshes pinned base geometry before a keyed empty remount', () => { + vi.stubGlobal('Element', TestElement) + const firstTerminal = createTerminal({ viewportY: 10, baseY: 20 }) + const firstHost = new TestElement() as unknown as HTMLElement + const firstDisposable = attachTerminalScrollIntentTracking( + firstTerminal, + firstHost, + 'leaf-growing-pin' + ) + markTerminalPinnedViewport(firstTerminal) + + firstTerminal.buffer.active.baseY = 30 + syncTerminalScrollIntentFromViewport(firstTerminal) + const remountedTerminal = createTerminal({ viewportY: 0, baseY: 0 }) + const remountedHost = new TestElement() as unknown as HTMLElement + const remountedDisposable = attachTerminalScrollIntentTracking( + remountedTerminal, + remountedHost, + 'leaf-growing-pin' + ) + const intent = captureTerminalStructuralScrollIntent(remountedTerminal) + remountedTerminal.buffer.active.viewportY = 30 + remountedTerminal.buffer.active.baseY = 30 + restoreTerminalStructuralScrollIntent(remountedTerminal, intent, { + restoreBy: 'bottomOffset' + }) + + expect(intent).toMatchObject({ viewportY: 10, baseY: 30 }) + expect(remountedTerminal.scrollToLine).toHaveBeenLastCalledWith(10) + firstDisposable.dispose() + remountedDisposable.dispose() + }) + + it('persists native pinned growth on disposal for the next keyed replay', () => { + vi.stubGlobal('Element', TestElement) + const firstTerminal = createTerminal({ viewportY: 76, baseY: 100 }) + const firstDisposable = attachTerminalScrollIntentTracking( + firstTerminal, + new TestElement() as unknown as HTMLElement, + 'leaf-dispose-growth' + ) + markTerminalPinnedViewport(firstTerminal) + firstTerminal.buffer.active.baseY = 120 + + firstDisposable.dispose() + + const remountedTerminal = createTerminal({ viewportY: 0, baseY: 0 }) + const remountedDisposable = attachTerminalScrollIntentTracking( + remountedTerminal, + new TestElement() as unknown as HTMLElement, + 'leaf-dispose-growth' + ) + const intent = captureTerminalStructuralScrollIntent(remountedTerminal) + remountedTerminal.buffer.active.viewportY = 200 + remountedTerminal.buffer.active.baseY = 200 + restoreTerminalStructuralScrollIntent(remountedTerminal, intent, { + restoreBy: 'bottomOffset' + }) + + expect(intent).toMatchObject({ viewportY: 76, baseY: 120 }) + expect(remountedTerminal.scrollToLine).toHaveBeenLastCalledWith(156) + remountedDisposable.dispose() + }) + + it('does not let an old terminal disposal overwrite its keyed successor', () => { + vi.stubGlobal('Element', TestElement) + const firstTerminal = createTerminal({ viewportY: 76, baseY: 100 }) + const firstDisposable = attachTerminalScrollIntentTracking( + firstTerminal, + new TestElement() as unknown as HTMLElement, + 'leaf-dispose-successor' + ) + markTerminalPinnedViewport(firstTerminal) + + const successor = createTerminal({ viewportY: 100, baseY: 100 }) + const successorDisposable = attachTerminalScrollIntentTracking( + successor, + new TestElement() as unknown as HTMLElement, + 'leaf-dispose-successor' + ) + markTerminalFollowOutput(successor) + firstTerminal.buffer.active.baseY = 150 + firstDisposable.dispose() + + expect(getTerminalScrollIntentKind(successor)).toBe('followOutput') + successorDisposable.dispose() + }) + it('tracks pointer-driven scrollbar scrolls without using output scroll as intent', () => { vi.stubGlobal('Element', TestElement) const terminal = createTerminal({ viewportY: 100, baseY: 100 }) @@ -358,6 +543,33 @@ describe('terminal scroll intent', () => { expect(terminal.scrollToLine).toHaveBeenLastCalledWith(75) }) + it('does not let a stale key-settle callback overwrite a remounted terminal', async () => { + const frameCallbacks: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frameCallbacks.push(callback) + return frameCallbacks.length + }) + vi.useFakeTimers({ toFake: ['setTimeout'] }) + let firstTerminalIsCurrent = true + const first = createTerminal({ viewportY: 100, baseY: 100 }) + bindTerminalScrollIntentKey(first, 'key-settle-remount') + markTerminalPinnedViewport(first) + first.buffer.active.viewportY = 50 + syncTerminalScrollIntentSoon(first, { shouldSync: () => firstTerminalIsCurrent }) + + const replacement = createTerminal({ viewportY: 100, baseY: 100 }) + bindTerminalScrollIntentKey(replacement, 'key-settle-remount') + markTerminalFollowOutput(replacement) + firstTerminalIsCurrent = false + await Promise.resolve() + while (frameCallbacks.length > 0) { + frameCallbacks.shift()?.(16) + } + vi.advanceTimersByTime(80) + + expect(getTerminalScrollIntentKind(replacement)).toBe('followOutput') + }) + it('enforces current intent once for visibility resume', () => { const terminal = createTerminal({ viewportY: 40, baseY: 100 }) markTerminalPinnedViewport(terminal) @@ -434,11 +646,11 @@ describe('terminal scroll intent', () => { markTerminalPinnedViewport(terminal) for (let batch = 1; batch <= 2; batch += 1) { - const snapshot = captureTerminalWriteScrollIntent(terminal) + const snapshot = captureTerminalStructuralScrollIntent(terminal) // xterm follows output during the write because the viewport was at bottom. terminal.buffer.active.baseY += 25 terminal.buffer.active.viewportY = terminal.buffer.active.baseY - enforceTerminalWriteScrollIntent(terminal, snapshot) + restoreTerminalStructuralScrollIntent(terminal, snapshot) expect(terminal.buffer.active.viewportY).toBe(terminal.buffer.active.baseY) } expect(getTerminalScrollIntentKind(terminal)).toBe('followOutput') @@ -470,16 +682,205 @@ describe('terminal scroll intent', () => { expect(terminal.buffer.active.viewportY).toBe(150) }) - it('supports bottom-offset restore for buffer-rebuild write paths', () => { - const terminal = createTerminal({ viewportY: 550, baseY: 600 }) + it('does not re-latch a pinned intent from a transiently shorter rebuilt buffer', () => { + const terminal = createTerminal({ viewportY: 248, baseY: 254 }) markTerminalPinnedViewport(terminal) - const snapshot = captureTerminalWriteScrollIntent(terminal) + const snapshot = captureTerminalStructuralScrollIntent(terminal) - terminal.buffer.active.baseY = 80 - terminal.buffer.active.viewportY = 80 - enforceTerminalWriteScrollIntent(terminal, snapshot, { restoreBy: 'bottomOffset' }) + // Snapshot replay cleared the buffer; enforcement races the async parse. + terminal.buffer.active.baseY = 0 + terminal.buffer.active.viewportY = 0 + restoreTerminalStructuralScrollIntent(terminal, snapshot) - expect(terminal.scrollToLine).toHaveBeenLastCalledWith(30) - expect(terminal.buffer.active.viewportY).toBe(30) + // The replay finishes parsing and the scrollback regrows past the pin. + terminal.buffer.active.baseY = 284 + terminal.buffer.active.viewportY = 284 + enforceTerminalCurrentScrollIntent(terminal) + + expect(terminal.scrollToLine).toHaveBeenLastCalledWith(248) + expect(terminal.buffer.active.viewportY).toBe(248) }) + + it('keeps a pinned intent when capture races a cleared unparsed buffer', () => { + const terminal = createTerminal({ viewportY: 248, baseY: 254 }) + markTerminalPinnedViewport(terminal) + + // A structural capture sees the rebuilt buffer while it is still empty; + // the at-bottom(0/0) reading is transient and must not convert the pin. + terminal.buffer.active.baseY = 0 + terminal.buffer.active.viewportY = 0 + const snapshot = captureTerminalStructuralScrollIntent(terminal) + expect(snapshot?.kind).toBe('pinnedViewport') + }) + + it('suspends intent capture and enforcement while a buffer rebuild is in flight', () => { + const terminal = createTerminal({ viewportY: 248, baseY: 254 }) + markTerminalPinnedViewport(terminal) + const preReplay = captureTerminalStructuralScrollIntent(terminal) + beginTerminalScrollIntentBufferRebuild(terminal) + + terminal.buffer.active.baseY = 0 + terminal.buffer.active.viewportY = 0 + expect(captureTerminalStructuralScrollIntent(terminal)).toBeNull() + enforceTerminalCurrentScrollIntent(terminal) + + // A live streaming batch lands while the replay is partially parsed. + terminal.buffer.active.baseY = 284 + terminal.buffer.active.viewportY = 0 + restoreTerminalStructuralScrollIntent(terminal, preReplay) + expect(terminal.scrollToLine).not.toHaveBeenCalled() + expect(terminal.scrollToBottom).not.toHaveBeenCalled() + + terminal.buffer.active.viewportY = 284 + endTerminalScrollIntentBufferRebuild(terminal) + restoreTerminalStructuralScrollIntent(terminal, preReplay, { restoreBy: 'bottomOffset' }) + expect(terminal.scrollToLine).toHaveBeenLastCalledWith(278) + expect(terminal.buffer.active.viewportY).toBe(278) + expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport') + }) + + it.each([ + { deltaY: -10, finalViewportY: 150, expectedKind: 'pinnedViewport', expectedLine: 150 }, + { deltaY: 10, finalViewportY: 200, expectedKind: 'followOutput', expectedLine: null } + ])( + 'resyncs a $expectedKind wheel intent after a delayed rebuild', + async ({ deltaY, finalViewportY, expectedKind, expectedLine }) => { + vi.stubGlobal('requestAnimationFrame', () => 0) + vi.stubGlobal('Element', TestElement) + const terminal = createTerminal({ viewportY: 42, baseY: 100 }) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + markTerminalPinnedViewport(terminal) + const staleIntent = captureTerminalStructuralScrollIntent(terminal) + beginTerminalScrollIntentBufferRebuild(terminal) + + terminal.buffer.active.viewportY = 0 + terminal.buffer.active.baseY = 0 + const wheel = new Event('wheel') as WheelEvent + Object.defineProperty(wheel, 'deltaY', { value: deltaY }) + host.dispatchEvent(wheel) + terminal.buffer.active.viewportY = finalViewportY + terminal.buffer.active.baseY = 200 + endTerminalScrollIntentBufferRebuild(terminal) + restoreTerminalStructuralScrollIntent(terminal, staleIntent, { + restoreBy: 'bottomOffset' + }) + + expect(getTerminalScrollIntentKind(terminal)).toBe(expectedKind) + terminal.buffer.active.viewportY = 0 + terminal.scrollToLine.mockClear() + enforceTerminalCurrentScrollIntent(terminal) + if (expectedLine === null) { + expect(terminal.scrollToBottom).toHaveBeenCalled() + } else { + expect(terminal.scrollToLine).toHaveBeenLastCalledWith(expectedLine) + } + disposable.dispose() + } + ) + + it('preserves a durable pin when wheel-up lands on the cleared replay buffer', async () => { + vi.stubGlobal('requestAnimationFrame', () => 0) + vi.stubGlobal('Element', TestElement) + const terminal = createTerminal({ viewportY: 80, baseY: 100 }) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + markTerminalPinnedViewport(terminal) + const staleIntent = captureTerminalStructuralScrollIntent(terminal) + beginTerminalScrollIntentBufferRebuild(terminal) + + terminal.buffer.active.viewportY = 0 + terminal.buffer.active.baseY = 0 + const wheel = new Event('wheel') as WheelEvent + Object.defineProperty(wheel, 'deltaY', { value: -10 }) + host.dispatchEvent(wheel) + terminal.buffer.active.viewportY = 200 + terminal.buffer.active.baseY = 200 + endTerminalScrollIntentBufferRebuild(terminal) + restoreTerminalStructuralScrollIntent(terminal, staleIntent, { restoreBy: 'bottomOffset' }) + await Promise.resolve() + + expect(terminal.scrollToLine).toHaveBeenLastCalledWith(180) + expect(terminal.buffer.active.viewportY).toBe(180) + expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport') + disposable.dispose() + }) + + it('keeps rebuild wheel intent when xterm classifies the same event as mouse input', async () => { + vi.stubGlobal('requestAnimationFrame', () => 0) + vi.stubGlobal('Element', TestElement) + const { terminal, capturedInput, capturedUserInput } = createTerminalWithInputCapture({ + viewportY: 80, + baseY: 100 + }) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + markTerminalPinnedViewport(terminal) + const staleIntent = captureTerminalStructuralScrollIntent(terminal) + beginTerminalScrollIntentBufferRebuild(terminal) + + terminal.buffer.active.viewportY = 0 + terminal.buffer.active.baseY = 0 + const wheel = new Event('wheel') as WheelEvent + Object.defineProperty(wheel, 'deltaY', { value: -10 }) + host.dispatchEvent(wheel) + capturedUserInput.listener?.() + capturedInput.listener?.('\x1b[<64;10;5M') + await Promise.resolve() + terminal.buffer.active.viewportY = 200 + terminal.buffer.active.baseY = 200 + endTerminalScrollIntentBufferRebuild(terminal) + restoreTerminalStructuralScrollIntent(terminal, staleIntent, { restoreBy: 'bottomOffset' }) + await Promise.resolve() + + expect(terminal.scrollToLine).toHaveBeenLastCalledWith(180) + expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport') + disposable.dispose() + }) + + it('does not resync deferred wheel intent from a canceled partial rebuild', () => { + vi.stubGlobal('requestAnimationFrame', () => 0) + vi.stubGlobal('Element', TestElement) + const terminal = createTerminal({ viewportY: 42, baseY: 100 }) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + markTerminalPinnedViewport(terminal) + beginTerminalScrollIntentBufferRebuild(terminal) + + terminal.buffer.active.viewportY = 0 + terminal.buffer.active.baseY = 0 + const wheel = new Event('wheel') as WheelEvent + Object.defineProperty(wheel, 'deltaY', { value: -10 }) + host.dispatchEvent(wheel) + cancelTerminalScrollIntentBufferRebuildCompletions(terminal) + endTerminalScrollIntentBufferRebuild(terminal) + + terminal.buffer.active.viewportY = 0 + terminal.buffer.active.baseY = 100 + enforceTerminalCurrentScrollIntent(terminal) + expect(terminal.scrollToLine).toHaveBeenLastCalledWith(42) + disposable.dispose() + }) + + function createTerminalWithInputCapture(args: { viewportY: number; baseY: number }) { + const capturedInput: { listener: ((data: string) => void) | null } = { listener: null } + const capturedUserInput: { listener: (() => void) | null } = { listener: null } + const terminal = createTerminal(args) as ReturnType & { + onData?: (listener: (data: string) => void) => { dispose: () => void } + _core?: { coreService: { onUserInput: (listener: () => void) => { dispose: () => void } } } + } + terminal.onData = (listener: (data: string) => void) => { + capturedInput.listener = listener + return { dispose: vi.fn() } + } + terminal._core = { + coreService: { + onUserInput: (listener: () => void) => { + capturedUserInput.listener = listener + return { dispose: vi.fn() } + } + } + } + return { terminal, capturedInput, capturedUserInput } + } }) diff --git a/src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts b/src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts index 88e70096a59..8fa96d62c69 100644 --- a/src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts +++ b/src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts @@ -1,35 +1,36 @@ -import type { IDisposable } from '@xterm/xterm' +import { isTerminalScrollIntentRebuildInFlight } from './terminal-scroll-intent-rebuild' +import { + clampTerminalViewportY, + isTerminalViewportAtBottom, + readTerminalScrollBufferSnapshot, + safeTerminalScrollCall, + type TerminalScrollBufferType +} from './terminal-scroll-buffer-snapshot' type TerminalScrollIntentKind = 'followOutput' | 'pinnedViewport' -type BufferType = 'normal' | 'alternate' - -type TerminalScrollIntentTarget = { - buffer?: { - active?: { - type?: string - viewportY?: number - baseY?: number - } - } +export type TerminalScrollIntentTarget = { + buffer?: Parameters[0]['buffer'] scrollToBottom?: () => void scrollToLine?: (line: number) => void } -type TerminalScrollIntentKey = string +export type TerminalScrollIntentKey = string type TerminalScrollIntent = { kind: TerminalScrollIntentKind - bufferType: BufferType + bufferType: TerminalScrollBufferType viewportY: number baseY: number + revision: number } -type TerminalScrollIntentWriteSnapshot = { +export type TerminalStructuralScrollIntentSnapshot = { kind: TerminalScrollIntentKind - bufferType: BufferType + bufferType: TerminalScrollBufferType viewportY: number baseY: number + revision: number } type TerminalScrollIntentEnforceOptions = { @@ -47,47 +48,31 @@ const terminalScrollIntentKeyByTerminal = new WeakMap< TerminalScrollIntentTarget, TerminalScrollIntentKey >() +const terminalScrollIntentKeyBindingByTerminal = new WeakMap() const terminalScrollIntentByKey = new Map() +const terminalScrollIntentBindingByKey = new Map() -const BOTTOM_TOLERANCE_ROWS = 1 -const XTERM_SCROLL_INTENT_POINTER_TARGET_CLASSES = [ - 'xterm-viewport', - 'xterm-scrollbar', - 'xterm-slider' -] as const -const XTERM_SCROLL_INTENT_POINTER_TARGET_SELECTOR = XTERM_SCROLL_INTENT_POINTER_TARGET_CLASSES.map( - (className) => `.${className}` -).join(',') - -function readBufferSnapshot( - terminal: TerminalScrollIntentTarget -): { bufferType: BufferType; viewportY: number; baseY: number } | null { - const buffer = terminal.buffer?.active - const viewportY = buffer?.viewportY - const baseY = buffer?.baseY - if (typeof viewportY !== 'number' || typeof baseY !== 'number') { - return null - } - return { - bufferType: buffer?.type === 'alternate' ? 'alternate' : 'normal', - viewportY, - baseY - } -} - -function isAtBottom(viewportY: number, baseY: number): boolean { - return viewportY >= baseY - BOTTOM_TOLERANCE_ROWS -} +let nextTerminalScrollIntentRevision = 1 +let nextTerminalScrollIntentKeyBinding = 1 function writeIntent( terminal: TerminalScrollIntentTarget, kind: TerminalScrollIntentKind ): TerminalScrollIntent | null { - const snapshot = readBufferSnapshot(terminal) + const snapshot = readTerminalScrollBufferSnapshot(terminal) if (!snapshot) { return null } - const intent = { kind, ...snapshot } + return writeIntentSnapshot(terminal, kind, snapshot) +} + +function writeIntentSnapshot( + terminal: TerminalScrollIntentTarget, + kind: TerminalScrollIntentKind, + snapshot: { bufferType: TerminalScrollBufferType; viewportY: number; baseY: number } +): TerminalScrollIntent { + const intent = { kind, ...snapshot, revision: nextTerminalScrollIntentRevision } + nextTerminalScrollIntentRevision += 1 terminalScrollIntentByTerminal.set(terminal, intent) const key = terminalScrollIntentKeyByTerminal.get(terminal) if (key) { @@ -105,7 +90,7 @@ function readStoredIntent(terminal: TerminalScrollIntentTarget): TerminalScrollI return key ? terminalScrollIntentByKey.get(key) : undefined } -function bindTerminalScrollIntentKey( +export function bindTerminalScrollIntentKey( terminal: TerminalScrollIntentTarget, key: TerminalScrollIntentKey | undefined ): TerminalScrollIntent | undefined { @@ -113,6 +98,10 @@ function bindTerminalScrollIntentKey( return terminalScrollIntentByTerminal.get(terminal) } terminalScrollIntentKeyByTerminal.set(terminal, key) + const binding = nextTerminalScrollIntentKeyBinding + nextTerminalScrollIntentKeyBinding += 1 + terminalScrollIntentKeyBindingByTerminal.set(terminal, binding) + terminalScrollIntentBindingByKey.set(key, binding) const existing = terminalScrollIntentByKey.get(key) if (existing) { terminalScrollIntentByTerminal.set(terminal, existing) @@ -120,28 +109,17 @@ function bindTerminalScrollIntentKey( return existing } -function clampViewportY(viewportY: number, baseY: number): number { - return Math.max(0, Math.min(viewportY, baseY)) -} - -function safeScrollCall(fn: () => void): boolean { - try { - fn() +export function isTerminalScrollIntentKeyBindingCurrent( + terminal: TerminalScrollIntentTarget +): boolean { + const key = terminalScrollIntentKeyByTerminal.get(terminal) + if (!key) { return true - } catch (err) { - if (err instanceof TypeError && /dimensions/.test(err.message)) { - return false - } - throw err } -} - -function isTerminalScrollIntentPointerTarget(target: EventTarget | null): target is Element { - if (typeof Element === 'undefined' || !(target instanceof Element)) { - return false - } - // xterm's custom scrollbar uses separate thumb/track nodes from the viewport. - return target.closest(XTERM_SCROLL_INTENT_POINTER_TARGET_SELECTOR) !== null + return ( + terminalScrollIntentKeyBindingByTerminal.get(terminal) === + terminalScrollIntentBindingByKey.get(key) + ) } export function markTerminalFollowOutput(terminal: TerminalScrollIntentTarget): void { @@ -154,45 +132,53 @@ export function markTerminalPinnedViewport(terminal: TerminalScrollIntentTarget) export function syncTerminalScrollIntentFromViewport( terminal: TerminalScrollIntentTarget, - options: { preservePinnedAtBottom?: boolean } = {} + options: { allowBufferShrink?: boolean; preservePinnedAtBottom?: boolean } = {} ): void { - const snapshot = readBufferSnapshot(terminal) + if (isTerminalScrollIntentRebuildInFlight(terminal)) { + return + } + const snapshot = readTerminalScrollBufferSnapshot(terminal) if (!snapshot) { return } const existing = readStoredIntent(terminal) // Why: a remounted/replayed terminal can briefly report an empty or shorter // scrollback. That transient state must not erase a durable pinned viewport. - if (existing?.kind === 'pinnedViewport' && snapshot.baseY < existing.baseY) { + if ( + !options.allowBufferShrink && + existing?.kind === 'pinnedViewport' && + snapshot.baseY < existing.baseY + ) { terminalScrollIntentByTerminal.set(terminal, existing) return } if ( options.preservePinnedAtBottom && existing?.kind === 'pinnedViewport' && - isAtBottom(snapshot.viewportY, snapshot.baseY) + isTerminalViewportAtBottom(snapshot.viewportY, snapshot.baseY) ) { return } - writeIntent( - terminal, - isAtBottom(snapshot.viewportY, snapshot.baseY) ? 'followOutput' : 'pinnedViewport' - ) -} - -export function syncTerminalScrollIntentSoon( - terminal: TerminalScrollIntentTarget, - options: { preservePinnedAtBottom?: boolean } = {} -): void { - const sync = (): void => syncTerminalScrollIntentFromViewport(terminal, options) - queueMicrotask(sync) - requestAnimationFrame(sync) - requestAnimationFrame(() => requestAnimationFrame(sync)) - // Why: preservePinnedAtBottom only bridges xterm's async scroll application. - // The settle tick must reclassify from the real viewport, otherwise a wheel - // the viewport never followed (sub-row delta, TUI-consumed mouse report, - // plain PageUp/Home sent to the app) latches a phantom pin at the bottom. - setTimeout(() => syncTerminalScrollIntentFromViewport(terminal), 80) + const kind = isTerminalViewportAtBottom(snapshot.viewportY, snapshot.baseY) + ? 'followOutput' + : 'pinnedViewport' + // Why: parser auto-replies and repeated wheel settle samples often observe + // no intent change. Avoid manufacturing revisions that can cancel a valid + // structural restore or amplify terminal-output bursts. + if ( + existing?.kind === kind && + existing.bufferType === snapshot.bufferType && + (kind === 'followOutput' || existing.viewportY === snapshot.viewportY) + ) { + if (kind === 'pinnedViewport' && existing.baseY !== snapshot.baseY) { + // Why: native pinned output can grow baseY without moving viewportY. + // Refresh geometry without creating a user-intent revision so a later + // keyed remount restores the same content, not the stale bottom offset. + Object.assign(existing, snapshot) + } + return + } + writeIntent(terminal, kind) } export function getTerminalScrollIntentKind( @@ -202,52 +188,82 @@ export function getTerminalScrollIntentKind( if (existing) { return existing.kind } - const snapshot = readBufferSnapshot(terminal) + const snapshot = readTerminalScrollBufferSnapshot(terminal) if (!snapshot) { return 'followOutput' } - return isAtBottom(snapshot.viewportY, snapshot.baseY) ? 'followOutput' : 'pinnedViewport' + return isTerminalViewportAtBottom(snapshot.viewportY, snapshot.baseY) + ? 'followOutput' + : 'pinnedViewport' } -export function captureTerminalWriteScrollIntent( +export function captureTerminalStructuralScrollIntent( terminal: TerminalScrollIntentTarget -): TerminalScrollIntentWriteSnapshot | null { - const snapshot = readBufferSnapshot(terminal) +): TerminalStructuralScrollIntentSnapshot | null { + if (isTerminalScrollIntentRebuildInFlight(terminal)) { + return null + } + const snapshot = readTerminalScrollBufferSnapshot(terminal) if (!snapshot) { return null } const existing = readStoredIntent(terminal) let kind = existing?.kind ?? - (isAtBottom(snapshot.viewportY, snapshot.baseY) ? 'followOutput' : 'pinnedViewport') + (isTerminalViewportAtBottom(snapshot.viewportY, snapshot.baseY) + ? 'followOutput' + : 'pinnedViewport') // Why: a pinned intent whose live viewport still sits at the bottom is a - // phantom pin (the user's scroll never detached the viewport). Enforcing it - // would freeze the terminal at the current line on every write batch. - if (kind === 'pinnedViewport' && isAtBottom(snapshot.viewportY, snapshot.baseY)) { + // phantom pin (the user's scroll never detached the viewport). Restoring it + // after a structural operation would freeze the terminal at a stale line. + // Only trust the at-bottom reading when the scrollback is at least as long + // as the pin's — a shorter one is a cleared buffer awaiting replay. + if ( + kind === 'pinnedViewport' && + isTerminalViewportAtBottom(snapshot.viewportY, snapshot.baseY) && + (!existing || snapshot.baseY >= existing.baseY) + ) { kind = 'followOutput' } + // Why: a keyed remount starts at 0/0 before replay. Preserve the durable + // pre-remount coordinates or a bottom-offset restore silently loses the pin. + const capturedCoordinates = + existing?.kind === 'pinnedViewport' && snapshot.baseY < existing.baseY ? existing : snapshot return { + ...capturedCoordinates, kind, - bufferType: snapshot.bufferType, - viewportY: snapshot.viewportY, - baseY: snapshot.baseY + revision: existing?.revision ?? 0 } } -export function enforceTerminalWriteScrollIntent( +export function isTerminalStructuralScrollIntentCurrent( terminal: TerminalScrollIntentTarget, - snapshot: TerminalScrollIntentWriteSnapshot | null, + snapshot: TerminalStructuralScrollIntentSnapshot | null +): boolean { + if (!snapshot) { + return false + } + return (readStoredIntent(terminal)?.revision ?? 0) === snapshot.revision +} + +export function restoreTerminalStructuralScrollIntent( + terminal: TerminalScrollIntentTarget, + snapshot: TerminalStructuralScrollIntentSnapshot | null, options: TerminalScrollIntentEnforceOptions = {} ): void { - if (!snapshot) { + if ( + !snapshot || + !isTerminalStructuralScrollIntentCurrent(terminal, snapshot) || + isTerminalScrollIntentRebuildInFlight(terminal) + ) { return } - const current = readBufferSnapshot(terminal) + const current = readTerminalScrollBufferSnapshot(terminal) if (!current || current.bufferType !== snapshot.bufferType) { return } if (snapshot.kind === 'followOutput') { - if (safeScrollCall(() => terminal.scrollToBottom?.())) { + if (safeTerminalScrollCall(() => terminal.scrollToBottom?.())) { writeIntent(terminal, 'followOutput') } return @@ -256,89 +272,60 @@ export function enforceTerminalWriteScrollIntent( options.restoreBy === 'bottomOffset' ? current.baseY - Math.max(0, snapshot.baseY - snapshot.viewportY) : snapshot.viewportY - const targetY = clampViewportY(requestedY, current.baseY) + const targetY = clampTerminalViewportY(requestedY, current.baseY) if (current.viewportY !== targetY) { - safeScrollCall(() => terminal.scrollToLine?.(targetY)) + if (!safeTerminalScrollCall(() => terminal.scrollToLine?.(targetY))) { + // Why: renderer teardown can reject the scroll before xterm changes its + // native viewport; retain the intended pin for the next fit/retry rather + // than latching the transient current bottom. + writeIntentSnapshot(terminal, 'pinnedViewport', { + bufferType: current.bufferType, + viewportY: targetY, + baseY: current.baseY + }) + return + } + } + const existing = readStoredIntent(terminal) + // Why: a scrollback shorter than the stored pin means the buffer is being + // rebuilt; re-latching from it would overwrite the durable line with the + // cleared buffer's line 0. + if (existing?.kind === 'pinnedViewport' && current.baseY < existing.baseY) { + return } writeIntent(terminal, 'pinnedViewport') } export function enforceTerminalCurrentScrollIntent(terminal: TerminalScrollIntentTarget): void { + if (isTerminalScrollIntentRebuildInFlight(terminal)) { + return + } const existing = readStoredIntent(terminal) if (!existing) { - enforceTerminalWriteScrollIntent(terminal, captureTerminalWriteScrollIntent(terminal)) + restoreTerminalStructuralScrollIntent(terminal, captureTerminalStructuralScrollIntent(terminal)) return } const snapshot = { kind: existing.kind, bufferType: existing.bufferType, viewportY: existing.viewportY, - baseY: existing.baseY + baseY: existing.baseY, + revision: existing.revision } - if (snapshot.kind === 'pinnedViewport' && isAtBottom(snapshot.viewportY, snapshot.baseY)) { + if ( + snapshot.kind === 'pinnedViewport' && + isTerminalViewportAtBottom(snapshot.viewportY, snapshot.baseY) + ) { // Why: a pin recorded at the bottom means the viewport never detached; // resuming must follow live output, not freeze at that stale line. snapshot.kind = 'followOutput' } - const current = readBufferSnapshot(terminal) + const current = readTerminalScrollBufferSnapshot(terminal) // Why: a shorter live buffer than the stored intent means the buffer was // rebuilt (snapshot replay/remount); absolute lines are renumbered there. const restoreBy = snapshot.kind === 'pinnedViewport' && current && current.baseY < snapshot.baseY ? 'bottomOffset' : 'viewportLine' - enforceTerminalWriteScrollIntent(terminal, snapshot, { restoreBy }) -} - -export function attachTerminalScrollIntentTracking( - terminal: TerminalScrollIntentTarget, - host: HTMLElement, - intentKey?: TerminalScrollIntentKey -): IDisposable { - if (!bindTerminalScrollIntentKey(terminal, intentKey)) { - syncTerminalScrollIntentFromViewport(terminal) - } - let pointerScrollActive = false - - const onWheel = (event: WheelEvent): void => { - if (event.deltaY < 0) { - markTerminalPinnedViewport(terminal) - syncTerminalScrollIntentSoon(terminal, { preservePinnedAtBottom: true }) - return - } - syncTerminalScrollIntentSoon(terminal) - } - - const onPointerDown = (event: PointerEvent): void => { - pointerScrollActive = isTerminalScrollIntentPointerTarget(event.target) - } - - const onPointerDone = (): void => { - if (!pointerScrollActive) { - return - } - pointerScrollActive = false - syncTerminalScrollIntentFromViewport(terminal) - } - - const onScroll = (): void => { - if (pointerScrollActive) { - syncTerminalScrollIntentFromViewport(terminal) - } - } - - host.addEventListener('wheel', onWheel, { capture: true, passive: true }) - host.addEventListener('pointerdown', onPointerDown, true) - host.addEventListener('scroll', onScroll, true) - globalThis.addEventListener?.('pointerup', onPointerDone, true) - globalThis.addEventListener?.('pointercancel', onPointerDone, true) - return { - dispose: () => { - host.removeEventListener('wheel', onWheel, true) - host.removeEventListener('pointerdown', onPointerDown, true) - host.removeEventListener('scroll', onScroll, true) - globalThis.removeEventListener?.('pointerup', onPointerDone, true) - globalThis.removeEventListener?.('pointercancel', onPointerDone, true) - } - } + restoreTerminalStructuralScrollIntent(terminal, snapshot, { restoreBy }) } diff --git a/src/renderer/src/lib/pane-manager/terminal-scrollback-clear.ts b/src/renderer/src/lib/pane-manager/terminal-scrollback-clear.ts new file mode 100644 index 00000000000..84929536a83 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-scrollback-clear.ts @@ -0,0 +1,16 @@ +import { markTerminalFollowOutput, type TerminalScrollIntentTarget } from './terminal-scroll-intent' + +type TerminalScrollbackClearTarget = TerminalScrollIntentTarget & { + clear: () => void + scrollToBottom: () => void +} + +export function clearTerminalScrollbackAndFollowOutput( + terminal: TerminalScrollbackClearTarget +): void { + terminal.clear() + // Why: xterm clear() leaves BufferService.isUserScrolling latched when the + // viewport was pinned, so a public zero-distance bottom scroll must reset it. + terminal.scrollToBottom() + markTerminalFollowOutput(terminal) +} diff --git a/src/renderer/src/lib/pane-manager/terminal-structural-replay-coordinator.test.ts b/src/renderer/src/lib/pane-manager/terminal-structural-replay-coordinator.test.ts new file mode 100644 index 00000000000..a4e2d86d429 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-structural-replay-coordinator.test.ts @@ -0,0 +1,248 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createTerminalStructuralReplayCoordinator } from './terminal-structural-replay-coordinator' +import { + markTerminalPinnedViewport, + syncTerminalScrollIntentFromViewport +} from './terminal-scroll-intent' +import { + isTerminalScrollIntentRebuildInFlight, + onTerminalScrollIntentBufferRebuildComplete +} from './terminal-scroll-intent-rebuild' +import { restoreScrollStateAfterFit } from './pane-scroll' +import type { ScrollState } from './pane-manager-types' + +function createTerminal(viewportY: number, baseY: number) { + const active = { type: 'normal', viewportY, baseY } + return { + buffer: { active }, + scrollToBottom: vi.fn(() => { + active.viewportY = active.baseY + }), + scrollToLine: vi.fn((line: number) => { + active.viewportY = line + }) + } +} + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve = (): void => {} + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise + }) + return { promise, resolve } +} + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +describe('terminal structural replay coordinator', () => { + it('serializes rebuilds and restores a pinned bottom offset after each parse', async () => { + const terminal = createTerminal(80, 100) + markTerminalPinnedViewport(terminal) + const coordinator = createTerminalStructuralReplayCoordinator(terminal) + const firstParsed = deferred() + const secondParsed = deferred() + const starts: string[] = [] + + const first = coordinator.run(async () => { + starts.push('first') + terminal.buffer.active.viewportY = 0 + terminal.buffer.active.baseY = 0 + await firstParsed.promise + terminal.buffer.active.viewportY = 200 + terminal.buffer.active.baseY = 200 + }) + const second = coordinator.run(async () => { + starts.push('second') + terminal.buffer.active.viewportY = 0 + terminal.buffer.active.baseY = 0 + await secondParsed.promise + terminal.buffer.active.viewportY = 300 + terminal.buffer.active.baseY = 300 + }) + + await Promise.resolve() + await Promise.resolve() + expect(starts).toEqual(['first']) + firstParsed.resolve() + await first + expect(terminal.buffer.active.viewportY).toBe(180) + await Promise.resolve() + expect(starts).toEqual(['first', 'second']) + secondParsed.resolve() + await second + expect(terminal.buffer.active.viewportY).toBe(280) + }) + + it('lets user viewport intent observed during replay supersede the old pin', async () => { + const terminal = createTerminal(80, 100) + markTerminalPinnedViewport(terminal) + const coordinator = createTerminalStructuralReplayCoordinator(terminal) + const parsed = deferred() + + const completion = coordinator.run(async () => { + terminal.buffer.active.viewportY = 0 + terminal.buffer.active.baseY = 0 + onTerminalScrollIntentBufferRebuildComplete(terminal, (completed) => { + if (completed) { + syncTerminalScrollIntentFromViewport(terminal, { allowBufferShrink: true }) + } + }) + await parsed.promise + terminal.buffer.active.viewportY = 190 + terminal.buffer.active.baseY = 200 + }) + + parsed.resolve() + await completion + expect(terminal.buffer.active.viewportY).toBe(190) + expect(terminal.scrollToLine).not.toHaveBeenCalled() + }) + + it('releases a rebuild on disposal without restoring a half-parsed buffer', async () => { + const terminal = createTerminal(80, 100) + markTerminalPinnedViewport(terminal) + const coordinator = createTerminalStructuralReplayCoordinator(terminal) + const neverParsed = new Promise(() => {}) + let postRebuildCompleted: boolean | null = null + + const completion = coordinator.run(async () => { + terminal.buffer.active.viewportY = 0 + terminal.buffer.active.baseY = 0 + onTerminalScrollIntentBufferRebuildComplete(terminal, (completed) => { + postRebuildCompleted = completed + }) + await neverParsed + }) + await Promise.resolve() + await Promise.resolve() + coordinator.dispose() + await completion + + expect(postRebuildCompleted).toBe(false) + expect(terminal.buffer.active.viewportY).toBe(0) + expect(terminal.scrollToLine).not.toHaveBeenCalled() + }) + + it('cancels a pre-existing fit restore before replay can make it stale', async () => { + const rafCallbacks: FrameRequestCallback[] = [] + const cancelAnimationFrame = vi.fn() + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + rafCallbacks.push(callback) + return rafCallbacks.length + }) + vi.stubGlobal('cancelAnimationFrame', cancelAnimationFrame) + const terminal = createTerminal(80, 100) as ReturnType & { + element: object | null + } + terminal.element = null + const staleState: ScrollState = { + bufferType: 'normal', + wasAtBottom: false, + viewportY: 20, + baseY: 100 + } + restoreScrollStateAfterFit(terminal as never, staleState, { + onRestored: vi.fn(), + shouldRestore: () => true + }) + expect(rafCallbacks).toHaveLength(1) + + markTerminalPinnedViewport(terminal) + const coordinator = createTerminalStructuralReplayCoordinator(terminal) + const parsed = deferred() + const completion = coordinator.run(async () => { + terminal.buffer.active.viewportY = 0 + terminal.buffer.active.baseY = 0 + await parsed.promise + terminal.buffer.active.viewportY = 200 + terminal.buffer.active.baseY = 200 + }) + await Promise.resolve() + await Promise.resolve() + expect(cancelAnimationFrame).toHaveBeenCalledWith(1) + + terminal.element = {} + parsed.resolve() + await completion + expect(terminal.buffer.active.viewportY).toBe(180) + rafCallbacks[0]?.(0) + expect(terminal.buffer.active.viewportY).toBe(180) + }) + + it('restores and releases replay when an optional completion listener throws', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const terminal = createTerminal(80, 100) + markTerminalPinnedViewport(terminal) + const coordinator = createTerminalStructuralReplayCoordinator(terminal) + const laterCompletion = vi.fn() + + await coordinator.run(() => { + terminal.buffer.active.viewportY = 200 + terminal.buffer.active.baseY = 200 + onTerminalScrollIntentBufferRebuildComplete(terminal, () => { + throw new Error('optional listener failed') + }) + onTerminalScrollIntentBufferRebuildComplete(terminal, laterCompletion) + }) + + expect(laterCompletion).toHaveBeenCalledWith(true) + expect(terminal.buffer.active.viewportY).toBe(180) + expect(isTerminalScrollIntentRebuildInFlight(terminal)).toBe(false) + expect(consoleError).toHaveBeenCalledWith( + '[terminal] scroll-intent rebuild completion failed', + expect.any(Error) + ) + }) + + it('keeps later replay work serialized behind an asynchronous post-restore fit', async () => { + const terminal = createTerminal(80, 100) + const coordinator = createTerminalStructuralReplayCoordinator(terminal) + const fitCompleted = deferred() + const fitStarted = deferred() + const events: string[] = [] + + const first = coordinator.run( + () => { + events.push('first-replay') + }, + { + afterRestore: async () => { + events.push('first-fit') + fitStarted.resolve() + await fitCompleted.promise + } + } + ) + const second = coordinator.run(() => { + events.push('second-replay') + }) + + await fitStarted.promise + expect(events).toEqual(['first-replay', 'first-fit']) + fitCompleted.resolve() + await first + await second + expect(events).toEqual(['first-replay', 'first-fit', 'second-replay']) + }) + + it('releases an asynchronous post-restore wait when the coordinator is disposed', async () => { + const terminal = createTerminal(80, 100) + const coordinator = createTerminalStructuralReplayCoordinator(terminal) + const fitNeverCompletes = new Promise(() => {}) + const fitStarted = deferred() + const completion = coordinator.run(() => undefined, { + afterRestore: async () => { + fitStarted.resolve() + await fitNeverCompletes + } + }) + + await fitStarted.promise + coordinator.dispose() + + await expect(completion).resolves.toBeUndefined() + }) +}) diff --git a/src/renderer/src/lib/pane-manager/terminal-structural-replay-coordinator.ts b/src/renderer/src/lib/pane-manager/terminal-structural-replay-coordinator.ts new file mode 100644 index 00000000000..35aee56a5cd --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-structural-replay-coordinator.ts @@ -0,0 +1,91 @@ +import { + captureTerminalStructuralScrollIntent, + restoreTerminalStructuralScrollIntent, + type TerminalScrollIntentTarget +} from './terminal-scroll-intent' +import { + beginTerminalScrollIntentBufferRebuild, + cancelTerminalScrollIntentBufferRebuildCompletions, + endTerminalScrollIntentBufferRebuild +} from './terminal-scroll-intent-rebuild' +import { cancelDeferredScrollRestore } from './pane-scroll' + +type StructuralReplayTask = () => void | Promise + +type StructuralReplayOptions = { + shouldRestore?: () => boolean + afterRestore?: () => void | Promise +} + +export type TerminalStructuralReplayCoordinator = { + run: (task: StructuralReplayTask, options?: StructuralReplayOptions) => Promise + dispose: () => void +} + +// Why: clear-and-replay bytes parse later and can overlap. One pane-scoped +// queue prevents dimension changes and stale viewport restores from interleaving. +export function createTerminalStructuralReplayCoordinator( + terminal: TerminalScrollIntentTarget +): TerminalStructuralReplayCoordinator { + let disposed = false + let activeCancellation: (() => void) | null = null + let tail = Promise.resolve() + + const run = ( + task: StructuralReplayTask, + options: StructuralReplayOptions = {} + ): Promise => { + const completion = tail + .catch(() => undefined) + .then(async () => { + if (disposed) { + return + } + const intent = captureTerminalStructuralScrollIntent(terminal) + // Why: a pre-replay fit retry can otherwise run after this transaction + // and restore a stale marker over the authoritative replay viewport. + cancelDeferredScrollRestore(terminal) + beginTerminalScrollIntentBufferRebuild(terminal) + let cancelTask = (): void => {} + const cancellation = new Promise((resolve) => { + cancelTask = resolve + }) + activeCancellation = () => { + cancelTask() + } + try { + const taskCompletion = Promise.resolve(task()) + await Promise.race([taskCompletion, cancellation]) + } finally { + endTerminalScrollIntentBufferRebuild(terminal) + try { + const shouldRestore = !disposed && options.shouldRestore?.() !== false + if (shouldRestore) { + restoreTerminalStructuralScrollIntent(terminal, intent, { restoreBy: 'bottomOffset' }) + // Why: live bytes must remain serialized behind replay until any + // post-restore fit has produced the authoritative destination grid. + await Promise.race([Promise.resolve(options.afterRestore?.()), cancellation]) + } + } finally { + activeCancellation = null + } + } + }) + tail = completion + return completion + } + + return { + run, + dispose: () => { + if (disposed) { + return + } + disposed = true + // Why: a torn-down terminal may silently drop write callbacks. Release + // the rebuild without sampling its half-parsed buffer into the keyed pin. + cancelTerminalScrollIntentBufferRebuildCompletions(terminal) + activeCancellation?.() + } + } +} diff --git a/src/renderer/src/lib/pane-manager/terminal-viewport-scrollbar-sync.ts b/src/renderer/src/lib/pane-manager/terminal-viewport-scrollbar-sync.ts new file mode 100644 index 00000000000..b5d970e088d --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-viewport-scrollbar-sync.ts @@ -0,0 +1,29 @@ +import type { Terminal } from '@xterm/xterm' + +// Why: xterm 6 can leave its scrollbar thumb stale when ydisp is unchanged. +// A synchronous one-line jiggle updates the scrollbar without a visible paint. +export function forceTerminalViewportScrollbarSync(terminal: Terminal): void { + const buf = terminal.buffer.active + if (buf.viewportY >= buf.baseY) { + // Why: jiggle-scrolling at bottom makes xterm stop following active output + // after split-pane resizes; scrollToBottom already places the thumb there. + return + } + if (buf.viewportY > 0) { + safeScrollCall(() => terminal.scrollLines(-1)) + safeScrollCall(() => terminal.scrollLines(1)) + } else if (buf.viewportY < buf.baseY) { + safeScrollCall(() => terminal.scrollLines(1)) + safeScrollCall(() => terminal.scrollLines(-1)) + } +} + +function safeScrollCall(fn: () => void): void { + try { + fn() + } catch (error) { + if (!(error instanceof TypeError) || !/dimensions/.test(error.message)) { + throw error + } + } +} diff --git a/src/renderer/src/lib/pane-manager/xterm-user-scrolling-contract.test.ts b/src/renderer/src/lib/pane-manager/xterm-user-scrolling-contract.test.ts new file mode 100644 index 00000000000..2141e2bf187 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/xterm-user-scrolling-contract.test.ts @@ -0,0 +1,182 @@ +/** + * Contract test for xterm's native user-scrolling ownership (vendored + * 6.1.0-beta.287; @xterm/headless shares BufferService with @xterm/xterm). + * + * Orca's live PTY write path performs NO scroll-intent enforcement — it + * relies on xterm core keeping a scrolled-up viewport stable and following + * output at the bottom (BufferService.isUserScrolling, consumed atomically + * inside scroll()). App-side enforcement is scoped to structural operations + * (snapshot replay, remount, fit reflow) in terminal-scroll-intent.ts. + * + * If an xterm upgrade breaks any assertion here, the live write path loses + * its follow/pin semantics silently — fix the write path before bumping. + */ +import { describe, expect, it } from 'vitest' +import { Terminal } from '@xterm/headless' +import packageJson from '../../../../../package.json' +import { clearTerminalScrollbackAndFollowOutput } from './terminal-scrollback-clear' + +type TerminalWithBufferService = Terminal & { + _core?: { + _bufferService?: { isUserScrolling?: boolean } + coreService?: { onUserInput?: (listener: () => void) => { dispose: () => void } } + } +} + +function write(term: Terminal, data: string): Promise { + return new Promise((resolve) => term.write(data, resolve)) +} + +async function writeLines(term: Terminal, count: number, label: string): Promise { + for (let i = 0; i < count; i += 1) { + await write(term, `${label}${i}\r\n`) + } +} + +describe('xterm native user-scrolling contract (vendored 6.1.0-beta.287)', () => { + it('pins headless and renderer xterm to the same version', () => { + expect(packageJson.dependencies['@xterm/headless']).toBe( + packageJson.devDependencies['@xterm/xterm'] + ) + }) + + it('keeps a scrolled-up viewport stable while output is written', async () => { + const term = new Terminal({ rows: 10, cols: 40, scrollback: 1000, allowProposedApi: true }) + await writeLines(term, 30, 'line') + const buffer = term.buffer.active + expect(buffer.viewportY).toBe(buffer.baseY) + + term.scrollLines(-5) + const pinnedY = buffer.viewportY + expect(pinnedY).toBe(buffer.baseY - 5) + + await writeLines(term, 10, 'more') + expect(buffer.viewportY).toBe(pinnedY) + expect(buffer.baseY).toBe(pinnedY + 15) + }) + + it('treats a viewport one row above bottom as user-scrolling through output', async () => { + const term = new Terminal({ + rows: 10, + cols: 40, + scrollback: 1000, + allowProposedApi: true + }) as TerminalWithBufferService + await writeLines(term, 30, 'line') + const buffer = term.buffer.active + + term.scrollLines(-1) + const pinnedY = buffer.viewportY + expect(pinnedY).toBe(buffer.baseY - 1) + expect(term._core?._bufferService?.isUserScrolling).toBe(true) + + await writeLines(term, 5, 'more') + expect(buffer.viewportY).toBe(pinnedY) + }) + + it('follows output at the bottom and re-follows after scrolling back down', async () => { + const term = new Terminal({ rows: 10, cols: 40, scrollback: 1000, allowProposedApi: true }) + await writeLines(term, 30, 'line') + const buffer = term.buffer.active + + await writeLines(term, 5, 'tail') + expect(buffer.viewportY).toBe(buffer.baseY) + + term.scrollLines(-5) + term.scrollToBottom() + await writeLines(term, 5, 'after') + expect(buffer.viewportY).toBe(buffer.baseY) + }) + + it('applies scrollOnUserInput before notifying onData listeners', async () => { + const term = new Terminal({ rows: 10, cols: 40, scrollback: 1000, allowProposedApi: true }) + await writeLines(term, 30, 'line') + const buffer = term.buffer.active + term.scrollLines(-5) + let viewportSeenByOnData = -1 + const subscription = term.onData(() => { + viewportSeenByOnData = buffer.viewportY + }) + + term.input('a', true) + + // Why: Orca resyncs typing intent synchronously from onData, so this + // xterm ordering is part of the pinned-version contract. + expect(viewportSeenByOnData).toBe(buffer.baseY) + subscription.dispose() + }) + + it('distinguishes real user input from parser auto-replies', async () => { + const term = new Terminal({ + rows: 10, + cols: 40, + allowProposedApi: true + }) as TerminalWithBufferService + expect(term._core?.coreService?.onUserInput).toBeTypeOf('function') + let userInputCount = 0 + const subscription = term._core?.coreService?.onUserInput?.(() => { + userInputCount += 1 + }) + + term.input('a', true) + await write(term, '\x1b[6n') + + expect(userInputCount).toBe(1) + subscription?.dispose() + }) + + it('walks a pinned viewport down content-stably when scrollback trims', async () => { + const term = new Terminal({ rows: 5, cols: 20, scrollback: 20, allowProposedApi: true }) + await writeLines(term, 30, 'x') + const buffer = term.buffer.active + term.scrollLines(-10) + const pinnedY = buffer.viewportY + const fullBaseY = buffer.baseY + + await writeLines(term, 10, 'trim') + // Buffer is at capacity: baseY stays put while each trimmed line shifts + // the pinned viewport up by one so the visible content does not move. + expect(buffer.baseY).toBe(fullBaseY) + expect(buffer.viewportY).toBe(Math.max(0, pinnedY - 10)) + }) + + it('exposes the isUserScrolling flag the structural restore paths depend on', async () => { + const term = new Terminal({ + rows: 10, + cols: 40, + scrollback: 1000, + allowProposedApi: true + }) as TerminalWithBufferService + await writeLines(term, 30, 'line') + const bufferService = term._core?._bufferService + expect(typeof bufferService?.isUserScrolling).toBe('boolean') + + // scrollLines/scrollToBottom self-manage the flag, so Orca's programmatic + // scroll restores inherit xterm's native live-output ownership. + expect(bufferService?.isUserScrolling).toBe(false) + term.scrollLines(-5) + expect(bufferService?.isUserScrolling).toBe(true) + term.scrollToBottom() + expect(bufferService?.isUserScrolling).toBe(false) + }) + + it('resets native user-scrolling when a pinned scrollback is cleared', async () => { + const term = new Terminal({ + rows: 10, + cols: 40, + scrollback: 1000, + allowProposedApi: true + }) as TerminalWithBufferService + await writeLines(term, 30, 'line') + term.scrollLines(-5) + expect(term._core?._bufferService?.isUserScrolling).toBe(true) + + clearTerminalScrollbackAndFollowOutput(term) + expect(term.buffer.active.viewportY).toBe(0) + expect(term.buffer.active.baseY).toBe(0) + expect(term._core?._bufferService?.isUserScrolling).toBe(false) + + await writeLines(term, 15, 'after-clear') + expect(term.buffer.active.viewportY).toBe(term.buffer.active.baseY) + }) +}) diff --git a/tests/e2e/terminal-codex-skill-preview-artifact-repro.spec.ts b/tests/e2e/terminal-codex-skill-preview-artifact-repro.spec.ts index 1365e54fa90..a0ced05497f 100644 --- a/tests/e2e/terminal-codex-skill-preview-artifact-repro.spec.ts +++ b/tests/e2e/terminal-codex-skill-preview-artifact-repro.spec.ts @@ -32,7 +32,7 @@ const CODEX_TRUST_PROMPT_RE = const CODEX_UPDATE_PROMPT_RE = /update available|install update|Skip for now|Skip until next/i const CODEX_SKILL_PREVIEW_RE = /Press enter to insert|esc to close|electron|orca-cli|orca-emulator/i const SETUP_PANE_ACTIVITY_RE = /install-orca-skills|pnpm|Progress:|Packages:|Lockfile/i -const CLEAN_SKILL_ROW_RE = /^ [A-Za-z][A-Za-z0-9 -]{1,32}\s+\[Skill\]\s/ +const CLEAN_SKILL_ROW_RE = /^ [A-Za-z][A-Za-z0-9 .-]{1,32}\s+\[Skill\]\s/ const CODEX_READY_SETTLE_MS = 3_500 const SETUP_CHANGES_AFTER_PREVIEW = 3 @@ -44,6 +44,12 @@ type PaneDescriptor = { rect: { x: number; y: number; width: number; height: number } cols: number rows: number + proposed: { cols: number; rows: number } | null + appliedPtySize: { cols: number; rows: number } | null + viewportY: number + baseY: number + isUserScrolling: boolean | null + screenToPaneGap: number | null hasWebgl: boolean } @@ -259,7 +265,7 @@ async function forceTerminalWebgl(page: Page): Promise { } async function describeActiveTerminalPanes(page: Page): Promise { - return page.evaluate(() => { + return page.evaluate(async () => { const state = window.__store?.getState() const worktreeId = state?.activeWorktreeId const tabId = @@ -274,29 +280,54 @@ async function describeActiveTerminalPanes(page: Page): Promise { - const aRect = a.container.getBoundingClientRect() - const bRect = b.container.getBoundingClientRect() - return aRect.x - bRect.x || aRect.y - bRect.y - }) - .map((pane) => { - if (!pane.container.dataset.ptyId) { - throw new Error(`Terminal pane ${pane.id} has no PTY binding`) - } - const rect = pane.container.getBoundingClientRect() - const rendering = diagnostics.find((diagnostic) => diagnostic.paneId === pane.id) - return { - tabId, - paneId: pane.id, - leafId: pane.leafId, - ptyId: pane.container.dataset.ptyId, - rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, - cols: pane.terminal.cols, - rows: pane.terminal.rows, - hasWebgl: rendering?.hasWebgl ?? false - } - }) + return Promise.all( + [...panes] + .sort((a, b) => { + const aRect = a.container.getBoundingClientRect() + const bRect = b.container.getBoundingClientRect() + return aRect.x - bRect.x || aRect.y - bRect.y + }) + .map(async (pane) => { + const ptyId = pane.container.dataset.ptyId + if (!ptyId) { + throw new Error(`Terminal pane ${pane.id} has no PTY binding`) + } + const rect = pane.container.getBoundingClientRect() + const screenRect = pane.container + .querySelector('.xterm-screen') + ?.getBoundingClientRect() + const rendering = diagnostics.find((diagnostic) => diagnostic.paneId === pane.id) + let proposed: { cols: number; rows: number } | null = null + try { + proposed = pane.fitAddon.proposeDimensions() ?? null + } catch { + proposed = null + } + const appliedPtySize = await window.api.pty.getSize(ptyId).catch(() => null) + const terminalCore = pane.terminal as typeof pane.terminal & { + _core?: { _bufferService?: { isUserScrolling?: boolean } } + } + return { + tabId, + paneId: pane.id, + leafId: pane.leafId, + ptyId, + rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, + cols: pane.terminal.cols, + rows: pane.terminal.rows, + proposed, + appliedPtySize, + viewportY: pane.terminal.buffer.active.viewportY, + baseY: pane.terminal.buffer.active.baseY, + isUserScrolling: + typeof terminalCore._core?._bufferService?.isUserScrolling === 'boolean' + ? terminalCore._core._bufferService.isUserScrolling + : null, + screenToPaneGap: screenRect ? rect.right - screenRect.right : null, + hasWebgl: rendering?.hasWebgl ?? false + } + }) + ) }) } @@ -490,6 +521,10 @@ async function captureClickEvidence( const beforeWindowPath = persistEvidenceFile('full-window-before-click.png', beforeFullPage) const afterPanePath = persistEvidenceFile('left-pane-after-click.png', afterPane) const bufferPath = persistEvidenceFile('left-pane-buffer.txt', beforeContent) + const metricsPath = persistEvidenceFile( + 'left-pane-metrics.json', + `${JSON.stringify(pane, null, 2)}\n` + ) await testInfo.attach('codex-skill-preview-left-pane-before-click', { body: beforePane, @@ -509,7 +544,13 @@ async function captureClickEvidence( }) testInfo.annotations.push({ type: 'codex-skill-preview-evidence-files', - description: JSON.stringify({ beforePanePath, beforeWindowPath, afterPanePath, bufferPath }) + description: JSON.stringify({ + beforePanePath, + beforeWindowPath, + afterPanePath, + bufferPath, + metricsPath + }) }) return { @@ -563,6 +604,9 @@ test.describe('Codex skill preview terminal artifact repro @headful', () => { const rightPane = await getRightTerminalPane(orcaPage) expect(leftPane.hasWebgl).toBe(true) expect(rightPane.hasWebgl).toBe(true) + expect(leftPane.proposed).toEqual({ cols: leftPane.cols, rows: leftPane.rows }) + expect(leftPane.appliedPtySize).toEqual({ cols: leftPane.cols, rows: leftPane.rows }) + expect(leftPane.isUserScrolling).toBe(false) await waitForPaneContent( orcaPage, rightPane.tabId, diff --git a/tests/e2e/terminal-pinned-viewport-streaming-switch.spec.ts b/tests/e2e/terminal-pinned-viewport-streaming-switch.spec.ts new file mode 100644 index 00000000000..23672bf7dd7 --- /dev/null +++ b/tests/e2e/terminal-pinned-viewport-streaming-switch.spec.ts @@ -0,0 +1,210 @@ +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 { nodeTerminalCommand } from './terminal-node-command' +import { waitForPtyShellEcho } from './terminal-pty-readiness' + +// A Codex-like agent: pre-fills scrollback, then keeps streaming — commits a +// row and redraws a synchronized-output "Working…" status frame every tick. +// The stream continues while the pane is hidden, which is what routes the +// return through the hidden-output snapshot restore. +function streamingAgentFixtureScript(runId: string): string { + return ` +async function writeStdout(chunk) { + await new Promise((resolve) => process.stdout.write(chunk, resolve)) +} +let row = 0 +let pre = '' +for (; row < 300; row += 1) { + pre += 'STREAMING_SWITCH_${runId}_ROW_' + String(row).padStart(4, '0') + '\\n' +} +await writeStdout(pre + 'STREAMING_SWITCH_${runId}_PRESTREAM_DONE\\n') +const spinner = ['|', '/', '-', '\\\\'] +for (let tick = 0; tick < 800; tick += 1) { + let frame = '\\x1b[?2026h' + if (tick % 3 === 0) { + frame += '\\r\\x1b[2KSTREAMING_SWITCH_${runId}_ROW_' + String(row).padStart(4, '0') + '\\n' + row += 1 + } + frame += '\\r\\x1b[2KWorking… ' + spinner[tick % 4] + ' tick=' + tick + '\\x1b[?2026l' + await writeStdout(frame) + await new Promise((resolve) => setTimeout(resolve, 50)) +} +` +} + +async function closeFeatureTips(page: Page): Promise { + await page.evaluate(() => { + const store = window.__store + store?.getState().markFeatureTipsSeen(['orca-cli', 'cmd-j-palette', 'voice-dictation']) + if (store?.getState().activeModal === 'feature-tips') { + store.getState().closeModal() + } + }) +} + +async function pinActiveTerminalNearBottom(page: Page): Promise<{ + tabId: string + targetViewportY: number + baseY: number +}> { + return page.evaluate(() => { + const store = window.__store + const state = store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!tabId || !pane) { + throw new Error('Active terminal pane unavailable') + } + const target = pane.container.querySelector('.xterm') ?? pane.container + target.dispatchEvent( + new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + deltaMode: WheelEvent.DOM_DELTA_PIXEL, + deltaY: -240 + }) + ) + const buffer = pane.terminal.buffer.active + const targetViewportY = Math.max(0, buffer.baseY - 6) + pane.terminal.scrollToLine(targetViewportY) + pane.container + .querySelector('.xterm-viewport') + ?.dispatchEvent(new Event('scroll', { bubbles: true })) + return { tabId, targetViewportY, baseY: buffer.baseY } + }) +} + +async function readSettledViewport( + page: Page, + tabId: string +): Promise<{ viewportY: number; baseY: number }> { + // Wait until the replay has actually parsed (scrollback regrew) and the + // viewport stopped moving, then report where it settled. + let last: { viewportY: number; baseY: number } | null = null + let stableCount = 0 + await expect + .poll( + async () => { + const current = await page.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + const buffer = pane?.terminal?.buffer?.active + return buffer ? { viewportY: buffer.viewportY, baseY: buffer.baseY } : null + }, tabId) + if (!current || current.baseY < 100) { + stableCount = 0 + last = current + return false + } + if (last && current.viewportY === last.viewportY) { + stableCount += 1 + } else { + stableCount = 0 + } + last = current + return stableCount >= 3 + }, + { + timeout: 20_000, + intervals: [250], + message: 'terminal viewport did not settle after returning to the streaming worktree' + } + ) + .toBe(true) + if (!last) { + throw new Error('viewport settle poll finished without a sample') + } + return last +} + +test.describe('Terminal pinned viewport with streaming agent across worktree switch', () => { + test('returning to a pinned pane with an active stream does not land at the top', async ({ + orcaPage, + testRepoPath + }) => { + await waitForSessionReady(orcaPage) + await closeFeatureTips(orcaPage) + const firstWorktreeId = await waitForActiveWorktree(orcaPage) + const secondWorktreeId = (await getAllWorktreeIds(orcaPage)).find( + (id) => id !== firstWorktreeId + ) + test.skip(!secondWorktreeId, 'streaming pinned 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 runId = randomUUID() + const scriptPath = path.join(testRepoPath, `.orca-streaming-switch-${runId}.mjs`) + writeFileSync(scriptPath, streamingAgentFixtureScript(runId)) + + try { + await sendToTerminal(orcaPage, ptyId, `${nodeTerminalCommand([scriptPath])}\r`) + await expect + .poll(() => getTerminalContent(orcaPage, 30_000), { + timeout: 15_000, + message: 'streaming fixture did not reach terminal scrollback' + }) + .toContain(`STREAMING_SWITCH_${runId}_PRESTREAM_DONE`) + + const pinned = await pinActiveTerminalNearBottom(orcaPage) + expect(pinned.baseY).toBeGreaterThan(100) + await orcaPage.waitForTimeout(150) + + // Stream continues while hidden; hidden byte drops mark the pane for a + // snapshot restore on return. + await switchToWorktree(orcaPage, secondWorktreeId) + await waitForActiveTerminalManager(orcaPage, 30_000) + await orcaPage.waitForTimeout(3_000) + + await switchToWorktree(orcaPage, firstWorktreeId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + const settled = await readSettledViewport(orcaPage, pinned.tabId) + const bottomDistance = settled.baseY - settled.viewportY + // The user pinned six rows above the bottom. A faithful restore keeps + // them near the pin; the bug clamps to the very top of the scrollback. + expect( + settled.viewportY, + `settled at viewportY=${settled.viewportY} baseY=${settled.baseY} (pinned ${JSON.stringify(pinned)})` + ).toBeGreaterThan(20) + expect( + bottomDistance, + `settled ${bottomDistance} rows above the bottom (pinned 6 rows above)` + ).toBeGreaterThan(1) + expect( + bottomDistance, + `settled ${bottomDistance} rows above the bottom (pinned 6 rows above)` + ).toBeLessThan(80) + } finally { + rmSync(scriptPath, { force: true }) + } + }) +}) diff --git a/tests/e2e/terminal-scroll-intent-follow.spec.ts b/tests/e2e/terminal-scroll-intent-follow.spec.ts index d3873805948..c4d8b171fed 100644 --- a/tests/e2e/terminal-scroll-intent-follow.spec.ts +++ b/tests/e2e/terminal-scroll-intent-follow.spec.ts @@ -5,9 +5,11 @@ import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } fro import { execInTerminal, sendToTerminal, + waitForActivePaneHookDescriptor, waitForActivePanePtyId, waitForActiveTerminalManager } from './helpers/terminal' +import { waitForTerminalPtyDataInjector } from './helpers/terminal-pty-injection' const STREAMING_FIXTURE_PATH = path.join( process.cwd(), @@ -72,6 +74,32 @@ async function waitForMarkerAtBottom(page: Page, marker: string): Promise async function dispatchSubRowWheelUp(page: Page): Promise { await page.evaluate(() => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!pane?.terminal.element) { + throw new Error('Active terminal pane unavailable') + } + pane.terminal.element.dispatchEvent( + new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + deltaMode: WheelEvent.DOM_DELTA_PIXEL, + deltaY: -2 + }) + ) + }) +} + +async function dispatchRealWheel(page: Page, deltaY: number): Promise { + const point = await page.evaluate(() => { const state = window.__store?.getState() const worktreeId = state?.activeWorktreeId const tabId = @@ -90,19 +118,13 @@ async function dispatchSubRowWheelUp(page: Page): Promise { throw new Error('Active terminal screen unavailable') } const rect = screen.getBoundingClientRect() - // A -2px delta is far below one cell height: xterm scrolls zero rows, the - // viewport stays at the bottom, but the wheel listener still observes an - // upward wheel — the phantom-pin shape from trackpad jitter. - const event = new WheelEvent('wheel', { - bubbles: true, - cancelable: true, - clientX: rect.left + rect.width / 2, - clientY: rect.top + Math.min(rect.height - 1, 40), - deltaMode: WheelEvent.DOM_DELTA_PIXEL, - deltaY: -2 - }) - pane.terminal.element.dispatchEvent(event) + return { + x: rect.left + rect.width / 2, + y: rect.top + Math.min(rect.height - 1, 40) + } }) + await page.mouse.move(point.x, point.y) + await page.mouse.wheel(0, deltaY) } async function dispatchPlainHomeKeydown(page: Page): Promise { @@ -142,6 +164,60 @@ async function dispatchPlainHomeKeydown(page: Page): Promise { }) } +async function injectQueuedWriteThenType(page: Page, paneKey: string): Promise { + await page.evaluate((targetPaneKey) => { + const injectionTarget = window as Window & { + __terminalPtyDataInjection?: { inject: (paneKey: string, data: string) => boolean } + } + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!pane) { + throw new Error('Active terminal pane unavailable') + } + const terminal = pane.terminal + const originalWrite = terminal.write + const holder: { write: { data: string; callback?: () => void } | null } = { write: null } + terminal.write = ((data: string, callback?: () => void) => { + holder.write = { data, callback } + }) as typeof terminal.write + try { + const payload = '\x1b[?2026h\r\x1b[2KWorking in-flight\x1b[?2026l' + if (!injectionTarget.__terminalPtyDataInjection?.inject(targetPaneKey, payload)) { + throw new Error('PTY injector unavailable') + } + const textarea = pane.container.querySelector('.xterm-helper-textarea') + if (!textarea) { + throw new Error('xterm helper textarea unavailable') + } + textarea.focus() + const event = new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + key: 'x', + code: 'KeyX' + }) + Object.defineProperty(event, 'keyCode', { configurable: true, value: 88 }) + Object.defineProperty(event, 'which', { configurable: true, value: 88 }) + textarea.dispatchEvent(event) + } finally { + terminal.write = originalWrite + } + const heldWrite = holder.write + if (!heldWrite) { + throw new Error('Foreground terminal write was not captured') + } + originalWrite.call(terminal, heldWrite.data, heldWrite.callback) + }, paneKey) +} + async function startStreamingFixturePhase1(page: Page): Promise { await waitForSessionReady(page) await waitForActiveWorktree(page) @@ -159,6 +235,8 @@ test.describe('terminal scroll intent keeps following output', () => { }) => { const ptyId = await startStreamingFixturePhase1(orcaPage) + // A -2px delta is far below one cell height: xterm scrolls zero rows, but + // the intent listener still observes the trackpad-jitter-shaped wheel. await dispatchSubRowWheelUp(orcaPage) await orcaPage.waitForTimeout(INTENT_SETTLE_WAIT_MS) @@ -177,4 +255,58 @@ test.describe('terminal scroll intent keeps following output', () => { await dispatchPlainHomeKeydown(orcaPage) await waitForMarkerAtBottom(orcaPage, 'STREAM_PHASE2_DONE') }) + + test('a real wheel pin stays fixed while visible output streams', async ({ orcaPage }) => { + const ptyId = await startStreamingFixturePhase1(orcaPage) + + await dispatchRealWheel(orcaPage, -240) + await expect + .poll(async () => { + const probe = await probeActiveViewport(orcaPage, 'STREAM_PHASE1_DONE') + return probe ? probe.baseY - probe.viewportY : 0 + }) + .toBeGreaterThan(1) + const pinned = await probeActiveViewport(orcaPage, 'STREAM_PHASE1_DONE') + if (!pinned) { + throw new Error('terminal viewport unavailable after wheel pin') + } + + await sendToTerminal(orcaPage, ptyId, 'g') + await expect + .poll( + async () => { + const probe = await probeActiveViewport(orcaPage, 'STREAM_PHASE2_DONE') + return Boolean(probe && probe.containsMarker && probe.viewportY === pinned.viewportY) + }, + { timeout: 30_000, message: 'visible streaming output moved the wheel-pinned viewport' } + ) + .toBe(true) + }) + + test('typing after a pinned write is queued resumes follow-output', async ({ orcaPage }) => { + await startStreamingFixturePhase1(orcaPage) + const { paneKey } = await waitForActivePaneHookDescriptor(orcaPage) + await waitForTerminalPtyDataInjector(orcaPage, paneKey) + + await dispatchRealWheel(orcaPage, -320) + await expect + .poll(async () => { + const probe = await probeActiveViewport(orcaPage, 'STREAM_PHASE1_DONE') + return probe ? probe.baseY - probe.viewportY : 0 + }) + .toBeGreaterThan(2) + + // Hold the xterm write call so typing deterministically lands between the + // old per-write intent capture and its completion-time enforcement from #8625. + await injectQueuedWriteThenType(orcaPage, paneKey) + await expect + .poll( + async () => { + const probe = await probeActiveViewport(orcaPage, 'STREAM_PHASE1_DONE') + return probe ? probe.baseY - probe.viewportY : Number.NaN + }, + { timeout: 5_000, intervals: [25] } + ) + .toBe(0) + }) }) From f62ad69260ba821e03bb9180fa733abec61c97a2 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:37:32 -0700 Subject: [PATCH 10/38] Click a link in orca browser should not open a new tab (#8782) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Route new-tab link clicks to Orca tabs instead of popup windows - Adds an isolated-world click/auxclick listener that relabels browser-native new-tab intents (target=_blank, cmd/ctrl-click, middle-click) with a private frame name, so setWindowOpenHandler can distinguish them from opener-dependent window.open() calls without breaking OAuth popups. - Wires matched clicks through to a new browser:open-link-in-orca-tab IPC payload carrying foreground/background activation intent, so the renderer opens a worktree tab instead of a native window. - Adds unit and e2e coverage for modifier/middle-click routing, cancellation/rewrite handling, and cleanup on guest teardown. * Route new-tab link clicks to Orca tabs and stop background popups Plain target=_blank clicks (main frame and iframes) now navigate the current Orca tab in place instead of opening a new browsing context, while explicit new-tab gestures (Cmd/Ctrl-click, middle-click, Shift+modifier) route into Orca tabs via one-use private frame names, including child frames. Drops the foreground/background frame-name split and the activate flag now that all routed links always open active — no more silent background popups from a plain link click. --- .../browser-clicked-link-routing.test.ts | 272 ++++++++++++++++++ .../browser/browser-clicked-link-routing.ts | 217 ++++++++++++++ src/main/browser/browser-manager.test.ts | 167 +++++++++++ src/main/browser/browser-manager.ts | 153 +++++++++- src/preload/index.ts | 4 +- src/renderer/src/hooks/useIpcEvents.ts | 6 +- tests/e2e/browser-tab.spec.ts | 234 +++++++++++++++ 7 files changed, 1045 insertions(+), 8 deletions(-) create mode 100644 src/main/browser/browser-clicked-link-routing.test.ts create mode 100644 src/main/browser/browser-clicked-link-routing.ts diff --git a/src/main/browser/browser-clicked-link-routing.test.ts b/src/main/browser/browser-clicked-link-routing.test.ts new file mode 100644 index 00000000000..eee5eea35f1 --- /dev/null +++ b/src/main/browser/browser-clicked-link-routing.test.ts @@ -0,0 +1,272 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { + buildBrowserClickedLinkRoutingScript, + buildBrowserIframeClickedLinkRoutingScript, + installBrowserIframeClickedLinkRouting, + installBrowserClickedLinkRouting +} from './browser-clicked-link-routing' + +const FOREGROUND_FRAME_NAME = '__orca_clicked_link_foreground_test' + +type RoutingGlobal = typeof globalThis & { + __orcaBrowserClickedLinkRouting?: { + listener: (event: MouseEvent) => void + } +} + +let cleanupIframeRouting: (() => void) | null = null + +function resetRouting(): void { + const routingGlobal = globalThis as RoutingGlobal + const state = routingGlobal.__orcaBrowserClickedLinkRouting + if (state) { + window.removeEventListener('click', state.listener) + window.removeEventListener('auxclick', state.listener) + } + delete routingGlobal.__orcaBrowserClickedLinkRouting + cleanupIframeRouting?.() + cleanupIframeRouting = null +} + +function installRouting(isMac = true): void { + installBrowserClickedLinkRouting(FOREGROUND_FRAME_NAME, isMac, true) +} + +function clickLink( + link: Element, + init: MouseEventInit & { type?: 'click' | 'auxclick' } = {} +): { event: MouseEvent; open: ReturnType } { + const open = vi.fn() + vi.spyOn(window, 'open').mockImplementation(open) + const { type = 'click', ...eventInit } = init + const event = new MouseEvent(type, { + bubbles: true, + cancelable: true, + button: type === 'auxclick' ? 1 : 0, + ...eventInit + }) + link.dispatchEvent(event) + return { event, open } +} + +describe('browser clicked-link routing', () => { + beforeEach(() => { + resetRouting() + document.head.innerHTML = '' + document.body.innerHTML = '' + }) + + afterEach(() => { + resetRouting() + vi.restoreAllMocks() + }) + + it('routes plain target=_blank links back into the current Orca tab', () => { + const link = document.createElement('a') + link.href = 'https://docs.example.com/guide' + link.target = '_blank' + document.body.append(link) + installRouting() + + const { event, open } = clickLink(link) + + expect(event.defaultPrevented).toBe(false) + expect(open).not.toHaveBeenCalled() + expect(link.getAttribute('target')).toBe('_self') + }) + + it('routes the host-platform modifier without trusting an emulated guest user agent', () => { + Object.defineProperty(navigator, 'userAgent', { + configurable: true, + value: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)' + }) + const link = document.createElement('a') + link.href = 'https://example.com/reference' + document.body.append(link) + installRouting(false) + + expect(clickLink(link, { metaKey: true }).open).not.toHaveBeenCalled() + expect(clickLink(link, { ctrlKey: true }).open).toHaveBeenCalledWith( + 'https://example.com/reference', + FOREGROUND_FRAME_NAME + ) + }) + + it('routes middle clicks and primary-modifier plus Shift clicks as new-tab intents', () => { + const link = document.createElement('a') + link.href = 'https://example.com/reference' + document.body.append(link) + installRouting() + + expect(clickLink(link, { type: 'auxclick' }).open).toHaveBeenCalledWith( + 'https://example.com/reference', + FOREGROUND_FRAME_NAME + ) + expect(clickLink(link, { metaKey: true, shiftKey: true }).open).toHaveBeenCalledWith( + 'https://example.com/reference', + FOREGROUND_FRAME_NAME + ) + }) + + it('honors page cancellation and observes link rewrites before routing', () => { + const cancelled = document.createElement('a') + cancelled.href = 'https://example.com/original' + cancelled.target = '_blank' + cancelled.addEventListener('click', (event) => event.preventDefault()) + const rewritten = document.createElement('a') + rewritten.href = 'https://example.com/original' + rewritten.target = '_blank' + rewritten.addEventListener('click', () => { + rewritten.href = 'https://example.com/rewritten' + }) + document.body.append(cancelled, rewritten) + installRouting() + + expect(clickLink(cancelled).open).not.toHaveBeenCalled() + expect(cancelled.getAttribute('target')).toBe('_blank') + expect(clickLink(rewritten).open).not.toHaveBeenCalled() + expect(rewritten.href).toBe('https://example.com/rewritten') + expect(rewritten.getAttribute('target')).toBe('_self') + }) + + it('routes SVG links but leaves download links and links without href alone', () => { + const svgLink = document.createElementNS('http://www.w3.org/2000/svg', 'a') + svgLink.setAttribute('href', 'https://example.com/svg') + svgLink.setAttribute('target', '_blank') + const areaDownload = document.createElement('area') + areaDownload.href = 'https://example.com/archive.zip' + areaDownload.target = '_blank' + areaDownload.download = 'archive.zip' + const noHref = document.createElement('a') + noHref.target = '_blank' + document.body.append(svgLink, areaDownload, noHref) + installRouting() + + expect(clickLink(svgLink).open).not.toHaveBeenCalled() + expect(svgLink.getAttribute('target')).toBe('_self') + expect(clickLink(areaDownload).open).not.toHaveBeenCalled() + expect(clickLink(noHref).open).not.toHaveBeenCalled() + }) + + it('leaves ordinary same-tab, Shift-window, Alt, and unsafe links to Chromium', () => { + const sameTab = document.createElement('a') + sameTab.href = 'https://example.com/current' + const shifted = document.createElement('a') + shifted.href = 'https://example.com/window' + shifted.target = '_blank' + const altClicked = document.createElement('a') + altClicked.href = 'https://example.com/download' + altClicked.target = '_blank' + const unsafe = document.createElement('a') + unsafe.href = 'javascript:void(0)' + unsafe.target = '_blank' + document.body.append(sameTab, shifted, altClicked, unsafe) + installRouting() + + expect(clickLink(sameTab).open).not.toHaveBeenCalled() + expect(clickLink(shifted, { shiftKey: true }).open).not.toHaveBeenCalled() + expect(clickLink(altClicked, { altKey: true }).open).not.toHaveBeenCalled() + expect(clickLink(unsafe).open).not.toHaveBeenCalled() + }) + + it('updates an existing isolated-world installation without adding another listener', () => { + const addEventListener = vi.spyOn(window, 'addEventListener') + const link = document.createElement('a') + link.href = 'https://example.com/reference' + document.body.append(link) + installBrowserClickedLinkRouting('__orca_clicked_link_old_fg', true, true) + installBrowserClickedLinkRouting('__orca_clicked_link_new_fg', false, true) + + expect(addEventListener.mock.calls.filter(([event]) => event === 'click')).toHaveLength(1) + expect(clickLink(link, { ctrlKey: true }).open).toHaveBeenCalledWith( + 'https://example.com/reference', + '__orca_clicked_link_new_fg' + ) + }) + + it('builds a self-contained isolated-world script', () => { + const script = buildBrowserClickedLinkRoutingScript(FOREGROUND_FRAME_NAME, false) + + expect(script).toContain('installBrowserClickedLinkRouting') + expect(script).toContain(`"${FOREGROUND_FRAME_NAME}",false`) + expect(script).not.toContain('BrowserClickedLinkRoutingState') + }) + + it('routes plain iframe target=_blank links into the top-level guest', () => { + const link = document.createElement('a') + link.href = 'https://example.com/from-frame' + link.target = '_blank' + document.body.append(link) + cleanupIframeRouting = installBrowserIframeClickedLinkRouting(FOREGROUND_FRAME_NAME, true, true) + + const { event, open } = clickLink(link) + + expect(event.defaultPrevented).toBe(false) + expect(open).not.toHaveBeenCalled() + expect(link.getAttribute('target')).toBe('_top') + }) + + it('routes explicit iframe new-tab gestures through one-use frame names', () => { + const blank = document.createElement('a') + blank.href = 'https://example.com/new-context' + blank.target = '_blank' + document.body.append(blank) + cleanupIframeRouting = installBrowserIframeClickedLinkRouting(FOREGROUND_FRAME_NAME, true, true) + + expect(clickLink(blank, { metaKey: true }).open).toHaveBeenCalledWith( + 'https://example.com/new-context', + FOREGROUND_FRAME_NAME + ) + + cleanupIframeRouting = installBrowserIframeClickedLinkRouting(FOREGROUND_FRAME_NAME, true, true) + expect(clickLink(blank, { type: 'auxclick' }).open).toHaveBeenCalledWith( + 'https://example.com/new-context', + FOREGROUND_FRAME_NAME + ) + + cleanupIframeRouting = installBrowserIframeClickedLinkRouting(FOREGROUND_FRAME_NAME, true, true) + expect(clickLink(blank, { metaKey: true, shiftKey: true }).open).toHaveBeenCalledWith( + 'https://example.com/new-context', + FOREGROUND_FRAME_NAME + ) + }) + + it('leaves other-platform, Shift-window, and ordinary iframe clicks to Chromium', () => { + const blank = document.createElement('a') + blank.href = 'https://example.com/new-context' + blank.target = '_blank' + const ordinary = document.createElement('a') + ordinary.href = 'https://example.com/current' + document.body.append(blank, ordinary) + cleanupIframeRouting = installBrowserIframeClickedLinkRouting(FOREGROUND_FRAME_NAME, true, true) + + expect(clickLink(blank, { ctrlKey: true }).open).not.toHaveBeenCalled() + expect(clickLink(blank, { shiftKey: true }).open).not.toHaveBeenCalled() + expect(clickLink(ordinary).open).not.toHaveBeenCalled() + }) + + it('ignores synthetic main-frame and iframe clicks in production mode', () => { + const mainLink = document.createElement('a') + mainLink.href = 'https://example.com/main-synthetic' + const frameLink = document.createElement('a') + frameLink.href = 'https://example.com/frame-synthetic' + frameLink.target = '_blank' + document.body.append(mainLink, frameLink) + installBrowserClickedLinkRouting(FOREGROUND_FRAME_NAME, true) + cleanupIframeRouting = installBrowserIframeClickedLinkRouting(FOREGROUND_FRAME_NAME, true) + + expect(clickLink(mainLink, { metaKey: true }).open).not.toHaveBeenCalled() + expect(clickLink(frameLink).open).not.toHaveBeenCalled() + expect(frameLink.getAttribute('target')).toBe('_blank') + }) + + it('builds a self-contained iframe script with per-frame routing tokens', () => { + const script = buildBrowserIframeClickedLinkRoutingScript(FOREGROUND_FRAME_NAME, false) + + expect(script).toContain('installBrowserIframeClickedLinkRouting') + expect(script).toContain(`"${FOREGROUND_FRAME_NAME}",false`) + }) +}) diff --git a/src/main/browser/browser-clicked-link-routing.ts b/src/main/browser/browser-clicked-link-routing.ts new file mode 100644 index 00000000000..42a8bbf8232 --- /dev/null +++ b/src/main/browser/browser-clicked-link-routing.ts @@ -0,0 +1,217 @@ +export const BROWSER_CLICKED_LINK_ROUTING_WORLD_ID = 1208 + +type BrowserClickedLinkRoutingState = { + frameName: string + isMac: boolean + allowUntrustedEvents: boolean + listener: (event: MouseEvent) => void +} + +type BrowserClickedLinkRoutingGlobal = typeof globalThis & { + __orcaBrowserClickedLinkRouting?: BrowserClickedLinkRoutingState +} + +/** + * Re-expresses explicit new-tab link gestures with private frame names so main + * can distinguish them from opener-dependent window.open calls. + */ +export function installBrowserClickedLinkRouting( + frameName: string, + isMac: boolean, + allowUntrustedEvents = false +): void { + const routingGlobal = globalThis as BrowserClickedLinkRoutingGlobal + const existing = routingGlobal.__orcaBrowserClickedLinkRouting + if (existing) { + existing.frameName = frameName + existing.isMac = isMac + existing.allowUntrustedEvents = allowUntrustedEvents + return + } + + const state: BrowserClickedLinkRoutingState = { + frameName, + isMac, + allowUntrustedEvents, + listener: () => {} + } + state.listener = (event) => { + const primaryClick = event.type === 'click' && event.button === 0 + const middleClick = event.type === 'auxclick' && event.button === 1 + if ( + !(event instanceof MouseEvent) || + (!event.isTrusted && !state.allowUntrustedEvents) || + (!primaryClick && !middleClick) || + event.defaultPrevented || + event.altKey + ) { + return + } + + const link = event + .composedPath() + .find( + (target): target is Element => + target instanceof Element && + ((target.namespaceURI === 'http://www.w3.org/1999/xhtml' && + (target.localName === 'a' || target.localName === 'area')) || + (target.namespaceURI === 'http://www.w3.org/2000/svg' && target.localName === 'a')) + ) + if (!link || link.hasAttribute('download')) { + return + } + + const modifierClick = state.isMac ? event.metaKey : event.ctrlKey + const otherPlatformModifier = state.isMac ? event.ctrlKey : event.metaKey + if (otherPlatformModifier) { + return + } + // Shift alone is browser-native new-window intent; keep OAuth and other + // opener-dependent window flows in Orca's guarded popup window. + if (event.shiftKey && !modifierClick) { + return + } + + const baseTarget = document.querySelector('base[target]')?.getAttribute('target') ?? '' + const ownTarget = link.getAttribute('target') + const effectiveTarget = (ownTarget === null ? baseTarget : ownTarget).trim().toLowerCase() + const opensNewContext = middleClick || modifierClick + if (!opensNewContext && effectiveTarget !== '_blank') { + return + } + + const rawHref = + link.getAttribute('href') ?? link.getAttributeNS('http://www.w3.org/1999/xlink', 'href') + if (rawHref === null) { + return + } + + let targetUrl: URL + try { + targetUrl = new URL(rawHref, document.baseURI) + } catch { + return + } + if (targetUrl.protocol !== 'http:' && targetUrl.protocol !== 'https:') { + return + } + + if (!opensNewContext) { + // Why: changing only the browsing context keeps Chromium's native anchor + // navigation, including referrer policy, attribution, and history. + link.setAttribute('target', '_self') + return + } + + // Why: Electron reports direct link clicks and featureless window.open() + // with the same disposition. The private frame name preserves that one + // distinction without weakening OAuth popups that need window.opener. + event.preventDefault() + window.open(targetUrl.toString(), state.frameName) + } + routingGlobal.__orcaBrowserClickedLinkRouting = state + + // Why: page click handlers must get the first chance to cancel or rewrite a + // link; capture-phase interception breaks SPA routing and analytics handlers. + window.addEventListener('click', state.listener, false) + window.addEventListener('auxclick', state.listener, false) +} + +/** + * Keeps plain target=_blank clicks inside the top-level guest when Electron's + * isolated-world API cannot target a child frame. + */ +export function installBrowserIframeClickedLinkRouting( + frameName: string, + isMac: boolean, + allowUntrustedEvents = false +): () => void { + const listener = (event: MouseEvent): void => { + const primaryClick = event.type === 'click' && event.button === 0 + const middleClick = event.type === 'auxclick' && event.button === 1 + if ( + !(event instanceof MouseEvent) || + (!event.isTrusted && !allowUntrustedEvents) || + (!primaryClick && !middleClick) || + event.defaultPrevented || + event.altKey + ) { + return + } + + const link = event + .composedPath() + .find( + (target): target is Element => + target instanceof Element && + ((target.namespaceURI === 'http://www.w3.org/1999/xhtml' && + (target.localName === 'a' || target.localName === 'area')) || + (target.namespaceURI === 'http://www.w3.org/2000/svg' && target.localName === 'a')) + ) + if (!link || link.hasAttribute('download')) { + return + } + + const modifierClick = isMac ? event.metaKey : event.ctrlKey + const otherPlatformModifier = isMac ? event.ctrlKey : event.metaKey + if (otherPlatformModifier || (event.shiftKey && !modifierClick)) { + return + } + + const baseTarget = document.querySelector('base[target]')?.getAttribute('target') ?? '' + const ownTarget = link.getAttribute('target') + const effectiveTarget = (ownTarget === null ? baseTarget : ownTarget).trim().toLowerCase() + const opensNewContext = middleClick || modifierClick + if (!opensNewContext && effectiveTarget !== '_blank') { + return + } + + const rawHref = + link.getAttribute('href') ?? link.getAttributeNS('http://www.w3.org/1999/xlink', 'href') + if (rawHref === null) { + return + } + + let targetUrl: URL + try { + targetUrl = new URL(rawHref, document.baseURI) + } catch { + return + } + if (targetUrl.protocol !== 'http:' && targetUrl.protocol !== 'https:') { + return + } + + if (!opensNewContext) { + // Why: WebContents isolated worlds only cover the main frame. Rewriting + // to `_top` preserves native anchor semantics without opening a popup. + link.setAttribute('target', '_top') + return + } + + // Why: child-frame code runs in the page world, so each token is one-use. + // A page that observes a real click cannot replay it to create more tabs. + event.preventDefault() + cleanup() + window.open(targetUrl.toString(), frameName) + } + + const cleanup = (): void => { + window.removeEventListener('click', listener, false) + window.removeEventListener('auxclick', listener, false) + } + window.addEventListener('click', listener, false) + window.addEventListener('auxclick', listener, false) + return cleanup +} + +export function buildBrowserClickedLinkRoutingScript(frameName: string, isMac: boolean): string { + return `(${installBrowserClickedLinkRouting.toString()})(${JSON.stringify(frameName)},${JSON.stringify(isMac)});` +} + +export function buildBrowserIframeClickedLinkRoutingScript( + frameName: string, + isMac: boolean +): string { + return `void (${installBrowserIframeClickedLinkRouting.toString()})(${JSON.stringify(frameName)},${JSON.stringify(isMac)});` +} diff --git a/src/main/browser/browser-manager.test.ts b/src/main/browser/browser-manager.test.ts index 29d2c8f8edd..912923fc8f5 100644 --- a/src/main/browser/browser-manager.test.ts +++ b/src/main/browser/browser-manager.test.ts @@ -250,6 +250,165 @@ describe('browserManager', () => { expect(shellOpenExternalMock).not.toHaveBeenCalled() }) + it('keeps plain links current and routes explicit new-tab gestures to Orca tabs', async () => { + const rendererSendMock = vi.fn() + const executeJavaScriptInIsolatedWorldMock = vi.fn().mockResolvedValue(undefined) + const guest = { + id: 141, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock, + executeJavaScriptInIsolatedWorld: executeJavaScriptInIsolatedWorldMock + } + webContentsFromIdMock.mockImplementation((id: number) => { + if (id === guest.id) { + return guest + } + if (id === rendererWebContentsId) { + return { isDestroyed: vi.fn(() => false), send: rendererSendMock } + } + return null + }) + + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'browser-1', + webContentsId: guest.id, + rendererWebContentsId + }) + + const domReadyHandler = guestOnMock.mock.calls.find(([event]) => event === 'dom-ready')?.[1] as + | (() => void) + | undefined + domReadyHandler?.() + await vi.waitFor(() => expect(executeJavaScriptInIsolatedWorldMock).toHaveBeenCalledTimes(1)) + + const managerState = browserManager as unknown as { + clickedLinkFrameNameByGuestId: Map + } + const clickedLinkFrameName = managerState.clickedLinkFrameNameByGuestId.get(guest.id) + if (!clickedLinkFrameName) { + throw new Error('Expected a private clicked-link frame name') + } + expect(clickedLinkFrameName).toMatch(/^__orca_clicked_link_foreground_/) + expect(executeJavaScriptInIsolatedWorldMock).toHaveBeenCalledWith( + expect.any(Number), + [ + expect.objectContaining({ + code: expect.stringContaining( + `${JSON.stringify(clickedLinkFrameName)},${process.platform === 'darwin'})` + ) + }) + ], + false + ) + + const handler = guestSetWindowOpenHandlerMock.mock.calls[0][0] as (details: { + url: string + frameName: string + }) => { action: 'allow' | 'deny' } + expect( + handler({ + url: 'https://docs.example.com/guide', + frameName: clickedLinkFrameName + }) + ).toEqual({ action: 'deny' }) + + expect(rendererSendMock).toHaveBeenCalledWith('browser:open-link-in-orca-tab', { + browserPageId: 'browser-1', + url: 'https://docs.example.com/guide' + }) + expect(rendererSendMock).toHaveBeenCalledWith('browser:popup', { + browserPageId: 'browser-1', + origin: 'https://docs.example.com', + action: 'opened-in-orca' + }) + expect(openPopupWithOriginBarMock).not.toHaveBeenCalled() + expect(shellOpenExternalMock).not.toHaveBeenCalled() + }) + + it('routes child-frame gestures with one-use tokens', async () => { + const rendererSendMock = vi.fn() + const executeJavaScriptMock = vi.fn().mockResolvedValue(undefined) + const frameOnceMock = vi.fn() + const frame = { + parent: {}, + isDestroyed: vi.fn(() => false), + executeJavaScript: executeJavaScriptMock, + once: frameOnceMock, + off: vi.fn() + } + const guest = { + id: 142, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock, + executeJavaScriptInIsolatedWorld: vi.fn().mockResolvedValue(undefined) + } + webContentsFromIdMock.mockImplementation((id: number) => { + if (id === guest.id) { + return guest + } + if (id === rendererWebContentsId) { + return { isDestroyed: vi.fn(() => false), send: rendererSendMock } + } + return null + }) + + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'browser-frame', + webContentsId: guest.id, + rendererWebContentsId + }) + const frameCreatedHandler = guestOnMock.mock.calls.find( + ([event]) => event === 'frame-created' + )?.[1] as ((event: Electron.Event, details: Electron.FrameCreatedDetails) => void) | undefined + frameCreatedHandler?.({} as Electron.Event, { frame } as never) + const frameDomReadyHandler = frameOnceMock.mock.calls.find( + ([event]) => event === 'dom-ready' + )?.[1] as (() => void) | undefined + frameDomReadyHandler?.() + + await vi.waitFor(() => expect(executeJavaScriptMock).toHaveBeenCalledTimes(1)) + const firstScript = executeJavaScriptMock.mock.calls[0][0] as string + const foregroundFrameName = firstScript.match( + /__orca_clicked_link_iframe_foreground_[0-9a-f-]+/ + )?.[0] + if (!foregroundFrameName) { + throw new Error('Expected a private child-frame routing token') + } + expect(firstScript).toContain('installBrowserIframeClickedLinkRouting') + expect(executeJavaScriptMock).toHaveBeenCalledWith(firstScript, false) + + const popupHandler = guestSetWindowOpenHandlerMock.mock.calls[0][0] as (details: { + url: string + frameName: string + }) => { action: string } + expect( + popupHandler({ + url: 'https://docs.example.com/from-frame', + frameName: foregroundFrameName + }) + ).toEqual({ action: 'deny' }) + expect(rendererSendMock).toHaveBeenCalledWith('browser:open-link-in-orca-tab', { + browserPageId: 'browser-frame', + url: 'https://docs.example.com/from-frame' + }) + + await vi.waitFor(() => expect(executeJavaScriptMock).toHaveBeenCalledTimes(2)) + const secondScript = executeJavaScriptMock.mock.calls[1][0] as string + expect(secondScript).not.toContain(foregroundFrameName) + }) + it('hosts allowed popups in an origin-bar window with inherited guest policies', () => { const rendererSendMock = vi.fn() const guestOnceMock = vi.fn() @@ -320,6 +479,7 @@ describe('browserManager', () => { // attached directly here. expect(popupContents.setWindowOpenHandler).toHaveBeenCalledTimes(1) expect(popupContents.setBackgroundThrottling).toHaveBeenCalledWith(false) + expect(popupContents.on.mock.calls.some(([event]) => event === 'dom-ready')).toBe(false) // The renderer notice carries only the sanitized origin, never the URL. expect(rendererSendMock).toHaveBeenCalledWith('browser:popup', { browserPageId: 'browser-1', @@ -1000,12 +1160,19 @@ describe('browserManager', () => { webContentsId: 102, rendererWebContentsId }) + // Why: attach-before-registration teardown skips unregisterGuest, so global + // cleanup must independently release the private click-routing token. + browserManager.attachGuestPolicies({ ...guest, id: 103 } as never) browserManager.unregisterAll() expect(browserManager.getGuestWebContentsId('browser-1')).toBeNull() expect(browserManager.getGuestWebContentsId('browser-2')).toBeNull() expect(guestOffMock).toHaveBeenCalled() + const managerState = browserManager as unknown as { + clickedLinkFrameNameByGuestId: Map + } + expect(managerState.clickedLinkFrameNameByGuestId.size).toBe(0) }) it('rejects non-webview guest types to prevent privilege escalation', () => { diff --git a/src/main/browser/browser-manager.ts b/src/main/browser/browser-manager.ts index acd404f91ee..b9232bcd8d6 100644 --- a/src/main/browser/browser-manager.ts +++ b/src/main/browser/browser-manager.ts @@ -39,6 +39,11 @@ import { } from './browser-guest-ui' import { ANTI_DETECTION_SCRIPT } from './anti-detection' import { openPopupWithOriginBar, type PopupChildWindowOptions } from './popup-origin-bar-window' +import { + BROWSER_CLICKED_LINK_ROUTING_WORLD_ID, + buildBrowserClickedLinkRoutingScript, + buildBrowserIframeClickedLinkRoutingScript +} from './browser-clicked-link-routing' import { cleanElectronUserAgent } from './browser-session-ua' import type { BrowserViewportOverride } from '../../shared/types' import { @@ -146,7 +151,6 @@ type PopupOwnerContext = { browserTabId: string rootGuestWebContentsId: number } - const SAFE_POPUP_WINDOW_OPTIONS = { alwaysOnTop: false, closable: true, @@ -235,6 +239,7 @@ export class BrowserManager { private readonly worktreeIdByTabId = new Map() private readonly policyAttachedGuestIds = new Set() private readonly policyCleanupByGuestId = new Map void>() + private readonly clickedLinkFrameNameByGuestId = new Map() private shouldForwardDictationShortcut: (() => boolean) | null = null private readonly pendingLoadFailuresByGuestId = new Map< number, @@ -620,6 +625,15 @@ export class BrowserManager { if (inheritedOwnerContext) { this.popupOwnerContextByGuestId.set(guest.id, inheritedOwnerContext) } + // Why: OAuth child windows must retain normal link/window relationships; + // only the primary embedded browser converts new-tab clicks to Orca tabs. + const clickedLinkFrameName = inheritedOwnerContext + ? null + : `__orca_clicked_link_foreground_${randomUUID()}` + if (clickedLinkFrameName) { + this.clickedLinkFrameNameByGuestId.set(guest.id, clickedLinkFrameName) + } + let clickedLinkRoutingActive = Boolean(clickedLinkFrameName) // Why: Cloudflare Turnstile and similar bot detectors probe browser APIs // (navigator.webdriver, plugins, window.chrome) that differ in Electron @@ -632,16 +646,118 @@ export class BrowserManager { // Orca window is not the focused foreground app. With throttling enabled, // the compositor stops producing frames and capturePage() returns empty. guest.setBackgroundThrottling(false) + const installClickedLinkRouting = (): void => { + if (!clickedLinkRoutingActive || !clickedLinkFrameName || guest.isDestroyed()) { + return + } + // Why: an isolated-world click listener can label real anchor clicks + // without exposing the private frame name to untrusted page scripts. + void guest + .executeJavaScriptInIsolatedWorld( + BROWSER_CLICKED_LINK_ROUTING_WORLD_ID, + [ + { + // Why: mobile emulation spoofs the guest UA as iOS, so modifier + // routing must use the actual desktop host platform from main. + code: buildBrowserClickedLinkRoutingScript( + clickedLinkFrameName, + process.platform === 'darwin' + ) + } + ], + false + ) + .catch(() => {}) + } + if (clickedLinkFrameName) { + guest.on('dom-ready', installClickedLinkRouting) + } + const pendingIframeRoutingInstalls = new Map void>() + const iframeFrameNameByFrame = new Map() + const iframeFrameByFrameName = new Map() + const clearIframeFrameName = (frame: Electron.WebFrameMain): void => { + const name = iframeFrameNameByFrame.get(frame) + if (!name) { + return + } + iframeFrameNameByFrame.delete(frame) + iframeFrameByFrameName.delete(name) + } + const installIframeClickedLinkRouting = (frame: Electron.WebFrameMain): void => { + clearIframeFrameName(frame) + if (!clickedLinkRoutingActive || frame.isDestroyed()) { + return + } + const name = `__orca_clicked_link_iframe_foreground_${randomUUID()}` + iframeFrameNameByFrame.set(frame, name) + iframeFrameByFrameName.set(name, frame) + // Why: child-frame tokens live in the page world, so they are consumed + // after one trusted click and replaced before another can be routed. + void frame + .executeJavaScript( + buildBrowserIframeClickedLinkRoutingScript(name, process.platform === 'darwin'), + false + ) + .catch(() => { + if (iframeFrameNameByFrame.get(frame) === name) { + clearIframeFrameName(frame) + } + }) + } + const handleFrameCreated = ( + _event: Electron.Event, + { frame }: Electron.FrameCreatedDetails + ): void => { + if (!clickedLinkFrameName || !frame || frame.parent === null) { + return + } + for (const knownFrame of iframeFrameNameByFrame.keys()) { + if (knownFrame.isDestroyed()) { + clearIframeFrameName(knownFrame) + } + } + const installAfterDomReady = (): void => { + pendingIframeRoutingInstalls.delete(frame) + installIframeClickedLinkRouting(frame) + } + pendingIframeRoutingInstalls.set(frame, installAfterDomReady) + frame.once('dom-ready', installAfterDomReady) + } + if (clickedLinkFrameName) { + guest.on('frame-created', handleFrameCreated) + } const handleDidCreateWindow = (window: Electron.BrowserWindow): void => { // Why: popup descendants inherit the opener's owner context for routing, // but must not replace its primary guest registration. this.attachGuestPolicies(window.webContents, this.resolvePopupOwnerContext(guest.id)) } guest.on('did-create-window', handleDidCreateWindow) - guest.setWindowOpenHandler(({ url }) => { + guest.setWindowOpenHandler(({ url, frameName }) => { const browserTabId = this.resolveBrowserTabIdForGuestWebContentsId(guest.id) const browserUrl = normalizeBrowserNavigationUrl(url) const externalUrl = normalizeExternalBrowserUrl(url) + const expectedClickedLinkFrameName = this.clickedLinkFrameNameByGuestId.get(guest.id) + const iframeFrame = frameName ? iframeFrameByFrameName.get(frameName) : undefined + let isClickedLink = Boolean( + expectedClickedLinkFrameName && frameName === expectedClickedLinkFrameName + ) + if (!isClickedLink && iframeFrame) { + isClickedLink = true + clearIframeFrameName(iframeFrame) + queueMicrotask(() => installIframeClickedLinkRouting(iframeFrame)) + } + + if (isClickedLink) { + if (browserTabId && browserUrl && this.openLinkInOrcaTab(browserTabId, browserUrl)) { + this.forwardOrQueuePopupEvent(guest.id, { + origin: safeOrigin(browserUrl), + action: 'opened-in-orca' + }) + } + // Why: a recognized user gesture must never fall through to a native + // popup merely because its renderer disappeared during the click. + return { action: 'deny' } + } // Why: file URLs are valid for user-opened in-pane previews, but remote // content must not create native child windows targeting local paths. @@ -739,6 +855,19 @@ export class BrowserManager { try { guest.off('destroyed', handleDestroyed) guest.off('did-create-window', handleDidCreateWindow) + if (clickedLinkFrameName) { + clickedLinkRoutingActive = false + guest.off('dom-ready', installClickedLinkRouting) + guest.off('frame-created', handleFrameCreated) + for (const [frame, install] of pendingIframeRoutingInstalls) { + if (!frame.isDestroyed()) { + frame.off('dom-ready', install) + } + } + pendingIframeRoutingInstalls.clear() + iframeFrameNameByFrame.clear() + iframeFrameByFrameName.clear() + } } catch { // guest may already be destroyed } @@ -795,6 +924,7 @@ export class BrowserManager { this.policyCleanupByGuestId.delete(guestWebContentsId) } this.policyAttachedGuestIds.delete(guestWebContentsId) + this.clickedLinkFrameNameByGuestId.delete(guestWebContentsId) this.popupOwnerContextByGuestId.delete(guestWebContentsId) // Why: a popup must stop inheriting authorization as soon as its primary // owner is retired, even if Chromium has not destroyed the child yet. @@ -989,6 +1119,7 @@ export class BrowserManager { cleanup() } this.policyCleanupByGuestId.clear() + this.clickedLinkFrameNameByGuestId.clear() this.tabIdByWebContentsId.clear() this.popupOwnerContextByGuestId.clear() this.worktreeIdByTabId.clear() @@ -1881,6 +2012,24 @@ export class BrowserManager { } }) } + + private openLinkInOrcaTab(browserTabId: string, rawUrl: string): boolean { + const renderer = this.resolveRendererForBrowserTab(browserTabId) + if (!renderer) { + return false + } + const normalizedUrl = normalizeBrowserNavigationUrl(rawUrl) + if (!normalizedUrl || normalizedUrl === ORCA_BROWSER_BLANK_URL) { + return false + } + // Why: only the renderer owns Orca's worktree/tab model. Main forwards a + // validated URL instead of letting arbitrary guest content mutate it. + renderer.send('browser:open-link-in-orca-tab', { + browserPageId: browserTabId, + url: normalizedUrl + }) + return true + } } export const browserManager = new BrowserManager() diff --git a/src/preload/index.ts b/src/preload/index.ts index 699853f6c8d..48e05ac67f1 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2282,7 +2282,7 @@ const api = { callback: (event: { browserPageId: string origin: string - action: 'opened-external' | 'blocked' + action: 'opened-in-orca' | 'opened-external' | 'blocked' }) => void ): (() => void) => { const listener = ( @@ -2290,7 +2290,7 @@ const api = { data: { browserPageId: string origin: string - action: 'opened-external' | 'blocked' + action: 'opened-in-orca' | 'opened-external' | 'blocked' } ) => callback(data) ipcRenderer.on('browser:popup', listener) diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index b3e2dae6b17..661fcca0b42 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -2128,10 +2128,8 @@ export function useIpcEvents(): void { if (getRuntimeEnvironmentIdForWorktree(store, sourcePage.worktreeId)) { return } - // Why: the guest process can request "open this link in Orca", but it - // does not own Orca's worktree/tab model. Resolve the source page's - // worktree and create a new outer browser tab so the link opens as a - // separate tab in the outer Orca tab bar. + // Why: only the renderer owns Orca's tab model. Creating the tab with + // the default activation behavior brings the clicked link forward. store.createBrowserTab(sourcePage.worktreeId, url, { title: url }) }) ) diff --git a/tests/e2e/browser-tab.spec.ts b/tests/e2e/browser-tab.spec.ts index b61ca1541bc..1370fae41a0 100644 --- a/tests/e2e/browser-tab.spec.ts +++ b/tests/e2e/browser-tab.spec.ts @@ -121,6 +121,84 @@ async function startBrowserFormServer(): Promise<{ } } +async function startBrowserLinkServer(): Promise<{ + sourceUrl: string + close: () => Promise +}> { + const server = createServer((request, response) => { + const origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}` + const pathname = new URL(request.url ?? '/', origin).pathname + response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }) + if (pathname === '/destination') { + response.end( + `Linked destinationDestination Return` + ) + return + } + if (pathname === '/frame-destination') { + response.end( + `Frame destinationFrame destination Return` + ) + return + } + if (pathname === '/frame-modifier-destination') { + response.end( + 'Frame modifier destinationFrame modifier destination' + ) + return + } + if (pathname === '/frame-middle-destination') { + response.end( + 'Frame middle destinationFrame middle destination' + ) + return + } + if (pathname === '/frame') { + response.end( + `Open frame destinationOpen frame modifier destinationOpen frame middle destination` + ) + return + } + if (pathname === '/modifier-destination') { + response.end( + 'Modifier destinationModifier destination' + ) + return + } + if (pathname === '/middle-destination') { + response.end( + 'Middle-click destinationMiddle-click destination' + ) + return + } + response.end(` + + + Source page + + Open destination + Open with modifier + Open with middle click + Handle in page + + + + + `) + }) + 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 11/38] 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 47ac259f1bb..1dff2ecccf0 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 625e89df7c0..bc002cf5741 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 12/38] 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 2cfae612636..18e99dac5a7 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 a833ea24f35..859a71bba1a 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 13/38] 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 | 116 +++++--- 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, 1383 insertions(+), 56 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 3f86c687245..0b11fd15557 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 a3dc0df5349..4392d64314c 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 00000000000..7dadf41c2d3 --- /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 fb2eefc3748..1359f5cce13 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 379bfbce1e0..11d9fd2dc20 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 3c89c5ff3a5..41d001daf58 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 a0791d6e038..a6fbd6a9514 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 87f2f8b63d4..98015aa64a7 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 3564b00b82b..57153e31fef 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 d407b9362d3..0a2d7821f91 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 f64a163cfe7..50f196f2669 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 3e7ba7e2791..da1bbfbe603 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 result = closeTerminalTabInWorkspaceSession(session, worktreeId, parentTabId) + if (result.pinned) { + throw new Error('terminal_tab_pinned') } - 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 - } - }) + 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 a14dc809c54..b1670590a9c 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 bb800b54617..15c592499f6 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 445a1f204e2..ec85e873b9f 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 62e1af606df..6dd4856b738 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 0dd54c2c598..21325c3e036 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 00000000000..49eee1ce299 --- /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 00000000000..c788f79a95b --- /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 9d5e0b1fd90..695e037379a 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 48e05ac67f1..64276047cea 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 ce310190eef..7c7bb511efc 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 3f41e8315bf..ebd861e6952 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 9e188a80805..86791576c1c 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 661fcca0b42..e84df727e4d 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 703c5405834..3c31296095a 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 d2bf7336a9e..6c6db493341 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 f59a827b729..ab7b04c2fed 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 5fa4365a344..61c0cb56066 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 41a800eb3da..774114b6d75 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 00000000000..55186d8046c --- /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 00000000000..66bb3d5dd63 --- /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 00000000000..2b0b17a9d3a --- /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 00000000000..10efd6491c7 --- /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 14/38] 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 ef9ca38678e..2b7469caf7b 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 f71ab7e35e1..e4509c9e6a6 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 4c5a25c635d..0fba648efc8 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 b49e75cba07..7ea55be7fca 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 00000000000..eecc60bddf5 --- /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 0d95641d5ec..b9a9a518e79 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 51b552f3436..c46bd3ea5c2 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 eecc7a6c974..aaad1b6a622 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 d6f3da3de84..10c11a9dde1 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 5bb212f2b48..d1219bad8dc 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 0a7505762d0..4ca67db446e 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 0693b2f8972..4be3cc90b3c 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 93e46c4db17..ca1ea34b3a8 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 b8c3048e9c5..dfb8d1df016 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 1c337a8db8d..3616a2e2900 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 15/38] 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 0dd6a3d85c7..8f67e1068b7 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 7d4fd9af56b..7ada984eb29 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 16/38] 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 c651aabada3..f7ffb83bfd6 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 00000000000..2ad8e260bdd --- /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 00000000000..5b7a4b799cf --- /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 00000000000..ba824696c9a --- /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 00000000000..ffd6816d4a6 --- /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 17/38] 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 e4a0e7ff895..44335aae86a 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 41cb70c5beb..0c0ee11511c 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 cce8ee85b67..4c182ee2ad5 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 1cf9ab31212..4b20b0c4a2f 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 5a19b9897b3..2ce24a87420 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 35d1d067ca1..a18eb2d02d3 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 695e037379a..2ce823028c5 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 64276047cea..912e1852cfa 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 0ade42f2811..af47d26736d 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 79ea48d8929..ad7a1197323 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 eff9f0cde24..0de9093185c 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 0501d05faf1..8427e6f4cf7 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 1abdf8130ef..5fa8b146a61 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 6c4707b5349..45e1afec7a4 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 a9c01fdbe14..c454ec9ce47 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 cb10041dfaa..b1f7cca3488 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 f8a6e220407..1e59503c12c 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 812e28b8233..a6c058ac9f7 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 fd084a3d985..4f6957366f2 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 7831bd18416..ad8e84ecbad 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 3616a2e2900..ab975939e25 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 18/38] 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 81b9a5c5aea..c3f65c069a1 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 19/38] 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 64cbb3d93ce..00000000000 --- 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 20/38] 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 c3f65c069a1..d07692e1edd 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^ zq7Uib($iCG0ktYt_zxo2MUSE~hPb)Z%Gu^7rpPB|YLMs}vHuaoy)Duz<}8pl*-(J1pX>tsavdKE1pgA%xYSye!Z0~ME70W74HOXrE+(KI+_|9Jk|4BH25{J0 z6cNIjlRThB8ud|j6f7G zk;~)Ac<>rSVlh&3Lw=0x2Pe~w-Rt1(BEud+X4?-?>0_Dk19~mHQQ=C50jch1w%km@ zGQ2FB;U3p{z1zohe7pZK(_@*^mhcC)9S2(L2oj#w6Y%0(X$b* z%d%-u%=)XGRWGnl7P<&LY&*4)f0g3Y1BG>tmAXoqSA08r7t)++$omGYtJu=23%3B(lpg4zQt0^X1#omy$0YS-#~Sm49jXo>w8g_K07p z#%w&AcS}~-1T7UxHpxB7zrvCT;g%&!`dS`26s{IiGn%GAZ)kf%_NiwB0%^>%ny$+J zYLBY7UCH)0-OWZWUw*lFPsi2$&ir&B&&{9c6)dMTqSE+Uz~mUoqY0}z`Pv6A;e~W{ z5>m|XLarrwcgn`%$AU{n+p>icBya`vzoXGzo|KSm=ivWakJ;oXXYPJb$XoT-ZVr@T zT4TJ#GN~*I+vwTD&Y_wRk=ne;w{X-lNOz}g29`?$R`d^fNwXnFRD)R~R#ABCUs4b2 z5&!75r@c1%et-VpF13HS3Qz}Kiz9mr`@vJSpJ$&f@#}I@WeV%|%wphPVNHl9cJx9V z-k7Eb56gaq+XV}nk?TWd_D)Rn0hK}*Z$48<0Q}+?W4wiorCi&CLxSd!C2eoqbM&YR zi2E&7y5-!(Xux_N78@s9_z&AV=c)_R+w1g0t{r7FT8lln0Y8rt4-F9`>nd+AN_^t_ z0*;LMq>4J@tn7`A8w|%6tp5UihzwJQ|uh)y5iH^j^>>#q?1`^#d)!7KnTz1i7@iU>IvmdlUQ-@X8yT!httA5|^YRLOunoP+ zC$?&)=xBA-I}9Jk%gyD;;<>S%vC(#qUgbSUS%P*9;CHnD2^a;obDi>E;9r4_(JhVx z%9OmVjvslP&ueRlD39VJ>n_CAg)STk@>y!TQ$01^+*L}W+G*hvh!r!JWkWCRbB7M9 zDJi5*PN@kLf1Twg0@(a4&o-58A;jw9DLbT|SuS1=1E(n_KnW4&x@Xpp%a9}mk9=ed z$0Zwj4oAljb^K0_*hJ68$&C}{=Rc-RZP{@Q`5v!Yw>-9J3C?Y*L|vGajQ65(n$eOn zu?f{5T6D_&v-v||8R?dQ`rHTI*~~4ksOY1HSIqL#Q_07}?#(zlNP}hyOSg{I)t)bn zMjZ!YwP~7cin~bARdrJtc?}{R%#ne{B-`;$F(!1TxldVt=RVJhuj}GT&*Hg8I|rUi^ih zByfz{?v&#mg+w{Znue~BcY|i^8^EIHeN^b(lKBnWg(MvsU9!~#f#j%kmyoJYiIumN z%c(4PqTEvvASE_k6FGYaeV`rW#<=Vh(bkblb!<=Mab;K}DgP&K`PD`V*7w;{#4;PU z>1LEif_^n>;$}47f7p&hL|$u{ZrU(YC~GFiH@|r-vIee0L(!)5f&L%;Ms4q>^&Wk6 zy>(Rb?lESS0=N; zr0coZ6i@oRjta3>Dz24I-t^6hoU{7&LI0xYL%%HO<(;FaQO#)N&;q`XC6NAyupD?T zRuF$7ErM298h}61`$51>KR+Pzw_ETm-$3&d;5wfSp;9TA*vDK*4x&C?6gI8(>>=oa?rG`(l z$zTOIDGn{Yd`(`wvk<+45p~g~$9iYhiuYP!B>#+H11agJnkok6Zj`;0v z+bgS9155iwphWO_Iryd)0VBo)bj#@)z0novK)z|p5RoXefV6(B@!D*;ndRy1b^t3C zgaSg1qpMqoxv{6`D?DIDr6WDf6JXr3JA5uT1oll5kzT%iRigJCHKmNlwx=t*z=<@_ zfJ@_sq^J&7OyqBc7E%C~HZyuGfgK9It*Wmljt$z^4TOnk{(XQubc=u^{rJoqo~911u-by&oj{ws%U`?pOK_K9F*3E$Un zXi1MY?95AXh}-Yh&-zMlXRPZfpE6bgw)(Om$r=|NNURQu>F0QNi@(%^+(*w`o(&mC zt;2tBPQ1TK2L{gMejwH0G^^~S3a3+pbKP!dfJ9`^y~U^$YiqV5oE>5F{g*Z)@nCUg zHE9_e^Y~U|(PZ>Z7MUFVOE&v9ACQAXZQFU=> z&IM!t`~o8RfG#rezlXE~4V?faxTtnIbP|VHc9hHF=*Qrlxum7dnr@BhtAPl!vz_Ow zd9ZN;%>eOkF}LfrtQUTKdB=>@!nf(27w91mJs%tc9Oz?3lR37f%d3h@M@J58|4jx% zG+$D*V=lP(e0XPe+2mqyQn4JJ$y_lVq3^8Jh(t>l8eafm%|NBlos)m=<-iQfzQR;- zYI5w&!Qup5Oc?sN$Xv0X^mux3U($c=paV;AHhSL8p1VEaAy|cx^H{ z#@;YYwCZPV&2^5pkh+Iz6B{hE%6(kO~pAab=HI$h?G-53@8-E56BqBHZ9F&P(XtvJJ3l> z%o)o7nF_I@PurADiTRHUiL>x8L?ARH5l_^T%csT+VaLRRiLM*Btk*ecYFLxbw2Y@> zT_=;w3VLwqaJ%bYgAKOYnpeMStC9hoVV#Sv?!QJO%f5K1$g)iY(D}dMvq2th>#|F= z4;^Xcy8t;iyAfylN~&dmZ=*NRjch-yIt*cqdo`1bk`CdNGNq*X$4}DN`>X=CF>VzhIh@E zOG^QXJ=u}u50-fb;b)uBJ;ndWD=C}~UBRZPTuI|-u%Nvg9;q;qv`2qT5?rCL|HR+K zJXf9E=G>XwVDl{Yy3hp&s6*BdtBT7)$F>YK1j)>>frB&uj=G-hT33I&O6GAVQ40r2~K2kjWf)BuZ zZh$;@oXTu6*8%vd&)5y9)2yBxTC8GMO!7XDyqm}g%nhQr&J>gd_AD>X zdus6heb<(~hst!3Ob~3)lZN0Txga2vgMg5N1Y9f$6JIHclL^uZTkI+8{mv7_qobcp z8ap%DE^d@!l8@-~Ysc(z+!IpW&R6;wpO4(Iy!ZS_6P~`jrG&MZYY`;G6RVLk?D7iz@<5xy=O<&E9TFlN!?3+R96w5#kQ&RL;9m zHh`soGAGW_H1D`ZG<|E*HlIX?*AGbMTdljhT-_%Bek_8asia}1I>uw~<3>i57?=+J!wV{+ z`a$dsxvOc1`Bub@6?#(uJ)+V~(*yRmIUA>|&@xGixxrw6$ESuxG9baq7(AA-5`3r{JN? zNEE6^KBeB@niI|{K*GglIxY9ZH0F%JK}y3hIoV*CRn>5;5HFtSi7PXGb(xd-2H1rK z031%jUTdE*StW>Ug;Cc>EBED|uc8N^CoBGXxw2#BjO{jc!A$TS>^*= z@XMP|P3Qt(giA-ZhosS^Re^TMNH7_$IDu>iTL)+C9Z)Kz{s~=`tZO(@Mv|=m2>Vmr zf9Px2<>Dpn^1J$!2RWryMP~SaY^(j~<+vE}9dTi{?2vyvO@Y*F)^rbib-S>VETc(! zsf-Rskb7^)$U1G<8j5P$q7jeX8e*Mu>QM#qpldukHGIy@Su=l;TR*5!xw5)TTdhWS zW=CIIa(RO8h)UpZS6x!)$CBlyJ&8<16&+%BWTjS6t?;mM%d9Dq-E7i0^LPb|WLsR= z#X^G4_B)t%8+`QXv!9x#r17~oZF*pQL{ALkq{pU`1y&mmG|I} z36Y-)ctb809w>t_&WWbw!udQ;8G{k~qtr@Gz}=y6nuE&6sJ=cx3Upcq z$j(2ciD&#SW?ua8^kT=PmO>S|bO9u#4Y4-m2wEPZ09z#>gW|)vntL9iXY%c1e}E~8 z+$+LJy#V|vXJ#@W8XJZ=d;Kk(_cXOR@%yO1r!wICx1!Jb*^bcf@#sFBYTc}6W$}_1 zkmjo?%>eBT0T4!^)07l2g?374@E*S=%{b(NI1tAJd;^e#YtrQd#%Bg%Bhg73E7cB8 zH5XEYQ#aCb#_NXs)`tM!w*89UzJaiKG%yYU=U6&sHv-P(G#Rd!Ye2YM?uT?Q`GyK~ z8aB&O8G+v-2jOR}@|_tnG9KhreS0%}n`Y18T)iS>T+r_2q%lyST5meDW|J#t8uqUa z^Zh2kH8O9X=dbshu$c@Xh>Qiz>>Z!FMn*Fuldy@l-jB3k#EVuiTT!X`_K35rDmgOE`@8FexS+{$bhxy|FAv(a_aqnRxJXO4eq|5U}A5D(*xg50p>vLd8j*y zwL%ailcds(TyBjwuI>up)(C_wZiUzOcJr-+oF%gV2yQU)&?bjnVXyH6b27SO@6utF6LjUV&7X@=cVm~s*>a}@)!x$Xve9mO znOE==B3yr^mbJG)r}%{4f+i}2`?jW6Md0ox7wiPnFXNyY04%NH@|`ecPR?+*)W#l$38_L{X?1Mdzy-Ch2FcP zbIr4_W|locClDY%CbFHUuvz+6bxZw-1E{7$&a;Y#Wtzbp4t*5{9qW!%X?FEB1Q$_x z9O7J^?M$Z(nc) zKxP-L)eUk&Qg`-qCSu)6KGAD757~QRFFbf_JXjeK7Y2OG__yOKCT?bbpxUd?E_~l- zJFD29G&BB#@XDj>ex-+1ObSFABpeXKa8v`irll zHk~Q@J5S{iSfBtdRw@(q1C2SU&NxwZ29EI;v9`f>shEB-<~GotYr%XAFQjuHD-`Qz zC^Pn8tKfT~K^ReX5>+vIT1;e;UKl5R)ib}1=g6mvCvuO zyF={~$ZJ+Ck?T%#<+GbVN!Fv1Y&ev4NKl8^Cb8pQz6BJLM#i6kM8)NwjE(&hs8InA ze!nV;IKU-`51rzBT&xjsJh&?1lubrZ8f(GZ`-j=14x_1F?uM>Eh_f!WjUM%dc3Uy@YkB-iZ zs!7Y!E1#eTELBGmp8`3dWAk7;wM5aRh_%2v8)!vH()~xJVI!O~^d1!?@gl{2m#%XWHEYyh& zzH&h_VFTYm<&fL9$h&6~3lNQ~&U~LMn(h?H2Fk^00S%=b?zf#?)jR(aruk=W)iUtI$%fkwWrEF?|S z4lW4SiZeynphW_hUR(b!UK$A$-bj~?1}$L}g}1A~h9zQz@!|q!9S6E~{kJ0PGlvhP z{R?3_ja-BUN^?xy98jfXjO!n$>IqQ}1@#H;tmJEQ?zTvICyIdUEmT zVg`%z*+m2eH1xSyHQX<(-~XL%$as%)n;dh#hotCJ`UNg{btV1$EAKv>H4fkk2nC# zu%~m)iG&T>J$@1uMa`x;s+=KqI&C|(UY^|!sNGhOdkcLHl_~GZbUF5wh0miFwfXSP zfvQ*;HMcD)XRMr9;2&1^?&goTE>@?-l*_o*Z>zM`zl1WOD~Gy^h=x>#X0=gWR7$YW zddCn)ZK%BD{1%wkH&%K*^P4bfU+BzOz4W8dhJ6xx7v`H|o)H-rc0b?((LbWU@jqJV z9w7Y-l=t<=vB-|nc3cwZfH1b7n5Hf|@WWRB5A{;4i63(5W7F7HV?HYP`x-duzMQZE z5DH30PLND{))UVXjYa0WMOz1_Z5xKYy&CSwJGP=a!@j00?Eh!?7YRy3fy&T04RJ?i)5n}d^P>cRVR-CrnP8pjG@P8t)xSL2B{!qADVWC9ivsk@ zG|9Tys7j)*`M1o&kY#eDWHsVHN!DqB2t+jWkb381Tc9H}n2~=UQ(xepOR5-I%J5k^ zee!xd9VdZT7(E2S!o~x1*ia!@+#ad}jVQsU1$8-CpGY`_X+t^lUcNpOf}O50 zPO+u!QJw0WvvKSE3n)y|FxqWOa5&(yc zJGELd4N2zTC3G4GS|F*=q~s`YWqU%+mUR?83yoGwTG=M<6;sB6R*=ePam{J#99n_n zv#izH>ea}SM!!JKQMpTTCJ4wAK)Q#>+>#N~61cq&cPI81NJVMY2)aY(T;J?fTw+5o zh`+Sm=T*Dq+E^tnC7@xy+#+Dx3{;y~aa+wip!+2mTyhz>WR%Jc{CQu6Q z2uKB*H~E(7K%!rDq!js{p5|P_h7CUAVe^NS;!uA*B)iGy1vM@RqNmJ+$GTmsYIPx- zKVD{H^E*GEq=kCdvjtpVj>(5y>8xQG8il2hutwAmUB`fiqos^tNPC;yEM6g~Y7YNQ z6dz~FE9^4(R&|j2k-DXrQ^&!WigJGU_-MMSFCbLsi=}2In2;>Hr7ogV04m(6laWZT z%}&Q&66jHl@sxyz)Oy)vjLm>%8`9GTTrHTJ!!%nOs)1DuDzUJq*)b<_HhKOa+s?(C zM&bTGeKEOJpsUP~iPxsl`sy4EKHA+&KM^5nbqvW$qItcpf0JYK% z*#>&T1?3Yp8+#=nN?C}+fb6mwNnSEUuyVU}+t~eupXke{Rg?;n#x-5mQBW1i6Z05D zt@Mrj_3z%f6_T9likWxGJ6^$Ft<7;&flkV80Xw4Obq5HLHx_X!Qnn(&OD3v7qz}wU zn!Po48quO<6HCV%dCgX#k!;Cm(W+A|w4z_PC=yS0t&M95$()LN(ibm8HV4U4s}o?XCCh=05rKIF6@Y$cJm z)VxV)LQkdxnf6&QeqT@RY@r&Q6U#SW=OJ16A{I&BW9!-J<$z~2j%f(Be# zh~6|uSVv_X>vN!Q(YyhostXAbquyt^)s67+Mk0)rQBr0i$ScO==l>jZIACvP>?_() z&GD-`o>xK$`1_J65pbaUDj>N-Da zec^BV)w|Di7aZgS10<%IZ$9LLmyc3TKtqYD{Z9b~eH*;?6`frCNxU*3H^PqR_|(O^4j7-fDIeHRto&$x+dH zTd>=&m|)F(d*8!vTaKm2_DG9>&F2kP+G}_IyJMo4U{xl!$I+{%u#jhgOy9CCXJ@fo zuv~Q$$+b&wI2c?mzCfaxq_+ttcp9@@yl~qiw*9-vu0x*nc{@ojFELK1^-r+;2{m5N zb>>4ft4GJ#E2uzqFMTO?GSCS=bz5eoAEP{~E{xSH3_y~9tm*%0Pz44dcLTqJ*anZ5%<~s)M)y7BrSq)v??x!1)#>AF40e4vPf6aTfp6KiEJevHY(BhHkfPBFE z>HT-IGX@`aMqT>(n`d`CEaUfsZXdLbcN_3Bx;YYb7-rvzbt|IQw3zSdw|1D-2gqD; zMir}IN^DzBF&oQ!HsPQ5YmGnQ{Mg8ehxqp+Z(tA8B%g!8Isyk)8At*2u~ow91cuuu zhg`17$W~_|U4=zCDCXM}mAs3~XYV#Fcgo~1zlHe@)~|S5L&TWnYSy#Y{q4+}bPI7( z+Lr$HMsn%pI{?m@aIVc}6aBB$7fkw?yiH{hmC?0ef9;#Kn3Vl&B08hNH=0&lA`yGo zu;vrzpY{d#jgEL)0D5$F_Zo(2(RaEK2^`T!)Y)eLo;JxlUzzeDKsQd(4nj_e^iVTW z2hHI5U?mVYmjzgMEJ&7TH!oZjTNOm84zgg zaR!!2v@NRLP*e)RZGh!)V2-fylezf={ixRH+~KZ0!&A}rpDp<1C2!Bpv`BMf$_gL* z0hbTO6VwDK3G{cvvv*<*LEv_QtMbNCM%0*=1wcl-+>@gD2C}GXtnedx12)kRdLw@Y zwQ!_`X72g=k~2FcJob=`U_dewdzC(pxe-(tw%^T=RRi|w=K%>9G>^+_kk?Rd8y32b zQ*AuH^gZ%WnsKE|9&#r#4<#F%c`PSyQwlr%JCj`8pu+JsXUg3w*38p{Hsc&uL2~3& zI%3azMYWlWJ1&b)dK+>YH619IkG&m>sedKoa=9Q><>scI)r*Ott}o8H_2GWjfs08F zM2oEB=Q1|tz1wAR7@NsPoP^`XEqO7T)m`BJy10G(?NmGOS75B@L1lIdR&9L&n?G_9 z7&I6^RmG}{=E=A=jqcG`Hgd8_>j{{W6bZDzw+6JX&0|e0RJDjBv*NMOyS>i|?Opeu z5ph|^TiyFOF{X{)K7(A(t~l(;{<&_tT2$V0?<*|4fnSg_JlU&X$9an|J#>s%XFlnW*!uLiBQu8ebI2nYK zjkojly0K@4GvwK;RRvLLa&hQi8IQM^X#M@>f00>WYVR3gO!9keznZ z>w$23I}Hs-CWBJwG7u|l@}HL^N8(-m%}hJSzCqkU)3=%`Y{@Aa?%MSn)=gA-6a%-j zSH%IhqH^NYPoG~ndap1ZYXcI^6k#*sc@!ivzcAW5k&9=+ME)LF&>=_^K*`AUCGVQr zA(vAwP8D6xsEKnnwthZeycur7Ka;-rWWC)SB$Vcl&WOKtF>S(L5lERJPL*cIhd^whUQ?ZX`@%XwS#jBx7p^8CCKqKN0j z<9RzDqmY-?0Bw(sc&ymzaYzO;a=>y}7~M9%95}rPcpvRVSyqT$7VgEf}=s9Y7#{0bFu#PQN{{0}Wm}s3Jg7 ziS|4`KTGd&BaBMpDvVpjM^SqkmF#El-Zh!W{B}PcD*}E*U6z`)M3|B25(19hO=RMR z%nmG;EoysX(NJ!S|6oUQxqsNt#K2vUYPR zY`^%iJ;^P6bI(_ooT~&*NuW6h2-Dvq>~6@#_0C(R6kg{30>ace5T;&(FvURHp`tcA zbWK;avGC(z@ayufjL4I+tHdg&mqHiMr(+|Ca=aDu9hCPmPiN0nX_$#HWW*)2C);L@lBZi;Ew|-oAM|Myl`XdR4>&@rsvG^77TImyfe{ z*+^5({YoBX9VqgN`svw;csg1DQJgyHclq1E(JBOm(9P*NFydW3Sfz2$4Ae2&=yx4O znC<;DJCVmy8}v9&;afh*w}tGly|1C>9v03?wou(p*AGP8zd>1^oWjX{y#+to35Y-h zXFj`_O7|F;SHyTf?~8CX#5E4i=LPvR@Pu=XmM4Vd;s(H?IH8&O0caFklfTWG{5Hf6`wJFvn36}eve(gi36L+@@+d4wocHqW#Bwt8VAJLtau)ZZ>q1)W za`Zi#{f_+addUVindhKsz?)hgJXqOJI*@x_xa4^>60EthPbeeDL zYuNrZ`VfslF`U`}e&qPj54{{SOt zSuP9Z3yP$>`j;d1Rw>q0960(jYVw9M2^J*qO;xK9!8kL;e0Q(QMyW(!br7Z%u#uv6 z6K+;=0nsSvXk%gscvRn$VmyN%+`3qM*K-eN0cc^Zy!9btlaA^nfRv!-=D-m>A4`_1 z>6%rpQuL}&+OFP6$_7MN&S;+kgVY` z%#}1IZ5Xf~F5wV;cHh*ZfpFa%)DR^VXo0`j(!)n2rbtDr#*hP}r<>v2rhN2Ecx^>+ zv3nI&*umF=fr;aGS-_4lFO(9P^SQGE#S?XyN5l_fl7JHiD+#1W;P;^4{GUAWF9znH zzW=s9y+D*iFntUFDwgZ3YXP>rOUZWuLP5lf@`^l4TmBCCKkK|AD<^=k@y_%Zxnv!v zd?;HedPvn5Tz~NkNX7enq||THcbz1#;kmf6xJg&Z0#v$(Kx%(37LY9LG;6-{qu+#S zY0w~%6oE+dp?v8Z%?K8az1)rOxC#F8Ux>HdW))}F3}zU z&7pW;&F_6RZr5}=t*`z*eYdE5*B?*gPf6EiG{lYbeB5DndVtx9I;WKREFYgu!NE># z`=0MucYLMvV~*CJW#E-c*vQr)m4QL4W;JtM!Vo8?X`HAPQb9aTi%M_07B+-kTXa`- z1V*LO_FO<3s9{51F9%px`mQz{ych&6wACPKFbMFQ7)3akjJW)zF{LP7D{D;3r2))I9Hj`TgwIW%M9_ zpari)bSEtp7eP0DKXx_ZyW|*42K@y^S&VS$z(bMU3O4&kAmwA&$sM|ZSo)aj39rCA z*-uIPsQ4q?A@W*QAu%V5SsG^KbwJ3)=bBFCnjP;Si!qHyB?8e90(2O)hTiPKda@jmhBM0WnxJDo=d!7JsG80&!y zr8T{ID^@D*NhW}XZ8}7T7Eg^c6?)@@Z~SA=18(m&ga~5p-No?4sB5KE-zM|DFse0! za2ggBjO!c79pDu#P)$%%=M>uyXMQ|Y4RzP1m%pFN&MB#nDL^jk2Fym$wAwPxEwn68NiKR#auHR0zz381}g0}naq)~J} z<3`Lz&BI%2W@O=5YmmCasJ=)m7(5Xm;PwuvR@AbmeW40vDKZLh-0nc19sZTX-wt&q z(l$cZH0lBandFk}{m7YvZ+Z5+G2M2E5W|EKK6HZpHZppc#>76m-p z*0>wvI7JYAIriL;%l0A??H~^!6i}TdG7Yn8$|$w`0?~W}edDL7{Otn~c^hqu`Y#SS zTq_x)CRQ>7=qTiJ*lwA^S_c+t^uA6nw?G1w!ALV<8OB!iEh1wBq3I~tt>cSo*AT}p z)x;;Sl-hF`{-f?x1t{Fk_dB50oNq1+(-;ogyP|z-2FZ3d_KiV+D*Cw8i|CX(t@6BK zeD^T+CVvaPxrn|x0*Hts&RuepqJ8Oy=J5*p+KIggUm|M*Eg2il4}y zvdDr^5;{0`MRXm`tUeNh8={Bzh)d$Gce?dooqw#_i@NKAmA&HP@p@E;Rk5y5#@tw= zpK`Wj6ZcOz9~0G7yq~Dmr-Wci%RKc)-)O6r=*>W#GBJ8~n!FVDb+ZZ9yRA6J_1IHj z>GRh(4yCd}h;l*t?px$>)biNFm;*=*G=;~d7<@7bRwNZkyJ8rrw6!9R*Nr2o^Cd4R z>pn%%jFNCDVsvM^7BCnMB ztG^XC!T zbM|e_R%GGWF9@S)F+!YQQAq957>sUj6^Z<>2x3YvP$_g}ltz*5t z3kQ%gwbje$ZCfPO615p!0s6N)f<%d`t5^5SJS@?y+%Ct0zRl0>HW0WbtW-txQyO+J zxgnQ8#~e?yo~nzS3$%oWTFmS`ks=oI7PT?d`ASaCRe%w)59I?%pcfD;Gabx(SN5b` zp)Zq6)=BOR1rAxeY|~bo(A&UHwWd>z#(wqRNTp}-wIF-JF~q8Kk@>? zM6L(I?23p_(EqX5c4Z9?X8d8C<-31)bmWJJ_)-7vud|)Lyw}!-Ew8Q5ZomHE_~e^g z%Pz$`Gm!HbS71XV0dF5UJ4I0C9Y$B8p`%P>=LrydPSeA{V*4fw4muBP>;y?z{YZK$ ze=m%_TWEkNl|4(IC!wr|PpPgmKV^H=)HCt0Sw;sd?%DohMycdD3Yp|O`rb}UcRm>` zSV1Pz&hJp_;YLc8b(u3H&)-;=1-Z>pqTSba*#6R}_%%B6KiOI7+pR8$JEOuD)+Hy} z{&dBlOc&Y@3uTmaEQp0-({bOXZqK%x3xRtPefWkvqE!_g4WG`x{4G_62gTw_0EA>)Md^dfR@h^0RJQ*MV%J?i{#_WnZ^Y8>zrhlO<(m z5R5g}DhdF`0W~Y^8kKj6%CXlTog95Uw+tBxgjsJNmQtDL2Iu44+iH0xMhqF*^80~^ zDI3h2H$6Us4Q{~fSonUFda0+?2&OjaD3izCRhvITc0hR-);)(!6rJ>?x9@%#4LSNCqYy(!=KD4dZ#RFjYu+OyU zI&RWJLHh;CA6<8lT0~hh#Ksf`tfl=OW?UQmlIaeL$L3F zvYbL6GFIjS%XO5gN*B2SwsgLl%}9MQas|B_Y>y;s?Oom4^IiL`=g}(=nk&H*#v^#o zfhyH@YZ0&Rc(GjX5E@0YDI|*j0J~vG7q>B6y) zkmB>C1!F$C?@!U3!bs9?0_k>2AUp$c`q+wvaO~kAH+30*6HQ1kq}dQLCb06Mv}X&| zbKC_uz!cWf4u)3FnFTe~F_T$aYinPDai|Mp9l+U_0WiZXi>v4A|K{UiP1jI7My1Z6 zO9z?BSWxQ+vu^{Lx?P%Kfw{_QoRUh{S2@6FAK*e_*<0zNQ*dN_6k}&ZG&!DuPKkIG zNlN9YfghHFo4$1an=q4s=#0Nu#l@AqBW1dO0guus%}U=V;Mwoj=*4bT7h=pceb{xF zebhtk^bMB~J9&VfTw3$6y$+iS_L-{?UZ?qH5EEkx{oIP*Q*Q=XHDPwuz{^>Ga|AC2 zat~*=gv}1+L%=`GA znV~b>m%u(Kq*a{%i0sPY@8ZH+o>NciC}*2NX>Kz{y(s&L*WveonZ;_IH1MlD8&K(es4X51;=V5(*{TMd5a2>^ zTb)7Ms4_tMoZ4{u8nL+*Qx=tA%ilfX4oVjTu%$J@qDoAB%~Xy#x1e)Hg~h$P&g5{FL%3$k^C6Xw_R>cpxYFyI-z!L0c@l1^!pwDYq=om; zKa{?PMZQwEzU*a|SVaEjGoEzIVhn1w86Ehgqs#RD+c&{Kq5dLh^t`~!6+8#8`bW*c z7#i6xW2X8t4K-+BgBns4dm6H>W}j~^MLNBNRSt2ambzMo{P&0@JlTl!4y-9zXQxgz z_O&!$D++jL(amj~HZ~8{{Zn5n4t&1L{!}v@=p$1~5wdE@;uC*g4HAQGXRIeN#TfgB zJ0BDcSQ+~ps$Cm^6cge~{KTEx$B=Z*dfbYs(^UUAQ8mvS;2m{8)|Qq}_($-fA3f1s z(Ea~u#zDpX4Ey}AS(RbEas=Tm?d=&Khn-GBb2*l2Dx2{`Wft6_NZcx9_C!LP*ns+C zMFzPu-I`3{x3b?8KYNu-8jEJjht*kDeWiX{w5Z7Ml!dcRSu!^5qs6i@xgGpwBt z#y+g_ee__njQUsFF>GYtVDcqwicTq*E+mXg7R^)y@Hbm<+ph<-7bW2C4})j=lRGHe z-PENjU+9ZJ!eaq%1Z)d4**Uq*&KOB(uC`{bi`>ZP^PrN%r_|&4mlpljCSC;Jq}dAt z4*PeR*c6NMLV_ILKFzKR)twJ22Z7@$R;wyv^!u?+<)oS=Z+ux@pY+!26mCVB^VO)c z&D-$&g3{KYi#F=C6Tc7LJVWsFh}3$ptiCqHaO>B!ERfOfVXvUYy5sImqxd$*8tRY~ z+s;mn7obI)=wV7j`|0I4ilM(5V5C|rOo)=vRq#E9% zr@R!Mo*9=LKt}nN!d7)r4vOfqVBV|Rm+%Uft|)OLw7a+=0^C$wte}aUs<`rC%baWWXJ4{ z!pXX<`!}qQud=nTpJHKdZ4L2QV`ua9`S0-N?HPYuZPzQ@d-hGSmOVukBv<(KbvdJV-nNT@}aTU27BRe#T zipr4$Meiqn^NgR!95NagaCSCuU^W)#m@4}T_0?@b0+AN(XtZe^1T$MokQ&_OUo zilo>(k%IKa#FaG{ylXAX*oj*=6>shf zzV&$UP)H!FHKD87X`n@A-zISE?x}7CL(DdeezKgNps}8)Gs_bd$Z%++_3N-Cg6!TD z%}AI#aEZQd0%H#ol24TuB6qC396@hm94)FKvHd3b=;~gVv)ZZ*u#p6%h4w%F{~h>^ zViCtBIlWbqh=zPo!$TL4hjN8sv%jsNB-RsJd%uEuM{(7DO0Wr;ZSwz^)L;gHH?_Yn zZ!p$uhxOH&>BTmG{BkU^d`#?TT_XRm>}S;c*C{6b)81k*-irf=8~p_J`3AVD+W>No zLBRDcr>S44A|XM$7%WB2FaoHwMLA{xDc%YCz)UomsioN%>jFP|^yu@hJM$5^&A>g( zWW!KahE2v`N3ONf*vW^o{EsK-3{d3#_g2(waIABg8);jpAsrH4R0|k#^&IZiY*l^V zM-O3aM9?jD#dA0PnQ0NIWbar?4cw?n%U*GSLYJB5cG5ODXHodnZJ@Lbe z!`N5YEh=x|q1e-$zXwSj2QYes&=RN%VUy=68>zgu#j%>8kf)*MX1$dm1Kc?LnOC>o zv=W1YC=PAiNteoN9YXe0YO6rY(_D--)d1|SyK(~YL$}$jIR=~hA^4NpD-BN6l1f_B+Lz3J1R-*YI&bFQq+629GYy~hB@mF2ACOD9cg3xb0I))(|u%}l1TW)tciLz#kD0jl#M-FC8`vJ9vtn&FywzOa}C zsa#=t3+@ih5CSRy7rC!K1D)Om*z$IRS5eQki#9?cuhs~jtRB|)btgfnPXL;z4=2kg zEzEaO%I7eGwP1`mpn5%YJlAiKiw|XGR?8);CTRu`|Nb|}zu=;tQPaU=WjqNL zM^^3+4D;s~2L}PgP{U4BEgDcL2VasgW-|#3dD-A(nYrUk{r8}n3IWD=*%}w<+Q5_K zHgjWP!z~+rfwUXvEvz!%EIa|b)-qW7#^+Llp9@~~In-~mHCZ}7zydG$9QZn)qfIde zsy!gfT+)o1fQ*uY*#tH6GU&mAOkm5q%|ZW!(JR=ljgp4dbHPgm+KAiHYOa%$IICKY zS~n@W*>{9Q-gbW2LdkpP8C)>oTvB{l8|qP+n!;=u7QB4veef{@>)cL_$pFxt#t{(U z-j~KV2smQHon0QL`40I4<3RUa1gIfBHK(pNxJe}&K8gAFE5tT$8MN+lV%9=W#V3D7 zQjYel1R@abVfAQe@Ww3Zq{~%?W53I8+0>>$ zQlt0zS3*u)i0=cjXtREPdJ)NFa0*ZKKUHumT)P|mrUs@lZ4-4|v&v#=_k{Tdcqob6 zh?nIj${ERmb@p8Wr?ktcZXLCO`-ZqnjJ2fN8x2lI&J3k-3dbyL+ot%Pj0=|KU%iV|6NvK#XMygB7u^} zDNX3R=nWb}Fck#gZ!{?TFlGZThvXSLew;etY!UByI?y$F9xRoj|ggh?Mqjoi6Tp&%hoVgMyJvykcjcB}aL_`j15OVcAPFDYJ6ly`( zw@RKgDGGido>^&oDa%4j1H7z1_J~nZUNX>XLm@kA6UqVQPd>%erMZl$E+ctG!|%JE zP15v`khpPX!tvJjoWaaT`txl@{_zkt{Zt-#%k@d8sRDvS@vqINU+OCLfRB3MH!wR% zg&nukrH-@&?tvKT5nfhpV@5DgZaY>q6=)7P_YPBfao$IVI5r`t8ea$w?=0f8|MQ>IXTl{=UtXv$|Xo$!err|wwF+oTeJPk zbd_$UR9;I?2lHaZggGGAR6L!m^{LBxTs|Rwn;Ta;=^Qz?{{1lFUEoN72ax){MYi@e z;`w~`Sl(s;TwQ*j{6kwsq&MS%n)2{?3jw-@2Hed+*s=qq6S|0+1`{BJ@&htbHAecb zBps?b4M(NKhE#q!O_lhZUZf|tccjW0s3~3z;e9)$DAb>u#btSxPFzo~)6%>Z@azeT zYlw6N4QiIoPdbQD4Ky18EdWfA5-PD5(I#PN6BiU&D!D_inW+FGbiOcY^3>QS$g%^y z83|D~3GK1VNQRl|4rG^*RZ>6N_d`H>fKj0hhE?MREr+W9ErA5 zudo-WP#n;g&S`7#Oy~x{8YD9}Vx^Um&2}L1wYtH@j60xW`Y2f9ngGJ9qgm9v|anJz=MyTOHeg?QSIcI39Oa0!xJ3rjAU>wEc zMH!zN5sUptI(IBM?XPoNw(IlnlWELEl+lUoyk+}-v)%q>oBR7T?avnf zovHi<8a`_5CB%=sgrN-*mQh^dWHIRUYpToDNEs7FnJ1A@8v<1a>mb}+mmRUe#Y8}@ zv#owIp|N;jvh!73aoh{++DVh_emj zmW3^5WISrpMGC=7K~I1w>59_Wi%0;Q-n`d_wrK+63bjkxu!(eI{x(k-?Eb{Hy$GY_ zCjZco?R*dDW2zoq+u?L=!hDyoa^AR?nX(Oc2jQ=0H{yeIwyKNhWv52UMQBhD3;*1tCwKB8_i8ED1RH_IF(NYT` zhLkcz3E2uViHOz;mr#g^Od>>vfI>)wFw|NMb07$rsxr!sLfC~QeNV3Ye!lg+Yd!Bj zUCX+JWbfZ_p2v9%=u;~v?SFLNFgK$=kVkjFx}snwgEq+wt85rrh(^X0dTUEy9G@NK z*FB+J(Jy&zm6hL6j3u7S@IcimPlMC-@PAwWFLE^tP&*!Ek(;nesP&t*30zzvd4*=T zA`8*iz5ZA43*Abr`Lg|5WgRMLV#l+MoHxrvR*b!VU6aJuNCDM+xgh_1WBcHMt5sgX z>w>Y_<26J0-tg2D8vRe7b<1lo0V+5MclRY+f>U9d^B$@Sr$}odCyGfp%{px%-7$%G z=90|a-5yyt3XW$XpU=N~jQO{o>df-($^Wiu(CLFud(~Xl<8J%5O%hkOy4shbWW0}#ZrkhGMKAq0RT_4v$M!Op zgBy!gb z8z{8F;l_s6Ar`^4e%D_-@0_xbt~g3ed*nB2L$c=G|OGxtbr}_*dP;#|i73XO@0rwqMB*$c zab5f=tnK2*!W<%MeaqP;7p@he zJqicO4n?-baTPv+=`I(o9G?A<$U7{b2>H|4?T9$smU^83qKg{?U}PB$uD`5y zJS(cYtE4N3of5K(duth zbJFmiKaqyFq}6;&%|6&3aT;q>6w3_AWfY!=p}1oZaV3^qIGj<8Sl?itdcyeJO59!1 zZvWtdluj=JQTfWCt6lv&MK1N}B|pq8F*%XE5C3Pg-aP2mAOL$Jouy_i{M33~3!jJ; zE3{@WbCy<%bzfpXOg39>s-0V&5>N^m<4aUta%mhb!bt6DaKlG*OLIo9T}hubw{&Xp zJF>U**yx)^o0KBph>_;#hyT@k*pl(D-gPxw!ve~fnQr+B7~Ib&YBXzVlL7`p@XH7q4LsI= z^+5T(p|Az6I1oO{e#2K7*TnJ=Ien#{=36X^j<&W|JD79J1t`*lTh_L%&eZ2rNur)pr}Aub0>X;P|m2@@Kqg{jyy3K#SWXZr%^ z#g!}p8Ym<$Wo{6UgmJKevnYLDcC%7*HuFUGCG~?|p^$Hk@;N1A<~^g5;a;3JBmX?P zpj=`6h4B0N{^Y+441l43yZ#4M>42wVIPRE-G#o}k$T!FpqGS!V7}Ia`-$e%LXN%Rw zb{Z{&cf{F7M#qt{62DH3L&V};Q?Cgts1D{o*8X(w$!NNHPWI!$x2;7(6sIbOwt4g6 z%ZSPl5L!1~3NiuBTOvUQ-{qhSq!vmI;6cUtq)f-6-E2b5*qA8f#q#o=$4)^RSUp*4J2+YY)@y%A;T(gVL6}rBsI; zbAIx8mG?fO7ivHg(s-pH@9IL=EOVnagXn4@eYpnxHcOqrmWHna)6S5lcZc zl})|XzfgL08!}#6+4MMnHT##ts@I`gf4iF8#p@eps#26ui5cgxlro0(Oa4l1YcXch*!I|mvFEbYhHjqs z6KqmG*>t_^Ej21LK27aXkQOM%s(fiqBaXsTpq4z}h2VM^eAWI0! znQ^V^gZkGrh4oA1#*j1CVU?{)BV!j8@P@(rP++97dCCwsTbV}_44}x0E~7|#D<2Ju z&q|h8)WR@4y@=Lzs9T*e^HH+ zgYD;SL|xCIhViz}d*mW$I9E7(qkn=hU{xI$_-h=nG>7`qG2#Z4mH)&C`Egs~#e$$u zeGQK4`{Ogl7Z!|?(C^upgPHoM_E+QwTq;r}8vhI<%arG;)&^U1X{*!iL>2oyMp1&p zD!VZ7S(_F9$KA!YrP+cA!;W$I)l*!9pW)saH?DP}=VC%A~nZ zml$cc_3An{RPanc|G8jNfS#Dd?8DSYw7G%+LjPxVtTvbLtm_b|jFbW)$xw6A-A2~( z0-(krV^kCmy1pQf>_DoOM9P7^Yi#~78&n_GcUiF$KWKjkBa&*Kw(etz^{>txyRPczcurLuN>~v!Nni94=iSobF?q zjL2;RUb1mI3p`F5#ri;+=$3rMs$ZQa3c0>BY3o!J}y?B9+ry@cjqX z2-cu#Rf;DLhN)~6(ngMf=9G97P&GZ`tyZGM48tbdMXk-X#N@{#R=Y4qrIyk5XjoF8 z@Su3qF=tD*z559XSTepo&dsq{t4I18&MO`p-H+>sb0vPxS+f($q(FKKvR32u2q0)R z8;x$UT7`8LVb)DobHUt7PFTkf><57aSD=sK(zH)BGC}}avG8i!@fuC{7c`W}4=6SS-M=R1m;8df>B5c8Q_SwOR zCAN?;H@SvsrkYH8I@RW6@t^R&pZD<26#0!-NZd-e{5u&Vk49wpWGU)DiEZ?1AYfZ- zgdVX{3nY+}!d|VpBm-Q(JH4tAE-2{1RMD z`?7G@o-y5$2FujnuCiOx20}iczTG)!Z&ZiFYnF694dJA(_N_~?B`VdCJs~@7t$#mi zTmRc^PQLxqop+8RjA8MrYlt-)^}_)xSU8DoMJD#k*l@=zVQs`(TR~o++s@ow0gVV5 zJ16MNfRGh=O)q@6F(20?dx3FK&RSH*v+jjV2T!;9E{LnQpgGJ+-Riitwa@N-->pY7 z8U8Q*_?STUEBV>&`P<7<`&?(n`pe2zRn3v%490rFU6-C3Azkk4Em9;it!3F?x<`Rg zrR}alD)M-Qzo~cP0&TBbZv<`6xvp?(a!X;j1Bz!I|=a)uINQ0*}dodvMyO1}Jy%H&Dn+xPqnGM$I*mI|@e43lp8A(YZ#F z3C|&u`}>xGZ8ZhMM7yr4>|cDpN;h+=Po3zwak9T{;dcwrY&}*NY?o!Qurv4z;Kvv0 zdSD~CgH?jrEh1iH2Q>5=PwauB6}xKZi>$|zN!3%xQi?fbfU#Ev4Q*|=vrapwGEOfp zl8*YDiw{XI#rI~@0u6emobKN-oX(Dh`ymEtaC}@@=e`#b1?s3pE>$A|5z#7r*Wx^7E*RehxIxA%^AM{t%ysj`4+gK#JgUK8}NqTy=dc69K4ap3A+2Z=h(Zlq}7 z8p1YgAt%fT7U>9@v)KOP;7D*nMoU1u&9WFZZ@#t1w?A0SU!)=y%Tr2EyfKOmjP0F` z+2vNk_-R{^{Qjx~ejr540xlDbt`(Ru9V&2wVtmKsH``T?OiW2E6D(g#C%?(ZT<5u~cyY-or<6vNw7@v?=6zsSv_-icA zy}C2Qz4sdFXZ{|f;UT%)SkTD9`)Ss~ksn-Yq{vQ7h;V_>;*2JEp3Kdvv}>EhJ2yfI zFQY^V99bKg$4dVZuU8AJP23+d3QzF5{6}3*ZR|09Rh7C=Usb1DYzrUU6oW-&Z~$AN zqN|gD_+qQMh^EKM3AY^Kkvngc<6~!ZM(D>f2oGeM$>Xd0;Xbx8Lx;Q&^ z8zD~T?&FcnBUxbvZ?cMq{Db!#wlA%?#>xYS5tJewhipc*H#Vap%Mx2TlG|bhxlt59 zv>VHi{|&7k`G#&6>hO^NMF+vz>&dX>O{$y_=f6Pwn(9QnhZIo6YX7Y@=KlE+l%lu& zqv_Q!6xs)TzlN2FY*5-?r(0@0x9L2qTTq3xkW*)us7PsG4o&(3ICi&TF1r+h&CW5l zsg=jX5jfLP-Cit4wR!I9=kwiGhMhFKXgu!VBkyy^#Th<-&sH4Hy%rM(4`VT0d}jnd zlCR;*3nz?KKw-IpP0zWus!U|0M$vj^pi^K=Oq4w1mb00Hyo|XUtu9wWUmZA}vlu&y zPM+_ao**CT+;tJv)o9CO3MIkRh32=@TuT4WsMf%C1b-Y#PT<+A4mQ@3Qk`w(cLSBk4faa=HV?m! zDZih3aVw~<-WAVyJ;qH=k#}hns^uVN*djoECo#tqH64~@xbBrHo{JxsLu5!K-`NEQ zk2s9f3jTrv#SD{X_}8kZs=gqnv>ROH3nmexY2zXukp~7X{kzVUtCl&<7tZP^r}dKK zcgObO{{W-U1-b+&6qYj4zj}SK9`QPZEG20IH+N=Cu=*qO+d_ZF2Tv#|$v0Ry3~{Kx zvbgp|N@I?d%B#V{l5z9(cNN~hwk(N2RtGy~UayWh@m*C1!!An?*MwlZYBVcSWI!Bp z6iO>aO-R2K3e#o~jqXw6n)J~$KeV+anYn`lrI{+c+Ua&(fFfoyh$_*HYgNU(Z$tBa zs@W0pIgwHJ3E{~~U3&_-yOc{GpIEIwLv29Nz?yL*njCMuUu6OX(=&Uv)xq46yIAWP zl$@el#-KJjB_^A8ZhY#;pbBR{yhPTFIS~_WXMUGlN+?$N{h}M{`qK?yh6|s*GL(VW z$sXO}gQ!rwO#29ghMah5rJx0GHKtpxsl%@Cd5QMNOI=XHD-%v#eiqO;M#LjGL3ktI{oO zvZK5*4ZE%NbfA)v6+>k-3G4-S4Sjs~+#cpKOsvob%0&}mrD!?R9>SDVKeQg{)zHw_ zO`h0i`J_;boDxry7E`Qn?L5CnuOd|X9^$I_F3q>{y0M@Qy!Oe{pWMzxwC&TMT?ai& z91(s=Rxu?QU>8dPZ7l2Wn(OosTjB=I;_|BJb?3Uzw^1)Sj=w8%r_gsc{2kltJJ)Zk zymZs8F2i%rHDoIpJr5KJGw32@=_D>ap0$E$gT*T*lkmo@ElitE;&$v9Icf6|b}P{; zmKN=grWZeAqZqw%u@|4b@k>b~7)9%8WNF-qa}aIE;!vTU=aEcR4%C@QM6gShnK3sTA+e!IGiE zTn6D(r`=J`X)^(&q@xt%xcKy#G|3{EZLIko{66(rHkfLWHyU{AG1-WVU4TeBQEYj|5 z;K0piex+-uz^*GqE10M6wSL98WG7gGafCWs^kaNpP~1zW`uDXnEVEt$*CecvBDs3* zAF)&Z9mA;(1pyq;bxYob%C)~p8xe@z%6`l!h{1aTOjMJ^yM!KWV&OZd|G8Y~ZgEQN z>{6gvh*sy74Xb@peBX{D5!)|iHE$2U(7 z>}?cOc{D>I1=$8s2Wq9aop&fp2`A9zeD-zw zv-ezy78z&iU-V4!tTZG93t9>&=`Y!k(n+HDW{Mm@|c z3TYf`JL;E~Y|LZ#djh1U!((Q0wh`bC;)M(IL?b3LnNG#uqd62XOT3LLY3FdY{1>fpsDQE>arlUDq#Y_(+Vu;Ng zBjcJMW4A@epS6dBf!nscD@87Eazo4dd?_7$!7_XmVnvd*i~3T&!|YWy-WXx-Agjm8 zy4lvG(Y&QbR)0{Dn8aVj5WUp*>L|_f^d+rkL^ZM(IqO4$w4-MYa5s&6?$mhC7I@h#we z)3MkUk+Y2$Zp}VwNXw+R62HKbsYO2I24&19;dfnjr3AOkH-3(%UepWf+)Aj?KjS*n z2Ix9tFfZdTW4%JEtXt^U1y)G#V&WE!6?RIA+?3ei*JzG{IV|6>r<7>PfP<`gwl}eO zgt!88lo?;yWnXIH%I^xVKho6{>RJ#SWtveF>6H3-kN0y!)B`{f-T~I_IWq2ab;*=s7b?RkxpR)fdEGWG_g_!GW=g z^NyldYMRMDULt_tOZi)<=F+4K4pAABvbyEPc_S4mbF{C!4y4U}=|}d~`$Wff*31Jf z_N)d$IqtTA%IU9B2DaMzf0pTLWC(UbgI5W^N6l_dfU&wL$%=%6Ip#N*>nRj3L=smG zI}N2M@M|fx*~7g*h%ejwneQFG;N&;WeF4o>}~b`rkw}rH7gkS20X56*p6X0nJ8(=C>fUIBu8I`IEvQ;F5kZl zp>5LpxBGuE(g6gj9wobx+&`vU$^j<2z?>QO2=eF8yuYg=G}tIOtKO)6j3EvwOy&|M zWUb})M0o6&u#0wg^%Yc=(|phT+I6)2N~hlewZypdq2cV$h%k75PB9h9u5{Gl)8g5h zo_51eCUbT=DXDzAP-AI57oZ8P$EIa5B$>3uBX-vHWg2)daxLmgaFE-speL}bbg%hV z-Bon#7{~TvM^)R`?qPE4+f{8g%|7*miShPzr4G$@sUg!5T7&5gxUm79g{K+{@VqMR z0|7)^?u4k>G7w91Fl!Q|=x%U>37ZAdaFDfIq?&x0>D5S2gJUKFS)kd3-BlRI#`CQ{4R2JM?bz{X^|7{~Xi96#wUP1KFFtb`2sElLt1dp*GW)BuvWw)dM?+Ap8v| zUSVoysPssSKpEEqMFc-`JwB|uy93u{t1;Z(n0T2IFv?_NC^6lH$vte3;D z5;8ack8{84PfhBU)&V&bJS$*HhUf;aUKViiFDK~U;4AeNGYoT+aRA;>IL2$DAn$D+ zmdk*Ka}8$Q;A6#>Pc90+`mtiH2#fPBPx$N^y}HwGuYSZ+YKA28ZorjjJwveZ1{7dI zIJa?FiXOvXLkyYo)*G8pQj;ilR@~yQuOP(nH!`FLFr;KyJoGg-q9Q5067nlkTD&sX zv%nJDV=<%dFh9i`9nUPgx90B*iGB0mPH*lP_xb2Wvo&Tm>UY}PV%-wjoHyonLR-Ls z;QD67gsBhb$kM#NIYdVH{hzT6N2?4;Wm)9*-5Om=q|xS z@bGH!(eW<7>*SG(hateBV#Hj1KZaUDK?f<0cLyh`dFkguR_Zx->6SZdt4Tk`0DNx` zirlRzXTk#TH*i$f5yr>{Rk%bQ4mC`JhNLafr`vglN-9r-=}DD(Xoa9xP#&(`(`dCq z9I=i|3#}To{ZPa0%&p5f+Z{e&^AJ}C_uO4+jdvfFe!?Jbis@V-^&&R2pMgK*H-YBHuT|FTa^%j3&GYxzLqc zQCHCmChCI$hgG|-KRHMCJ>O3r@fo^TIBFVo(#^QJ>hkl4xc4Tym>J~|_0ucq+7Njrw_0%?mS;}|W_yO+0{Sn_aqGL$n`?cfxd zF%R4Di;9t`!oKtUBbo8dP)Wqkk5?8a z;Rh1cySgtwoi-v#lr(grcYl&4W*DP7^B@2Y}VKv=;j6CO)z zx_a?>dxTr%!_nD@=}wAyD)!9rltgN0c738<{~y|a2eR5WSfpY6z0A$p9M}&}$cvmQ zs*=`BGa<5=>Te1hP9u-ij2!t>XOiJfCRLt1MPJr8T3dJIa=07K?fZ0_jU`l*eu`7x^*pXeGA-iY*132?0k;jlzCq1c(ZBe37j!JtQw!d|Ee zEP%vOS{aqRbjsMI#eCBs5-@1LA6yzM(4U~FD zMs6izg83JAv-eLhczWR4N={g{c49(7DY)ZmcJs&d19$Tr{^Mxfe$Io{lfN(F0ws2Ur-7pqJfn<0Gzv;x}O6x|Xe@2~y5$1=kLV7&liv;4==QJm~ zs&Gj|JGz`#T5)749B?OSWDl4-R4SrV2?u&|#Qt5@72V-rCH11GswcB?bhgFF<;b64 z`7r9GOw_D)Rj_|jpqx)Fj28wx{N{c?Y7_ekoi62p4#`{>3>BxC;;4l@#w5H zS+oC%|DcCKe!|^ciuvvQmSK9bj)pMb>J4?b!ZON>;`3r71gl;Q?@GJp>JwJc?=yrlboFNSjP@pT&z!NYH^WQH^?K=kz};8P#46>Mn~#Ia0B?x3l6&TT z;TMh(SD|n21onovtA@!p6qvR z1im@&dS+mo1$o@y>tPwFQXy*c6`5jg3bWKI{DR=fNGnJ2E_=bUUb!8oWt#XR|-P_dUdN&kJRwJP_!|`r`qlF-AUw*$$;{nfbx5;;;PTo(jcFN{KsT(|2GT% zDTEtrAA~r1Uth?!= z8}x{*n5Dwwm306@9Tx|7(LR0*DII_MV=eFgg@?G$+4Jg}eu7M-S?#7a!Sc~O`Aidj zF-wfE9M7>(dBxkscplS9v0})-94kMdE?!nG5ydn8AnSOZ9~>CEy0>PQSeG{<$&T;- zdSFm;_)DiE!%t8l7@pDfNh>}y(G&3Ht&O4tsU^!ob4(>l)DaJ>!pI%E^;o*FH8hB& z*9G|?Sfn4_tB1G4fs;q+JWn+{Aoc)*bVVn=lLlw0C7Gj7@2-AfOMoJmBtlrt0RWHcpjG6uFCAzv=U<2L^8*lu*<@ zska&?IJOs_rz%h4-Wlqa+iPo(OAvS6CqTa|VK3H%q4+c!DUfh`Hlqc9ODtVJeF|_r z)ax3Tlmu6?IxnDjFxYB&nr}t7y-^B2(dw&O^70Wg^TecIEx)$DckER02$`y!>Tll~s`xI%g;vJN8ZtEbCB9U~7blb`lyRVvQ%30LC z6^t`jV6$`vO#evD=`|TwC*vPNSfKAo4`Z0+nm2!pm(R6%o$q=otBBS*^-ufl*!7ZB z7VR+*t^SJ7t*+;4OJO%$vE|sxYisUkA~5ng@CfZa_j@bhX25-;BclUb4n@4NTyxzA zZhE2!a}ugkz&~Z5Ziuwx5w=yew~@U1Ax55a8qQg&sxFnKp2n|WAjedPXKrgk6<9dK z`EcR7DHne^zzDWh>X9ni!IKGZ+(hMmuZ=ZkrqA?{ufiA1P69}<9<&A=H2_1g6|6rH zQ@%Yz0-xb$S?E@MoX;UICyf7%~h+xB#3#>qA(cX?V#{T)N~1P2~`xi$FwQJZR| zkQZbp86L9gjIa&!ss&<fBt?IPCgWZ>2-d4%MH(tFAsPHVdmrDwU-N z9=d@08%8W8S}t;lY@`7+DmK3ld~n#;i1;>$52%OrA77z4-1`G+kdeY$0D#cViLjD$ zqr|Ut&0Vtk8V_?u@KGiyCW59|lXB|wi3jichq%un9!kl^w!4n+Mn5zRZBu=?IS-CJ<&1X?c)); zGxV9^r@HyOOxH)Qm}#8sFHt|htW~bcLLohYw5kyMS1$@cc_H*nJdXm*+{NpqkLVg~ zgkxPk9bC?8(c@vCraNOg%kIluTO0Ro2sYZ`(>QtRS#@QP-B=1(aNbkjg6|!#6XK0^ z`WinK=O&4Y^sB;Ma8Q{f=hwL#DmZaWzq0eMt`v9_p6eUdnV~tHQY^#2$KLqBr`{H! zTSs`7_Q0o6#r6v|KV2>|{0z+NMUS9nsenF;+~=hM#Q)O*UC!IjIYpqD0wu%0RYwYsz z8VMGW?6I__aNHX{ULQ`~Vut6EUeZoaHnA(CTF7585La|Q(Q+~4b(6U`#Th{Xp|VA{Z<`LHIv5tR^|?Ig3f1DN(ItH$5lD-cL_Rfmluxu zuT&+(6(1}KN-Y{mv2T9!!1k?AL3UBU&$X*KjhPgpuI|uDvil^i*|jMWw9ERmC8c8ZE_b0?yjXiAPTBNbQ-XhMFeU%yWSSX_JYYkMNr((JOn=dOH9KJ2XS z_;W!&(ts0$Absya?<5+(CkFsXLY1)-m}k(}@-~hTv7FZ^u4EEq7C(j)P8QM*YU2&eWW(i|B^1rgg!()r%h&(=d4(NWxlx&rtV)3a)n?=Ms)K4>G*O{B0`sf zOg=(;&-tR#*z+ZS6_#Iy)s8h2H?>a<#CZ97EN6ZL*5-tpwL__S%~4k_Q3IY}CC5=0 zaeTP;>W8@lQlaD3zx+369Z+q#>WLwz^>iA*lhz2ZEg9M+BntsLJ@&7j2FTED^kkAO zfn7P21r5g(3bWx~Un{KB?y1969yctMHTUft!WhaEXexj*2bFs{H(n`1Bh25ny0j+# z)Inp0Zp*gfP#%$+th?yk66;>hOHw`oi>HGC?_a$G9xWi<0h6kK^){U}{Co<9u_YNb z0vrbQ7f8&2$D^Ppvz%+SIf6jZ!UokJuP#w8T~xBoF%Lg9tpPC$A{%{V9;&&wKlkY4 z^UM&y?*m&+YiZLnNp=FmA#L0tK;0Bc_5>q6+yx(;?lYdp`p1XBv}i=D$W9TOov(8$PTufL!@BGu4UbvA8L&GPKc zlo@mW{lc+c$&q}heG9$EywXjmrnL_72A_g@_u*Hy|Cd2IFZ@0ON76VH6KhGA1lSJ3 zH9-*mmVgUZV=#)xB7jgX&$t0&9|BlN-EflFo|rpQt~#5iv?MoG3xa#>cj#V+E=tZH z{@Hh=*Ys3%Arj+I9(tvqAYp-a5fgrP+(|_CfVBzUwV#>%HwZ7$nK2J^JG&GemWh%a zSww;AUD^%Jp=S@m>&d54|00PWsDg_^@(%ZeZ!Q_ma7zDu=4`Li$K;r;)g?g-bQHV) z6&cT4vBW`1fWWT^CtYAmQq&7D{M=DphbQxU74$Eu>G0lev2`n@>-;dILvYL)CPfgI zjpVE;@2V12XM;#k_<1zS>D|<;sOYFCZL1^_?*X5E57#?tOqDw0F>N9#0Ku$3G~*?8 zYDyiJEfbh)R*P8{{=rurZ!*2Yl!GR%{&?)VYEOe;X_?*8MUQ&-U?DTdc&d7K@`Qce z9+tJc`PhLMoqfTo(b|26=;ZvE<`H=uy8J~#q~RO>2HhW|%ETtlEh+k(xB=j%d^Gwc zK*iv;NN5!g8FuRWRe0gms9W-mDyi|v4{FJq*TU+|Zu8YpY=e}lc)8>KG%h+U+Kc0OS>(AF%sG?#u0g81z>cuk1_BLRQwK$QJ zFGpm(udZ+ZIAw@MM-;Rby3%8)PlcR(p|mQ|NU8$(>m}Um zHg&FiawVhgsEVJe*jrfMW0#d>f#8OQGpnyVo98E#uj#!Pb1_UlmTlwdu}TlwX9XWQ z?i3=+@WI6~33-g60cBts6WFm=X&J!##y8cwHr{0v`o;Fz_1OR6yr;l5B0xSzJA%4} z(((p|{0p-6n}g{b|82Fu>Hh7bZJ%FXfl9saqzD@ADj9tq)$HDTa^N1R?2Z*8p!N+$2@<3+KLJB(~V1KyHN~ip_*; z&XiyH8~A8o5^MEahUtK3_tfONDs(7#)+=Q~6I1wS=C-`Y`JzOwt%>LJ6u+LG_5-%o z!VCB}etIzgoSae_!+mGn@u;<>$W7!YgyCAP^Ub%CP)GpF(^o6H-6luSwf)WllPg45> zf_f!@Vxb4{*M=6t-%hgr0;Vq6KyzBvat}xc85qsFwZTQ5MJd6gFa1}eLn*2O9~D7p zWXqzRSnIq$g7XU1KU3Ati;5}q_K-)D*Y_bojKF3w5tpNEzJ~84YYqUWweVLU%3CX= zbXN6ncH9NKb=$JZg`9_6Nv2hS1QBSJauR-dKa5ihglY@s#rMk5|Q!<_MP)7 zb#CqE&=AQc?C%k!x1VuK_)Ks67&y9ZN1ANl9S~+$ftQ7RO#H9jKdY{hu9L7YTgR`+ z$$fa;Qhk^yzk&|!2uF!G(0nCkT1Ls;>Ez1T*S=O(SQ^^ZfUF5mH92D{cKnJ=#(Y{^ zS1|m?&L?>nCSB?n@Han^tbH7wqK6at=xO_4iNoiYWwH$fvH~(!aGPbtM0z!6D5zNO z(P-(Z@Gq2e7N0M>Ta0zif-9c?^3JJZ-mwb%-s=H{evH(J{=dsBU%cqv(siNh7O=zK z9(#%JD+T*gX!aKV=Xq6a>_8*3{^>63>Ku#cVg8=6J#K+^cfURywc63a#Mf;r?^flO zs1>jED-6=D5B%{G-zL(mve7N3&AI4$1T|94YLd))!a$|pYPB6U_(BMKvTn4oBsAU; z%2LUSmNM2>Xwj}b!s>j)8@lbI(~Qnh&LYbQ_E~90l&_!?9hH3iy;KqHzGolsRF5yv zT8^>N;3&z5-*?;9A9(GqGR|nRm>X+bsvGS*w{tqn&60eK-^tk( zUYER?A1n4O=XHJDdVOO(_j&b|jX&c)fGRo#z9dPTCAd5(*{s$8TCxXVRttupkzCGh zqLSmBcHNb=(i1;1KKOMr$tuDDE9Y*mMU0ydzvDt>CFc8cug;{JeKNT~wIez^GW7AG z3?N&IQU$VZ1ltP!fH;#N-Lgt-Kt?wmy=s+zNzPg4*finRq{7JOpI4>35YA@^ul{yc z?KI^?Qyz>U-i-*On&vK}2Zu}?f27!e`vBe7XIPe-b+7U0N$D554uP2<*fL6igUTU+ zWRf8h-GHR7`g4p;ZG;IsU~bxK6s*^b-Ad46O{rQiO-vCM4`duzFO3>A2d#VdH8`?YrkvPbhHi59R` zSLX__cnZ%I67x;L9;z13d zJQB)B$~4*&G@9ozJuOAcg(cpH!?uAM4LtHWlFbTSg9tlM!ol)!z`%Dr*?+alp-?$D zxt?;DtRbS)Gq><=-Ml?dZ10z9VcMVPk$e7+nsl+l%2C6URlmKJG=qIb6zudxi0rzw zJZeHTw}v!vc+P{Yaf4VH9!+J^?7t2bCBV4r3e)frw)rr+W=dlFlA8UEKbZk}$Xll> zf*mtzM3*Rq65o;95O#s9ol|;Y$+Z~RIR;j+FE5c~Y|;`Swn48E2yTHE(iD<0%bK|a z+p98ECZv>5hj#O@5p6<@k7}XPC^= zDWyE0#ZG<7Er9dy{>`kSztAsv!kQSyP}=zu_V~szgSHee%V8f&PP4#pIA?E%X3b+P>UhT4RSmKU!g+3cS;So15nquBG+{#< zOnnS;@+k894f+ZkM)1Dz+v{FgQ!!I zKxYxHlD4c=B`VD3!PF(pl`6C5ob6pqBu19%@ZqFN9d}9buB%GAjPiq8?Eb8s#{G|- z%>jDINBYICUv-}%>uP`BZumD{vmOlC7VgvjN|FJiqcW;NAla+-?`CZnCZTtj^e)yC zhT!GUZt*)+emcMeOFXX4XL24@{ghocR9~u2j>ttCt9czCtFBWJO6g&RMRfD?&Y#-< zPV>>z9S+j>-QPv3o^G=^@>%y<%PFBq0wqk&UMODZyaci66Y~$A)aK1+XO07DPl$B) zo)`N_onsAfqLMVL#D~zdgeqk1ISoxExH;8^J6@r(zUA6%>?S;_i1|g*xt;40Nm@Sg z%;REibMeqlaVW*1^6kOG)-NTcJ)-;SL_HltAggAh2PgZ5Y6opT>{&K~BmIP|#l#J| z6_wr;iy*k9d|PVmVTKV>Tx-hT}Wbuko%1mBx8d!DCPCLRJiG zOYfjvJ8oEb2=Boq@Nq^Vph}|ozx+dObD=N9M{hO2ZNWT%VZ@~WjFbWlNvMN!wUQn5q+Z3?cWTh=5H=<+ z4LI3pZ)0iQ6e%#{8lU(eoV`Ezd;7Vu$l1t}g0~}AZ_J+QzDpKfD)v2p&hOP14ObRc zPj33k@H1R%3+>^0-=J$FSBqM}j`o1IK;xxEOfg@z8Flb^ zcF|lZ9*q>wHtVw2nog6fFQ>AEzd(NM`bEVj21ZFcER4ZZS1ZECGsaEAKp zV;Oh!SbC5?B0vwN_513U?nYCT0#G2N_^Nbu1u*o#j@ip`i8)3+qS(@%oT|xX7`eqq zkjhP4C8_^d`1Cw^0DA;}(BW%oRVBt&HkM~~vmctw%v&;ZEcCQ*uz#Qd2JE|GPXT75 z+RryTsw$MTo7_AMs1kM@Ga^*A=yR*7zm<3@Wk-aYQK?6xc~fL0aZ{&t`d{8ub1(Jn zdsje>=+|06_1RWHv#0LEwLm0Yjc#%1+(zJb2`&Mpf?Sn0aWF=OYw~iyn@7-ZSgk?> z<1~jIW5cyy;1**C8(22onuF0#&t;ViMt*Ku#ZXHcZPr~V=XT__AOAygLsDX)hwDZL zDs{#;;YIzz{1&PS3h5>udP_M>_hInJmWiA2H+5@Oi%OIOP=B2EW7?rDg-Y)!7Y||P z!}6Ni3f0z}z3`H`!v;W*4v?@7*5h6P>NGha6CQIXMnTnd zNr5Es_o{BE$VJOAkEThPtgKexNkYoKz7x3YP;Cxp{)OdpJ{+AH&F6s*+*cG|l{wRn+N zRN+^6GaVowTag;j;3G(!_AL0vL1)utH01nU2qj*Z;lQdHJ41VP zsFptPlWNWT;wV|P$YgNLEA`UfWnB6r-;tzDx6!Lbh5=AKCBTn{>COc~f1p|q8<=xr zy}8HqL>-$*u|G4ynj2sv*l?_Za%?;3O!vo_=P8sskx;1KoWD+#Bi$lC2n>90a@9h`2l8oif&04thqm7%{lXT>YBjGyCv=R4Bk|?3Cy5ApkDIU7Hzaw zhcoqx%-F;=MInxxqFYf|%R6~41rCo>>2hO7KW_U^y&pO6>Qt7~_lIhGYwgVA`EmJr zOpAHk5thhuT?_Ioa6I&(u8Ul0@S!PfDK*Cm%a==m;3Nd~oSn|8lNs`TM( z;<^3I2=Ol79<;DQGTB5kYvym|c75nE6Fvh338IbVA@|EhO(P2@Xt~3)GpXVAu1%i( z%~8>p9u5S1RY#hqwpE>V)WeU=z|A)g<5Q5%gMy1}jT?-jG}4oXp9l&mwnIFACKRT$ zd$G;x|5Rh5+(DMeY8@6q8XqjyP-}!5d#k;*ApPJimrQeR@;VCrG+$M#jtD4_)PY^1 zFM3^YFhVhAdv>hhfV9aI@@4QSv@_VY4DFV^>UD4#a52Mw_1dQOkWJ)DM6(8`-q9(L zy%#|_o7c<#YUHZf_!yAdSP$l$F?beSGj*Wwvy!wNK28XlB-ZTb_`SBQN#w5|V{O13 z6;yZY*4m8w4~3VCFoY})?BIPHQ8YRW@aDk($G80FCs+)=f*^XlfJ@h`4ufU+0t0vK_qX~XQ4!WFPGsaWSQzDDMxxQRP~0>S)QFd@IuhFp?7Ea#5qc( zgjo3hF!%0JO`Ti2I208XAqoNtLRzh&1*BFkEw^Z?q9Vpi1r0)KDRPOJZ2^f8vQPw6 z1Zt_Ug$)t8Dwl{5P{70x0=so9cMvG#R+U?1rD9x?mE~{J{q66Z@jK`IdB!;Z=y0@S zt(AAJcfNBz^O?_69@*68mHNQ&mDj(AlmO{Fy^vk0)Y^}UXIvvnBvdfm2B%Kwbb|^O zPzkK;B-tyZ>`?WY6m(SNy#am1OLt~21qMTR&EKV-y1uZ2kuxZ8aGHnd!iNLJ^%}(d>kI3Mx7lP?Deh9{Q z6H^Z@&P|m`^)V00NUl&}%sBEWf4p~mv!mkPeN#b2ZIso4)UZ2IjmhLc{tFsj_-nx{ zab-NQoT=4k0VJNJbpwsK%moGtY$w5b+`1P?>+d?ojj%Cw44`CflvR@IPDLTT9gT9| zny2hq6_Mw?9+eQ;is&!=$Yv3D&X@YJn{%_?ZHt=st_qFPeZ^x;RvDY%Q+bm%WXk<+uG5zR}tHSWQIhU2Sl|IZ(8k=o(Yb_bEgW!kmu^B~&14A%pJ;N4)VJgc^Nr^$p)o21|gj6is! zT#2KR7*tNf#low$X8otszE?cWs;Av|r{0xL+fsUteAB}`V|KW|Ci*R7Le)Fi{$#V^ zoSXJ5L`0{^Zc@xRi}1~ANrmc()`?{kCtMV9N-2_B5MSckup2s7L0;9U?~;e|r+zeaysVS~^EywNxXn(8Uq-r2fj8S+_jiE- zt`D74RI2QPY39rTsTJ-QhlWb>ff~7l6)wtWUcwo&=h1I@>oDKL1D=lHH5MFt?2F^9 z-O?iuhg8m0%i=Q6Jps=X`ic{b$K-yhxC^s~ri`V6dV#%BsdCf2(fy3Oger6f`_+q^ z(m9_H-JpF!W=;EKA8yQ2YV9O~WomlHT-PdAIK6LpWavn*OVAoH_5A+2?@h}*`((rc z9Km=CThupUskU;LicA`Y=phvdP( z7wa;s8@kLU5~!DBrzxvBJy4*vnk$0*Y@*tvhZ`EzmMQF$iW)PCz-;KdriWRML1ORC zJ+a8D2)F68Wzz{E+&d$`kL=LL!R2=l%*8Ed9%_%_A-WvGmh)6-iEmKfSIT@-iLG?@ zPH6fDGyLFB)>gv75540Qpx)P8 zzf)7iQj;l)P3uOBM@pMDC!RZ7(Xa50^U7dL_m{>2hJ4ib3nW{rC6v5wyI^ zY|E-(EMflWmA76d!IVRc#L(Dig&tlB!~z%viUrnBICI!dqr?CWXb6{(jBs){$BDgk zfW25ww`RQy=)Yx*m*0#yE}wp{ID+;o8VM_R9C(YJNI z4LqyysQf}@>FLH1PC|jYz=-6u$w<|p&UPfO)7iJFrkF=iGbgY8z~&AZNimv1uCnq_Mt%w zs~f7^5;^9wU9q>paRa~>_u2liuHXH&*WiHtSRDZ0+G0ap=GM8c6%+Zq%P!XR6~w|` z!=Z#NFt%z_9%@Yy6DDZw{j$d_OI?w8_7WMHRtzICn=uF`Irz9Bb^I z1G((x)<-S5n@g^J^!gV0Njh!?$?D=25RwF?x+Tva3sadT-m61heh(#o=rqnc;<^3vard8t1D*wY=ElBQduovFQ#nD{_VI&59vi&>7es=ihzv&Vp#ldZ zjxIo;)bD@~=7*9MpVAwO9;wq0WWWRpdkYLmjBh~c*Enm~pQ6Fniq@*#GUJl0eFyo4 z9w)H)np%D_ZOpaeWLR8j)JMx|n41!JkbdW3n*(w4cw(8bm?M_*kM#B)3RRU@S-l#c|P*`H%&$|QPFx8O_s8#KMm zFNv`WyHXv(;H(?K&8u0n$9tT4Yl9pDKzUXrNQpmx2m5}0yq}dOK2Sfzb~#_2%mI$z z0{>f91#j~};dWwKkM<-MDyB5r&Ae5|bOy^1=T8MuhhDX>duMVjX`D1H@S6J%@~&0Hp0n~KRpobCvoo&pl#&K z`(n?Y-_%qCUC>+lMA%=TF&7bSMr2h|SSBjeZdxGev`ZVEX4Gg-2X`4IJUd0Y$n(66 zk_pSiS;t_6VT7Nu{m;E|^7T#FtLZ`~RWW6p`aY~q_K4}*O&GFwq3M#AXfq$| ze5FqnD&g6kl&G&RHtAC7$*zqkJkyNu*CJi}AHKJ{n! z{q&qCR~om3t$4mT?Z;c>8GV@(W}m*0@Ik`7Or3KNPYZ8Wn_(am)H~->-%&$0#6v4CFASKMxI-EY~OC+lTJ#uKzWLvm>-k;yIz#P#_8MI^MOH~! z=e?P|NS&G8PEMzBa%9x>+#;8rt6d=8IZYw4w};EQE2q3y4{Z3^59oC#-zM5u&QZ-G zyS83e*l$)2I{OuhPu;6;ng``=;Jm zaq?WoE56ewQl9X-H>Tyg&Tm4Abjl!BVs>$ArI@?qEhSy>IhI#5>1~WfpFN9SA-0}i6?{F!KF^aLi?Cfg z$UEJdSU!fGas2Tm;P%lT-}X83^=kzgWiSOlQgIcLRnyV^Fz*=J&T;66Lj^*>BTa!k z$tX~5Q5+^`j!@)InQZF$(A$&mtZt0)(mk!^Wb^3|2lc6zvx^(9N7}AMJ2Itg1@=u8-%!fqN=k(A+BvDUz=DBTd+wFb zriGXfdfJ^-NU!`D5*Z)js6N08mDhN5tlFv6ee{yNf_gVec&IU$ru5UErg>=}>+ANj zA>e{|q-dMB6^kU1u5@u4eWLMKdx2kujUW91`bx|lF_JsAQ*0S`DuP;-d(iBQ(2G?a zX%E>n^w#hwzc03CYe!_ogBxyt|L%Cj4$_NPgfSe~7cUX;!1W*W9#ckqH3*1`l;`Nx|%Jb$mLEZ)|TLHy!}_OK@^!7PCOhv?xywieZ!P&ysuTtOH5Ahk_sW z@{N+{KJ|mnI=A-viI2&>RMkRRcph#O27PCJbz(<61#%{#GJA%rKdq**JmhvTe1w=2 z8?b_J2de+*iA^$VEBXkvyXwL-Wh7*kR7JT4bUC;lnxr|F=D7obnD1pKb`Rk#$TW3T z;8tR|{u13a&SH)P4mCm#Kd&nhtYR<4GGVyOc0E=R%~10?2~+GiD0j5kx}Y$sW*iAr zg!T~E!Pw{x?N&FkYGnSWykdOsJxuc3*Tmi1nm=3(lG4hEpE6uNGf-h5h4nI zAT3=R#erna6!%k7hqa0(F?|^*{~317rqRtk<9S%UBJg4y&j33!bYS5H9I9sg_`I{4 z(U@m7cz3X>A|k=lt+}=&W^qjwUypMNJ`z3|JH5Sg^FxrM#!>3_W7!bd`hN+KW}8l<$)(9DY1$bdbmE5 zy|+9v!2MM!k1r#&P-HwZdX$UVg;n5Y>6&IBUr|m|ho=>#e5_TO8dK|I{Bdg=( zv#@?^qhLDtrtDFoIKkmv0x22ytqGnevh4e`IqwL}n)2%^BJ#sB@9_OvYLG+S4gMz# zUT`rtXmuRb8shNDUp&qxVdm;kO*ICZ$BI0WW1@mc0ryol6RVKXFEtJMb_lIF8`y_X zO&w6#4!qT^VjsQrI~LQ?9QNp0SxG@^-`kd8KR;pCn7rxd2?GIK^DQFs7k)=IgT->9 zM9ijAjA0fKNhYrqY{0|nP+l_IOxAUgV$rIvj&C=OdZIKolH}2HAC_aJcG_5H8ooWW zghfBX5SlW!M3!yTPBwoN16~6oaiC59{?>vv`BXaO8|84TYPg3x6QV*PxsI3C>aYEQ zc!n(Z3_~k4F_;?~)ETlE8&Ky#$x64H7 zAM$nuQ(p3FP!9s^ccO{Din9^-f`?3wrR$kD{50_^TGbJL;?M@ixHOaOGvR+326Vys!ZD`$7Bp#EO{kn_zmj(mU0!7Y(z%cpjb zU;WQwrVCVw3q+R`J>7|$y6wR1P_aLjgBrF|tk`UUNzbfju-ZpLw&HlO{En$PEq6yV z0|A>e#~K*r`u$*@9FM4otEpC@1ANxl$km;#P$56VuP*Ra`OZ|I1EjI^n^FZQXpkG{- z`59qjf0*Cne310=T(UQS6c^)&ry=ILdmr?oXNM~^r_|3L)?!SNbd6xsB*$Fs%9m%~ z%U(G=kv8(~aR2U%t!0d)3h2lL43feb=g*%+m|>1>W51qExL0ueF{PJEd{PJ3v`2dW zj=Yo*gJfK{*#J8UFY+P+>>|LyWdEQyrE$>$%{gMM4=kGe!O(@0m0+2|R~`7E7lNec zuyQ(~CN@T7Pwm?iB(IBk)AQzKo#NbqIjBHaNkgXZjZRaB6S9i$KB+Jcm@`0MCR1pL z?6R;fyp2ApB$jVL(?MnQkCER$=oxM7V+Si|EhgMeiqDpeu{x<3QnFV;#=d_2wq;oj zKgn{p+PQJEoROcK5`R*$#`#WdN6Oua`Kpm9jUHJl#eJZEwI~3TIX;KJPX=U<@1Z^O zehdOtFy#j(1g?7(`07ASt}a(#9+mL23Z0e@8$2ESjQ|pRCn8j zJHp$mHq68gwG`~gW3x!1Q7i;g0)YO|TKrH!XF{wy5QM2RRnXV!!bf?RiqTk5r??>> zW9~|#9(z>BJ=!1MGdDVBctVe8W$y)i=@~r#T9+=9>5;8BwVyFUjQjbSmf!Y5KhP3m zYEpD1wIE<0r24ue(T{2x;R;%2H45&s-x;)ZZ+!1e{*A>I+ipPTng;Lr)_uC1VTJV?)@Qbc%?dt4$-kO zC54RdR@2kwysX|R+}gR&Qp1vNv%O(VG}Jbo)W%!GvO72!=d}9w69uXSt;9X8p2@T* zygdxo&pj(>W75kzyeHwhP7JdrQZn+>;8^9+iyk4ZHh%%5-#id}98A z;d{w{1{M|^IO4P>aJL5(bf0m&W~c5S;N_AovuIKmDCk+lN|vR9e9ik424n#ocb2y{ zaDezY!Xo60+~3H?xGPp5CoP@K4@u4+fq|&3&Tq2*47T6j@L

&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 z#Yxj5(L?5F4k5z=aIJXl->`36(T2}tZ~T^=U(9-TFk~JXE>klLxtAd&7pyQ4G$a}; zGQhAI7E%k}kd=J(lx;zJ&7MQT91p9aip`L6>twUlYBOb){FFAKItd)vwL%q)@bqV5 z*;I050%X}0Ijc!$o9m`ZVC74uH1whi-<`|6-L*F_hRqgFUSeBvvi0<7Hg~{i`HFLp zHZaT#16Ud{Gnp3*n#@Z)Sfv0HKORvPQcGOckd$0#T;mzpkj%D_4(h`!D;A-cqv9eA z4Oqd8EyBX1hXD{3*?!ZDvqTPZy;skKt*O`uLtd4NO5c=TFb$c&&@>bP!2#C`Y1Rv1 zw^exzntkFDup$igz|A6&AWa_itq6T}h!#c`dQ{>OzZM%MB!0n^&Tb^50VeC2_iBN# z*%G8r(-0x@07YnacJh|O9>kb2NXj#o-z(&wa(HD9AWO@vpBeTG56;m+0^h^1Mz~ZT=fv+-TEx9d} z=G^;ZKkHPlmE%c!r8ktV^Dj@C8_G>@D48-@CTuFbhZ3^^DDf+nAwvumy=vM|J;Z>~ z#~)A2Jh5ShocKpi&_JevmqX{UeB7P5fx@3 zh%ltyu(mNi(?lBy6J<0kY=UZNW?}Wo!q%(;rK1QYQ$l>Z@nG{{0S16U-(nH)h$n$e zVD6iH$g;>Uq3zc!o?I{_Ae3N)Lh_q6S<_SUYt|IQ0BB7iB!G#kOwD3TW|E+fCr4zh zAPYFnZVx{pK?c@C(wpT%i%sch3bODF1IKI!W~g84n>|WD0pRcK@J=pG3`$y5kcwki zxpD_g&)HLI9am}`Kd&5Pcwcw}8*dbZ7mT=lV`W#XOP(i9kX$m6evEEsH6 zH7~&Nn?;_?BobE2Sp4w-m`YF91ROcRR1qpNC7C(QkZ&!{zC$>{hzG4D> z_$-uiRD3b0yp;86>?myX8bM{M0N0wRR{e2ZW8f%~)=`~#an|(MrZO-EKxjn>q6$D` zw)0-7hRE@R$pj}a_4E@z4Yu?Ql>)6sa4vitF#f?4%tF$$vRi~*U@Dd`v(w;5CO|e^ z>6e(1H+6qXo6x>BDb=YsJhrYXvRS23LHu?VO$k>c zJQ&(x$sPMiHt-BbnDV75p2#y1xkS99(DIn_qV7V3q#2%-fVN`~qZ!8O8jUa|=6Gb9 zJ9_-sAbXLJ6carLt<5p+O!;k^o?cY?_F`-m^Pt>^3}qo-P5gqP_X0o= zz%;hg#5wmeFbptrJaXJJoqoK?HA8rr0@K(A3HrUw4ndMT%%XCh4LM@Y1p$C;ir{rZ zFRG>pX!XTY56Oxi5164|Q+7K!C12JQhF&jbCS>vO1=F=cXs^PX(fcK9^}!{#*MZygO4oGh;Ztve*8Nczb9@aK+s z00M^AQT&E32TrvK3?P_*iyX6kf`La<&Nw|mBTS1VE4`sm)o=hS0h?JhK`5BWr9)sC zIeP71c8SG~wB{r3o-vCUO)EB`@h&ah)%{*#f{rHhb}Q?5q@p> z#41jsDET7HolCAcv8@vxB_<~b!yR26nNvnmpO>WuAdH?Ba+ry#L~t@OB~*O}eo3;F z-2C9Du9APh=hjU zeCb8&c7qXs=YT5K66cY3N+x=?63JuCd%ZKPq_&@PLGDgKWlEcPOtts0RUAk{3!SE^ z0%Ir=jeti+o)y{y9HpF)0Llv+$JXl*so$61asupJV3TM@a`G_W|s$9XP2U#O}E zo`#1L9^%^6au!=uOZ_N!y-44(Iz)1;GHqbUYTl20hROoA%RsZ2pK`B0hH=+gs53AZ zIfbE@Li$|T2CVdkwoS`q2*wtkzFA92LuSrIf;9a`B3KpE=MhHZTFWUzNQR|Q7zi5@ z;K|*GWJizEf3MNk1f#*=0cd!v{@Bkt$6^PTYT5d4;i8nha8;xv0c4VF%AXo8F?Cc; zGCom+?l`o0@fBgYD3@*)@xWJAyplg7%IZv zNGQ2xtG9wo7-GnZKu=a-J(yk@399GWrJuipl>*rmSh3lMt&0Fe?thaqHw8ujBS^5T z4S>cPMdCagYTPjgBbSSphcp79f!_i*XDXSHX16zmP_j7!kR7XfQ#rz~!d8bMU~AMZ z15l;}wx%RPqqiHg-F14nMu|!ptXJX)4NC{PTY^llg%J<$Ra;UT7z{zLq-GC^2Mm=Q zfx8?OM6!Z0#kB+zssNdQkf1DVEs#iZfn@1RBpMQ6-`;7B2mm<(t_3hv>s*5&mzQ{? zKsGz25Cd7GH!A>Cwiy>nM1O{C{jYfN4aAK#lSR-xa2|NXv@?TRa|Fsy1KmX#z8Rm9hEzVel?grspie9igQ zS6}V;!ITnwwjHe-Z@lp@|MD*tR8c}zxRYAT+N z%2&Vo)mLr*qyPrUPkiDNzDU%|Jd3~UcHpXO8K5pUx)2Wt`Ketw0$xl7d8@KHtO4K={fQ}p57F+MyO0pRe>c) z>F*WT06nz;+hxqs42nd4Yg=pg|*ET5&q^wtD1#?C}N7S6+Fg`wq;i_1XX|PF70l zFEjYN4FKeW`~qv4S6Ef`3hl;t-t(TC-#ltbFC8Ax8*N zRYQXC9(m-E*SzL6stqPi7$jTIo87pje4=Y|ik3S)fL?~uX)v*Mc>PIbR{0-ExG_2(pX>Doo~hD z-9A@&Js4&hFfrHw{$hb8vj@Ds`0`0#|5&sbT6aTZe?4@mo>NOO&3* zo#=uiD4%xp!U+tsE5?!TG1GDiSQyqLa!cqY5<^>UFFDs*7Xe#H4Y^?O`|5o!FzpXR z{5F$ZAbowimm(Uw)(bz4KZW71L=^)JD<*6d}o7gq2eT~fRx?KUYNm3 zv*NcLOWLI@jDn$5Jr}ya9HMAA4fWN5;N{9ABPGI?iP9HjY%n9InVz1n;_=|+ zr4f~3`vgpEs^PDuc-1#0cujVc{t5v=p|-&|rNQ|ZfAJR{=?Z3i9Yw&vJ3||QYmN-_ z+g?TTCx7xMZF}|SS6ROA`@ZiP&v-_z%ajSd{_)2j_e@D02WLLsJR90+=~lt(~S~W>w51f}dH*G-mUX$K9y7gMma_ntIoZEC^<6 z;?$UX;dI@RnegipaIN`UF)L<(4FBkl{s?oyz~lVEAN)aI%ugeoFa&I>C+vIgy;nRc z-}T~_^Y^ww!mwfzo(tEDiaYGv zBPxViLk;< zDJmKm3^)mjCJRbU*i6*NY$noSB>?6kfnidprpWc>H8HG&W@D(QSz%^aSq#fGzy!v;eBBqO8k!}rGIbC?az%zDfN98E!TeUk z!Bj(2L#ZEpZQg$xJwHmM{zg=qjk@WVetwIT}vjrHq7CS$B>Q=9(VZloSMC z784IxUch7u$qSFF*OFxjZx%y%7y>r4xn`Khc<2cgjlReUz!Ub93gx)h1QqfCt%$zi zE-n3vq~XM4m~DN`W-H(>;Yz~_xQf{rwyR*Y3P^9(x+_8j%%z6i-NucBSKTMjdX z&o)EFcWN9lLoQ%~4W=O;fRPP_YMb@y1}s|UtveZ^Df_1#l`l!r^XMq)vEd1TfuIO% zDNSiK?G3Y$XKRXvudE`c%uuhTOg>_N?B8MKa+m!O{iGxlAr>V(eXCAZHJp)1GEADx zXJ=F_7Z=_#vz$`WbS86A{Cnky9*m&^_OdrI=h{#h$V(6#P4N>9C+4FFIKk?d`0*8CLDnx}Bd-{wU`o$?>4IDn z>KXBgbsV-`fom0$GD338hrzHis+)Vp&9f{5Ks7It`p8Y~dfBVtqG(&JDTQJD#ULd7 z7y$IMyF}GlaB&IM+5sSJw`0&yF~`Hu&?#tV(&Nlz^Pv{cS|ZR2<+ zf!*Mq3XS+JkHdM{WtaH`hM@%(wuNcI$X=ekJzmB4hAQ~7I97yQly#ecJAy^!$S>gi z9Iu|rh?B<8^a2yAcbNS~-zK0}Vf923&(gbiC|eYmNP52Z4z~4bQ?_;Zh2+I=Tfg+B zFZJVcD{CKn0~0_)IOoT#mHnH)`5OXTviOxzUl^-`fXT=wK&`%b@E{b!OJ4Gl-QY1u z&@WSb>J75p1?;mOei_7&k`)1xBjoDykN^0OR!Y%&92AvV$l_V`r6X(wkIlRKeube# z+~tuTK(v&8FDbP$6&rJJ?B^Y(%UvYa2-t2>Lwfo8)tm72KKD?jIh9U4sQQ?Z`qNZo zO5PD_tH>`7FQf`s==N2sZ*SN?_J&VXI4P!V9{IM7Z3a8UA@y5qThZ~)Jj{ud1)&&* z&_O`!7iu(Qv-NhVX38&L?0Kh8%!1S$DDp6zV^ny=BiYWi&$0FT;>7WyAZE#6dcPp6 zAjPSAesNrtoML#v7ntB3QokR;OfN|KOD?(OyT0qYdf9S*{U>TSiFJ_?a=#*n#6!a` zyiXc%v8}}EM-%*pIRE*d|JkQ{Rl|BAS0eFWb=6gc$I6Fh{8k7FDx81+_kUj`_58Y} z1ppAgU4g`qiw7Tk(1%$G_z~`{aC!iBY%hc6Z)+XSL;mMy>Y71BnL%cVH3$V?ys&PsksQr?MdW5 zB=8vra_pCGWGtK4R&k0QsZjk}Oc=0Psme zpEeS4xdDuMc}^VXO%Q+}eMna1c7SNrjz&C6rmsdf+;GE3KJpP$6DLdw7HY7ghiw@u zg5uy*oP0TH*Is+=JKy=v)}B%rDrVuyOgxMDgU>Fz0+<5kI%RT+=p_ZFM}quUe&tte zTx_K?kX*)`W(<3k%{RHpns{yEQijv$9WLqoxs#YUjyf`BvRZeJwPVg~rl-~3IV zsoF)R`P;w!Tc45>whFjwe4z4{TW;|wYz(Vv^Un3#-uAXX{^LJ}l;*Myd-(-pPgZ;Y ze_8tXfB*OWwc*N+KQDw*d+&SSd-KgVL-ru(IKZuhq8a{$9`<%R9y>XTcLUN!CN z!JLc09m39_*EuJ}X)>f2r@D1Fa^ZQ)ed3VP2-}pa>D8}(wK8JRC{6W3NcK3=C*KvE z{#(EGTR;5a5Bq7#!DK5V6_7~5GynBp|MlPdz2B22FixMDed}A_T6n-)51R{J2rVZ-4vS zZ@cX_E@WruWwgy%`oX3@_V2JbV=O)!(bIqj9lQld@K^{xwRBv8X)p+Yj8+VKugx!h@r!*9*OVju zv5$T11uu9(rx6l{)#OX|jOa6W*3{lIE8qy1@l0R{onBtdlBMb6y)(NKU_8HT&Jx+D z9eu>|8^7@z!8n>`m@l;6mGQxCpMR98mw~C?in$>MCK_`$kq^P~MQK{`Ft~bq_k$G>?UCR-Hxb410w2pLYOa zLlUNFFZLH|1#C8{*#twNvmI*={5&9^)AD`SR;`B3yH>lhI%8pI5B69HnN|U?iKKHo} z>1RIk8F=sLc=W+07+`ETi!%oQqtiO##Gou5blklFz-5Fae7*nG>#4|l_GVx7q8Ite zN=RbB)}@gn&m}aTSG=vD8iofISSvb;$&!zJf+zHBC!EHkCy3fNKB5sK_^ga<39u;RiNrio>?-l z7D*}PT()8b(3EL6n)9jY-2w+jkYeMy2qi*R7h!uOvQ%VkVv|B`G)ELTatu%);lF6*+fhq`&O4%P3{a+^w3& z_*q%UvFgW1yGyi`xoe@5v%T0~z!k7zEWHy=uyxyn_K!AM1K2rj2?vt^=BVmT4eT5R zwDzIvj%&d^icCyC?{(M0Ji~jJcE@XXFBNOVblH`YJ{ilKaGz zET}SU znamROdB>)PQUC^1qA@2FlG!1VonTB+Dvly(dqeX7cx(@|m;uDoHfG2X25oD7`iaMo zI~YnBHMaq1;x~(A`WRr8$cs^t%LpdUWnXy5J~JZ5$R!8y+D1H84h~G^#;gK~m4-kW zzBS1G`R|3wX;Gz33+t{E1pEC96QMy@_~u#LhHVI%|^QbvxS? zZFo!HUNY70YHzg1Y%R0E1dX!Ybs{CQ{V+S!8d^iH?ZzUhH=HICF}peKRn5_6ML3aQ z%C}c9a-~QpWnpxUh|{5SBMH01m0o?sTmvp6JqzD0a$BDBYu$xm26uM7$9We`eQ%qU=cTJi^L6Q92m+3V=~OtD{#T-wQ)~4|4qzd z5MFg@C`ba0Ug6r`Do&7EZ9?59@kA*{f4HDrO4Q!s2nk0TkEYo2q6e^{^`>a-JV*j% z$2c#P3B{(5s`X~BQ5tf(akFJ()v6f+U@!!eW`y>lc<3o~h7IZ$+TV`@^s64HVlYK;m-Qw308B8L(znM(DF!nAXvnh%7+!1_WCiRH`^P@FbL>Wo zPOw94gW0-m3F)VSA$O14A#Br}NPPt3tRb{N{UW;?gHlwyPb2^uJ*R|V1Q><`JCTqa z4M)jmW=mhp4?Zj0h6&on$kxbp^#{B!r(*&sbL$KDFAr0WO%(rDY$Cbs<=+Z0a2>cD9pqrzzl8|CoRgt^#Xs1<16_UdTe?IruC9nx-DCEg|h zvu(1a(N?gt9-?*J>~?ouAF#+*E_OqrcOsZ*gbej(9l**^j$Y)%;1Km%i@cB^i)z`M z6$wrCffO?meHvC|p%ONC%J!{=lyTbgF+VbraK3t$SkV8}M7 z_(jNyoVgyxdjvMHyUvZb3S&7$tBq5(t9@b?o?Z`@t=McuUI@Y{)Q}sxGcRQHVgr+f zo~`1GGm_~sXs4M+dqe@9Jh>@|LX-oIM#!I-0HmRDW=H@o#BqN_rzZ%TjU@(h%%ZY> zGzurqA}8D5hP&R+B7PN+z7nYqzg(ylp_l541A}>j$uB&smNUT8JI%o8owwpgZX?pj z0-%hzGt0z~Y!)-Qbt$T4zK|N2-fTSZIWiIvHb?m-=RN+5{RLS8TS#jI+W@_;Tle;%Jh2bszqdTe0y`+4R{G7%8gnYyVCjJkHMAv*p;l~cAvc+y3Al@eQagiv!bBttBLpD8TNm;0WtIjN1F(A4px^SH z-cem*kN{iHn_CN}3RC8q(q|0Z(u%(dW`^Z!2!jfPhH7ep4W^iLH%=kRGbDf^!KO82 zD-5zMaOx$?5Dd6Ba>9XU2Fu=NBPyRMo?_%x1cutwrF!YP*KdP7y>f-Sm;5{^olHS; zB!9U`L3sM!i^{K99I-!>BsBu(bzRXj6<$0I3TCP;?MMn6sD zu@wNB3k(s05jH7pk1LYO=y1{qRiiBOiGgy=#RF`WF*ETaEY2!VGY_>$=r?Z8ym5sy zUpW;OTCVdOi6>YwV>&`VFFK|3$qo+aDpt&iT|Q-;Z?wXu!StNO=9V7 z14C)HTiSIngk*Yxodee0#ft4gmD#(6nD63KcIx|?*hkYSwE{y$sHY?djE0G)92&n^ z*JGyKj; z^~Ym4hZ-9_yo1l&(5xYttGmaY@vd&k%n2G>y(_(Y9w3Ii3(qWOJa!CRpG_8?VW zIC;^tLO78?8p^b%sC?CPO3;AGFy$g_GNndiO2ebAgk$efbf`t#^BUh6MO+oifg6r# z^z&+z5;FX%H0Nz7{@w`?Ij1T`Q*5H`Wyw^#R`7cR%XaV7K}s&F6#-_30!SkadKj8^ zi8>qB+HmB9_yrHo5Yj%gldO?_MkALJVV+-LJSn9NpRfMwBN?EmELH)~wEKIb8@Ygs zd2dMk!k~gdW$0k!cZH9iQk-1Ca7t!EC=3`fHjOOutej-4+2tZ}v+&w*qD{ku&;g6V z6wch~xigy~WM!o6GA&a8nm98A)66my2_;)4q6EuI9r7hS9+UCFz%yi0#?wI8?0KH+Z zGp7*@Sck>i(u4?)8`42|F8l=8=LgDN-#zJ=u*#j|qZDNE%rD>!#RE3y6ds|zBbK>p zcbkDBr_>0{^=1vTif0^E8B?k;0W{lxVgPW9c74I1;x_@lz_5om|1Ny*|yAE-_eMizVOKSO(EBlMMXA?$836nENB3lk_e4itH1P!WQI}$ zV@s2{>yZf;0-#3)PcWp{S4Q8fq#ADBl;NX=(daW2Pc@;WnI~_u#-x_4KfTPG$)mL* z5WO$(@$E*8V&piNa*zO+_VJH@+;?LG$GwU4%*i>Lx06SS%KLA=GgsKyd+s4@}{%{D}C6r9W>5D8~LxxNa za!rrTVX(RF4~h6rWLY4m@3EtlXQg2572nqc$=rS6o5b>1&m&v1#ie7itiV;d)iepMGl4p=B~T$ddpkh z;=zI=8`px`^e=kRi-c!u0*KkN67;NI``XvuefQlx#Hf`B0rtkj9j7^hAvkruirU{R z!KMjg+3?#fP8}=3D2r!JU(@WXrDQE^UPQ&jP&KU4t6uf0#~ypkTRSQyYw?(xV5;y8 z8Il3On9J1AU&D}sQ0XNFBTiJ0Jo3mj*Ie`HqmPP8Fa%%E>bq0L*6JW9=;4|Ba_SF! z-~)n`7k*#3jLMfIvjqU-3l${SQhUv7UL$^Bm4O7vuplSqi^7Fh0o%Y1v61W+FDecWffsUwdRyItxElk1 zd(w^-FxNByuYdjP{WZ7DO^FJ_lJM{Vo^-aX@gx8Ar$7CyXFaQL6y#dP=$%tla%|lv z&XIt{S>!HKf6I?d!yK3pA*JsozU7u%3>`yLYL3UZ8+Xw`ime=hJ$(H9&;Pt16yY`h zv(gj85B<;&u{9KlM#vXsfAhiBQG$Mw0T4VFTkPhorUZ`M^RIZtE4)Z^Z8iOmGr_#@ zg)g)p3`LGa!I6toh5hjyfBu5TP+Qhe45MjF=xKOK@9|48BJqWRt}O8&6bW*KqVf~n zo8SCq&t6=M=Ya77ugr(EQhb z{g+^TFEb?#PVsAmqI%c6-sMjkh(w9tmF3ybe)c7oTmqvk^kU8s-V3xZO14tiw1*yg z$k*umtA(au^dsP>Ih((SB@@2&xco>h0%37R*{Pr}JrVPvpi}*Xz^m$iGOamT5~BYbRjIoalDW0|6_jQ;SaVj5a7 z0_7oIVDPtfJiGA15H0@Jn{C^d9-FGd#3N{vaEOI1=pE&@QBHBt;xPqg*25)3dVrGEBj;KG zt-$P38^F=Ci+aiN4tzUA4`ZL(Ew+SVe-PI`gw)$4?V__N5jM;Wxxk;02D4cq zaq4x^jvbG7ERXyGIHYU|1tW_NxY@IaXV(O?R{9Q_H*C15AmVXe%r+EiFUFKw<#N5i z8_EI+hU)AX!gmZs?z$^|{L$iMg}fo#?mCH+<99LU1-Zxzl8ja)rnE?~@tbX&>Qi70 z$;zmoD?*(8Iv&H&=0cBL%=F#EG&ms%^@bXNU}&@%VirulRX5A2DMB?g1i;T$;`(Nl zB{s7(2$^ul%I|Z?lx&%pqTg*WV$Le0;M-KTI-U#(Z22N+ZS~mLGNcR-lL>u-UgVnW zJZUHDrm>fhaY*AS! zfR3>ZDr}2k*nZ+p*m_e3@tlR8m(EhQT*Ob9Lcx@*9<1uwH?Bo%-*Dwx@#!fEFix_e zT#!pAr|mq8;>@Q6P5>*aZeBwr!oUQTz(j(Crd?YYFnL@B(-1i;wl;$;j*}py$_`U@ zOIu*!af&&Qz<9L0(-WAaF$A{JQ`+>RHQNw_2APWlnkqn$4y49PgoZFY7Dk~8o1UXm z1`b9@32#bjs#^7ERMikbgGAUAJr`9q!cB;0_81}BWlb*17hVPIH$QFL3~djKE=RK7 z_RzMQVzYI%(r4mE_1bBpk~_kORE8lEfch=dM#GEUNIxfw#wIsqL$*utJ4$4FFx%CU zF{`Ix9%lz3L%rc4@f>}M3zc#D1Q59hk@HKaR^>&ml0ApPu!Ye?%T^P=Ts)(Aw&-BO zgd`}7r0{60n#vIj5+-^w0J$u{Fq2IQfNa*V#lk}q31zed=B-*hVd6I%Awji0^oUtt zhL&6KmYxfGWr3`jon@s-E;Up9mXy9+x_k-}puAdZVT^#P1R*KK!<D68Jw zXfl`~_F|<0(`fXD1dU7i80I@z(SWVk%pRqkoz*UL(PC?<00b!#jkM`xw5EIddJ)5Hbv7R)TcqxE?KfI)~TC72616V1=rH`Ar^t@fSQLFcaW3m@U0u zSYJD^!${Wbt6Do@HlEz+8=7jv=J7 zY#8TY2vJo8^{j>ggwd*r-xa!ibf#ef52#p;moZ^=Pjduug^x%UJ`mLn*(<+$&gyfH}5+Id@l>=s%cmQX0_j^Sl5d)b@b z^d^T~%RSJP; z)2wC>8kUPJlum?5v}Zo^nWs*j+G~Q}g8tJ#{Sz2jeXO|8srl#t8Jn8ZkeSQk|FL)P zzqVdg{l{aze}VCmfVb1uCZhhJcnhJ#UNGK}V7!(}ZBNyJQi_JV)QTyHqKMI`NNFjw zjUs}IjhFPQ6{0Z+iq}93(yB4hKf!!oYrW4IJgmL;K6{_jp6-1(^M|>|9P=|i<1*)b zo@cGS_S$|;`_h-b)UUDVX#{U4`vjjt8A3+VEcwP8Z@mBh`win)Hw5qVdmUP!4<&g$u7S5;_hmCN}JzBL;n|l@fSWMF{j>%@Q$Z z`xJ{0Q#)<|U4NJ`cdXFXFY9KiOSj(H^iMG~orPc1@8h*MI)*fil+n#VQcAi*x`dI^ zAbd#a7y=^Qjf8ZANP~2PbR*r}@tfc0_52IV`+c8tt_yW4afa-WHE<>YMyZi~PTy?i zDUSSdV0`nokshiDB8Ns(-Win)<&2RIet~PYp)3JSdYw53CUwbPzww?*U(uo}$y(xw zi74*)*CsbtK3<4uaFr?P)yg8KF11%#f8yDVQ{w!j>Ac))MN-Scwh_gk!ylm5+=cAH zcm0t}EPeOc3{{f{QXATWke|yigJ7Sx=c>&Sr4Od)wCO*QA4LsB{aO)VkVfDx6ZK2$ zBjR+zZ&1Nq_uG@V5#ZsxeT-ygKDq4Lxb;lwA>qn=d+~a5S>Hi=)Ojz`+&y}X!BhH_ zg(Lq5a4_l4k#BkA%+WhU@etR??^4eH&j{|<r}#%S-E5N=8RgHT(4Xmw!LMaItUcaS|fy^H>CY}m~JkJ|-46j0Qa z*wd4jUzm--E%@fy7VuM=sHi`gC0|H=EE%pT@keD)toIqwZf|~Wzi|{1lQ~Ja_elk7 z^+K8u4mcDv6DUS)xwIGq&R8YQbN*-Y;(m zRf?Mg(&+-x`?lnroP_^-c4`tY|Ap|a*$jqeLFK{^$Iw1pFGHC#>1MgnW0tOP#GHeq z_Wx!dn_h$j0$mhRzTa1H0CaWb+-{@9$m7C^`GOqB=Kz_c&?UPO~#csLtKr zyc&*_QkikR??A@=iR!t8PoYOmb+jCW>74($-ZqLzhn9dzKGissIPh`KJo@giHu~6r zu5qvYu4eyChK$Tzm-h2`H5pO%uyaynoY_U(%Ii?i%D`z-pH5QG1?Qp$(*^2#l-o^1 zr`BnX-eA&}X!%t{Q(uh11{GMBRD-eZ5P;y}jA|RaO-gxOm~C^{@gW#M?RtJL_F&jN zE_hyQM?>uMeKR9a_b;XqX7xZJ&iPXbPBUMdMdN=&R@RDld)P8dQr?V`-yW}`U|67b z+4lcB>*odvC$9fmC``EsVxpPhK$~m4_3FXRBIhTYTB>hMPiHkHU%Xm1c$7u17B zLZ68*61YwEeM9+o_eD_#JByD+laHlpFB>!UZJ(WctPH#E( zuuS(KX98NvC!A^vE7#n2%EGY5CaveOe+c z$(K~9eL#xhEzill(jZ~jH*1!=XSES(!Oyc+#`FI9M^_n?W+|D)r4?g@XFZ&Deyygs zf-@^yn*OJZ2r4J|1P|o8K_ZX+SkY)}z@0wXv5VOWGW314-djIl0ta-Er7bV$H0lv9 z1LkNV>Q>lsTCI5%eIJya6f22GbkNbY&DhY|Fov}iB`}SYtj-}BStgMYwH+~YuQM8Y z2m81R+Uj?@B;B`DH}Q=vFJY)Nj8nDcJzSyW41~sd_0S)iiHug7F8OM({*t%j07J%f zK|k+?4*2@P5iA)3YrJltA^WKhYrCYJ7;5~gUSEs-F#MU?e3bOrNEfH`&^`lez$@T= zsbXN5vB^+`Q7427t>!@Z*@kJIDJd>XcK*HTOrwU3wP#yqWk=I{;KNd``!!}JUwZr0 zY_5Fj;TIuw3jisOZoskZCkp{+a==2B;Y zqXG5aET=2L{tOoN2?&xt0Di#T-l|Y~3$$-^H~XFwuqLZEIt2<jySV;Q<_P6bi>4Y!0du1%6{&dM{>XvZC!*;C_WpyTpNk87g+>O;*sRO zVV&vu;Gl$OMSfv~#|tjUc08WUJ{i+pTl(=%7b@I>9khI$_eVWwH1y3}HP2zoaPfa% zjEq5jlnR!R@or%iBV|_^mv3vy=w9FBtezs;U2P*cSOZNBshz#GIHiHZ?nNQ^?iH9; zv9NK9w2^uYzQHXo0g=9i%^xKiu2IvvvZ*6xj1IT{XpTJ9;@cQcK|Jd4Mc z768%hVs|OkMONY#)IquveIN~b)qU56GyyJ}rZPr`Mq&7Pp$q8{zi(iVLVWHK#1FbH zmae>e2Zq@RpOJmZ2p5ad(~oQ74KfAiqnwx;&};jX9SbBv01E4HmrCD*AF& zNZ&8tHo$lw7b&vQuYc;6i)>4(iT8K$=kG)cm2^#=oQcguWLMMQ^^++{wdN)O~W()GZhazdYuR|Vp`5Gxossr@xh69ezClP(qy=`y+L z+`cJ+GCsRTbD$p?Za!K7z0kYGSj2aSS(vSxADv!BzD}}7o$t~VL%Dx1P#;LKuhOGG zmtbYet2y9x7Q!8=su^4yHK5Sb=11ZhsjZMoJneYx;=%tj-d;kIk*FK4k&$wxjdlkB zPwp@qN&!^lPgm|IDUL~oy`Ju{D0{Hxb{ss5iW}vd75hV14(s^E^)#(M7rzM$Vx0H- z&o{urP$eVNlR2}~wBIeo8#Fxd-i_FnmH+<7+o5GgY~r#tFQ(rBO8CL2SuJm!%O=4a z?>*gk1^rL`2`GGJ>YMP-TJI9UJOhI~Jwn6fNYNFkn>g*f%ld1kHF=wju**xNoC#9B zv~bqhzZktUX%wHv#sldf(ajm#v+SMU7ojIasy^iHN6ZY11){&%{*y>}*9(AUMl89K zc``lNSgQ}1j7vJ5Y=^Rcr3{aWll|~EOj9DI>=Jqz9|Qx1t%tn*q_Ux`e)iI=lQSNt;wvkFaLE0w5yCB`cti6*}-N&7%GX-~r> z^a^;|k-;mC4W4ltUa6j@A(SHFwy2Q7TSDE&cqj{C5rArf*Se%k)Es z@dKpRZ`1elzh?EKqT~!E{FTCpC)G_aObTfl?~cs&dN2WfW1SbGwX=YSoME4Iu~GNm z3oXNbB%zXlOfKakGVm@=r{cdG2#I;EkflW=H~kYundM5RjH^v-uB6e!1r?mEHL(Dtk}$2nfZqI!80+OzWs5o)+_~ z5j~WHBFlcS6AL_Cwo0BBS6rMzG5E_t8utX`F~ujW59@EHmOVaeRv!P7Aq!I3(p9rb z;?lDC92%Dkk@Dp6m2Z{*tN0nM?v#_6> znm8B%lF*nzy!o}z1iq97g&seDGm>!aH6^y2mdc>H2?G`GFSV_sI?%oMHa=6%CG$@w>h-#br)XxPPnL5V&+!Ezxhwwq*WjW4zknbsVbHweM9MYxk3mH{Q)8K zk-s;^bbX|$d~*Vvv^KL@d5sFC6>ZO``#;phavr32(+!O;ouFz{E2U@@!?WG>p$cka zS%Wk6I3*M*k>wpTW>CmFPLn+5yW?MqGlM}i$<{V?Mw(q}zJj4-IHCryZy42EyYED) z=}WC=I9Np)O#TbOz)ZU@1s7R-Lc&n~;kgDuRltP(SlhxDqVIt3w`?1>stf#9FdyU? zivg$wR0FGQga`ngqC`yvm12l1sM8pfr?~tXEF}1Yn!?X5<4vy( zrt565sSxrPMQ^Js&|V~EO>T$vZyH!_>W@+Dxj0j3oxL&*i|{}QFO(OqsV=1mYm+t` zltLp-rD(_)#te<+A>=t(8NlNR;IF0S2K5@%HlF7pq1eaMK><*l&T%GSX%?4325fj^ z*4jY7hNy}K!{gkn4es+Ry-K`%AzMpi=KZ&CDABiU-&h>1$#t}at1^3IvTEMSV~UBWEnq4d1;&MNv6l4#@I`EmpruTA|1014c? z1N&U4;T@FQIvQa@U9LR*!WJLXMTH*5_va)X_-lpJM@Tw^%q(9PJx{MMFevNAQ>Q3c zS?0_81CeGxro(2)&Jl8}3_}S|OMEd?%Onh>u4myos}pNoTH)qvd9y;aEcq#3+$b}O zcv7C2zkJ@;6yR$ksitTYa{sZ5nMcx8Rhx?#Q_r>o#aIi`T4SX15u4uoUVk89b82&m zy?gt2x}J@z$y{<=%GRFj*U#|5ykwZ&rbJ(8te1$l3xmd_5#g>Os=Y6t-AW*)9aHI3 z$NNU1jHDHE8OgD|ly{TZ3PTO$*39zqx7K0VF~;^Vi?fdYE+YNc6b076pj;jzkd!eq zwz*yVy}usA*f1&H2L#u(ZJwmLS3_twbmgKG5$5hH?MuHKFbS==+$?{ks7zxjQ#?$3{7Fa4FhUX4RUFSYl=#TgHk~=$7q`Y&R&Jj5LY`-q;T8sK@HX)Ua$wa{Y5P6fOF);0A;q<_d6;wg0J(8y z<*$9#2dp`LhogQO9&X-*OqM8z8tQ55jtJAJw`Ya;I4LXaq7tv;95fTkYt|u7nh{de zJ58ingKBodWQy@+^Wxte4DDb_K(vk|SCVH$;JjSlwbg^y`^Vl~1Q7F&xBK9zEqAi= z`NYliv1RN*q>A&agKh)-{vR|PF^Vehmf_!W92~S+FelzvUwG=N-;HeBjf@CHQwaah z03kmzCDr)2x;3Qj?Fc+A7^W}XIIDD9L2N;=r3e9Em3SqouXw!&B@^xWc>iDtk`+O? zLMS1K?%umfwQuUU(J@&HQfawSKH{Q_X7BhwHXtS__wXqq_pFj_!}n-O7OV<=hp|?Q zP}pJDoFdT;&{c&IE4Ib3{aj(Kx)LX|2lRK`e+n5H=^|ygz&NbGWD>tqxS{hi2gf79 zy{1czw-%$86!C@8&zi&sGy1 zJ@?J+KjML#PHU+|H@3&d+1Cx>lC3?h(^vX&C~f@^oKk#Ca;Z)DT*k}E)5^(J(g%I{ zK2ZYDI)Yg2KfpWB-r3-ZNX0J@3%t8CS74dhtNe|}`^uMJ=HdF9pNxO1R>213tuLWI z#H_%8ZNyck_n>B$na)-sd0ZSB*i_2niG<*qWJ^MmW0H|gA>!!rH7XM50#iEBwwJk(02fv#F zVU;QGRoq|A783eUo{$T9NTc!%_sK1r3?^s9Cfsm$8i43m|J#cSkJcf}&a2 zM&DwbuT!)ICbE^YN$Gx&Z$K0nzWDo3RI}14YMS;FN`=eMYkyJ3#T|&`#e{FA zQ^Nr>Wn!%T7Mt_?fh_A6s&D*m=sF^?2-Gs>2(XYHH2-RWjk%wf#-1{qCM$Wcm=C(l zk6)~YooSg8s8ZZ?Ex@8Jg@X6{f3YKMcDlty>v>52JCr4c9k6PkMXBCJmp z1-y02lnN3rh{@Klzc~us5K?eJxeNKq-|FUJje948HUYBkw?C%iqdo#m_8qV^()bCQ z^cw?a+n=ve)|FgOo?VyBa%E_6&bXy^FyBEehEi|=#RTLeb%MVa!t*{hHx@mOnQcq@ zwsvoF@R4D?E9G_2pLdts4P2x&(h3cap`l{b4-6eHDPq=yU+Qnk8#5$Mn(3XBz_S3^ z!UbXYZ=|%4zKAIM0huA_$TLXktn@)v#2vOrJ(L4oL58qRMu6W-`&wrpHg}b}$*i~4$Ix9EnSDDp-%vnCFc4y>z8sTK0Y0ps%zn&^LS01R{Ynbgqx$yAet(aZ_9Q_Bu@-xw$;2CN)+ZbRMbm$~Ht= zMB8e^Xs{5_1jtRKZ|58n(eN8mC6S+=(mPh*=H})`;0Z33J1_2RhqN*`x zp(p~)8_d)5dG_SB|7^FhGWPb#N6ylNaix>_JzGrK>oe1LQw%`Ob zs8E%((MG^={k!gI>;QO>^%u}YEE_6 zSj?JY|L+)3s)@X0!`vh(q(HD~$`D^s#AhpkvbN356x${VLYg|IH+2cPT!!~|1y3-l ziU7dFlT4b|7(H&-wn9S5yXM>@nDu ze&*~{FAS*6ZPJF1^hwkZQ&58+wC^%VUSx*Xi+<~T8;v%JfqX6bFQxlkmmzf1%9QzH zD5*?hI}{*`JWt+zJ016uV@3G0@AH|d+NmKBbxJ(^!<2o6C2)5=l0&u4fN|3^>+I`C z=uWco_xjBQu_m)=R83i^72-48y;iwZ@aM7#ifSIA-p(sz+H~^T%f!WzZ_9d5rem~9 zde_bhnO7P~s<5nR-44oj={;?c*@u;v^?|5G`(U-+3VVRx6)?i|izoyV08P1V&%GYL zCC>5|wl`e_JeSrcw^*@Ud7gHEE}cY6`7ullXKdMTf_!N@9Xy7Hc&qlF zsvu}B*YFueEeGt}`Okhg3$Mvi+$0^K!i0WyR4KPw1uQ{X;jJGno4=KVXR(*zMd&F* zNV5%fi&?AW0TZ>8c1O0YW3Dv+QMSyQea3c@Tqajj9H#9{GT~|8w8Df3akFc2rtEtC z!Df^aB`eGA=osum{1$M2wPH~c;euOji*@W{F9o!WIn%wAQI%KKym`Un*o&U*fo4eoY5(ROZ)6a@rXz-*H^?$^FMtu zZ$&Wotw~CS76ZX_vj1Ij^8_6Nh^mU@0!l$O2?|x&qZ8ck);S(B0-ZFUi1l!Z=-e?l z3vgRl%7=%^WhC&<1@P5aM*`VJar8>d#@r-RHUaVgmoyPZGRMo(Ra1u_-)7X^>_4Gi zd8LuI?#d8rji&801A)CekxvHIxS|ucdXtDLJO7J_Q4L4%)`@mo?)hEf&cn)0o0sy6 zJ|TT?+2uW`q(-N9?bmi$a%|=YW>FYa z{MiN<;@MN`_eD2iXCv;c^9{u}UZStrX$-lxuJ)LlhtQmRemf;%zG|_!-VYYEc2OW!t z7LV=g702sf&5sZi9INbCJ{Wv{a1jgBE>f-!m1gJ-9C(!eue*iHX#G}PxB@z5?4 z)J|wB|ISXvC#E2NBRld=y+I8RdvU=OPeCXm=nr+?`b4*0Lx%JG?pi?U(&`HplHWYLqTl9(rn&5Be}2-=VdM-<*vV zBssEbRq|#z{`;EQ`*(=^@PzTR(QUW*V|OH1o8Y^Cen)b!+Q_qOE1kp6Q4*@Nrc$S0 zE24P(AV_SreI(x+1%CdQ$&0^Sh(8cwQj{(?{r7%&!$y;bcTpX79J|uy@)zYm3-*Pc zw6*4J;oIbu{HG8Bd`>D~odLxnvosCCUPLKjM;D=7tYq`p_~fjZuvRDh;s`C-RJ=qu zL@$y$5(@J{<*LGAha9w)9>lx0Zhcf9mpqx8I2vR1SwfG9PUXhMHh%yy?riMa2lAU?LKNv=O`ree#1iLqtCDxv=>qR$=FbfjAA*ZTc*7_OJr zKQ1b&P$@_Gh;AL^yRt?<1Q8A1_Xu6BvVzX^%77tQ&ic0BDG zgu$dt&t~1SJVepznD?~6hYhZ0vr31A3FMf*P=s!Di1^(-f3Chtnlfl>bP(c(dRmxP z?DWv!q?sycI^6Y+fZ6LNDNx)UA$eMnBBN<9I;28xH$FWKilNi;Dt@*I`dmk0w5W#b>&+I7#V?!oue2351}8t8 z56<<3`f2aF6$AfN74~y}Nu9%d<X+%DDPu zS6GWjl!=f49mG64x{jSse<(6XEaFgAHs)kTnRu$K-PH!Yq1n0+!#ri2;CA|JmkZF! zmGjIzWsOx@-K$jqyhL7Gaz1n)w!VdsgudlMG7mC}m$CoY?$msKpcXG}_VPtP(IPid zFv|AJDq*mXXx_v_CVmThM+#v+TJH=VU^v_O<{B=}gH#wY@g^KF|`9-`L#ob&YS+CjrYHI4~-M{kDed+A1?L7i_lO(SBDtQmtyeHh} zNWlTZo{ZpYi|JVO zEj}J^gAuXHZpAl7Qr4)XV)@F+&}D)D3+t$|I`^!m7>kO%23sU8zTO}~U44SmiFT%N zo-QeD8d#Q+ns2(XzC9uL`stzlqh$RxOW9XHt zE+Lp@muZjy)8XNI3se+A>lx)+4TY<(WYN$F^z@)Gh!V;ic|kt#JBo8z230H(0MvRD z7$MStnTW^K>WezSXLUb}5Y?-90jB8sbO07`PRB?=p0@&}@X(q@?hLy1dF zDCWxA(O^X!**-^J;WGJ;*^q-1yrO+5Et2++CJM1rDax8S1=hp|XMxl3s(Mfdlv! zpGWa9;n#S9cITI^SCZK`8so^Ds`R0z@oxWlRdWFs+&cw%LN*0D-7gD~tTYosVzRed z_w`kj#?uIln*M}A6X^r$QM_?vwMDHBkY?#>N_e>cTQINHrrAqvMB!jd?Ii-^SVi>? zl53>d_Rl2bvZOH;5PQYeCAZ)ln*H)VhS%JFO0Ze_9miDR`IoFBV99zi@flLwTE z(to-PSM%wTisrgXs@&@5>z>rr;{+wU^;4;KY4~qi2o2_)q`72>Dh%nO9)>NuaBr!z zCDcGuvDn$f8*UxwVT*3cC5w*J=94A!N98a%I#wO4kH{2QQXRLQMv|c)1%Vm95^XZf zo;4VWT3}+u%J-9=p?jagU|~}5)IkroL?WCn0LW7J9^fKy(EkxzR{bMvi#A*KFSn~N zF2nmzCH)veiTGR_vpbo__R0~Eb{+?2iL@Dp5u#3HMF9*EVyx(yw=S(Ev%d{$&&&d^qCoFS#R7O?yHnbPc6`MNd< zlL?>gl{!9_FR==)4F2HJX{Rln)?$&Uyk#x!r~c2IPcw7c2-tl2qbkV@i4^p4NCNIO zu<+p&?k)8V@QZ$;YFeBHo1Wl3%a_$-|FIjNF~k|$c|DZ$zN0?IDl(1OVO9=N_eq){ zX(>LRWWx>!(jxO$H6w}sNYlP}f7>VSH^k8hb`Lfkl^&DfjlQTP8`19i+-&ACTWPd*oCEi=}xawYOQTOY_@2xFu*)+AutWB{j};a};k_lc-?P z#~*RHihXoc!a<(Eo9g`P@>~P`sxFgg5N&t+mz?c%5~hS^l;tOWP-1eSl62cb zDn%7pT0I_r2@Gl%`Wrtwp&ZpfK7`zm@v`Icek%g^VjlmuJ_9y;k;VJel2-#}uh`GC zP;C?=5-s!-lk;i9#gwKnE$`_h*sw<2v|0#wa#A9hZdV zEaa#YhML~?G2Zay!~Q)?K0xi}90u@`eAE(bg=2nkcty|Y+j-Ld@-^A=K|okU-mNiY z=KD0k9DBK&ZS#0cd26fL>)qBX+lZA;D}j$95%XuHO4#ARwcuW0_FL})=7B4{q&>9*~?W1xo z#qi;K@;D$Zy9ZBhqfd`H1~R6OmlwOHXsg|iENm(GhfkA4083M49P{xV{M%19UUI~) z$G_FVmniEKLqlBYkJF`Gq35qR9zA3i+rW(Zwn40-rkh6jt1P%*o1vIT3@52~pLJJB z)?eT5QDL1iQ?a=n4DM6HT* zNLENnm~iEaLsIH9K|qYvcb>vsj<6lovlO>0`S2+shQOOq$+KaVQA>_lzyexR?z|2Z zunqGRB-R!NPHOdM(toy&z@sn(E$ywB2J4f@YT8~$A0EEt4#|#k;O9EG)UJUM@P5S! z04bPISt~MSxl9!8yzR8l2&_JHoTDV4 z#XsMKM!_0Z#u;9LFxi46iQ}lVdcRHD;Z(2x$2xDTLUry}xi}5MZ9Pp`2@)DBFQ~5$ zX)UtF878+)TFS;VRH9fu>0dnVxQ=2$kTDkUIQ(^zC|wVh3)kyJH-9Owj?&cedDTs@ zTvcNRK))?8d&fEhSpa~p-fywrjtWkCgFDrsw4}ihSIOh^^qP1Q!(-MzVxPUjqjKL` ze5=ALWyRr248lHlth3aTm$cMd#EDjUGlUWcMRzJycuk?3Z8lzN!B@Omm3CFo2>exk zEg2Em2k+UK8V;vyV}*Rpbfp0$WZ{PI|v*6+U*J zsy%P2-56KtMdttu z(XT`J^CvXy>M;HI1I)BUnZ=vp4(7B;ZYTFWT740y1yfgOKe24Ix`+b-H9lIQG{dbi z2MRP4MH#!{a6NRS`Ic|E^XK=z966<8uDF6bmaSXV4pis8WFG#EJ*7d%qRscR$|&Dc z2ZakTV%Pq}o2IO>ciz6i49?*4RwNhk4mmqjbxL9sC6fi-nUyk;e(e9z6%VZ2hX=v} zY3fx!#Y#os5il)kjADz@8-maBEjU@y48qu&9dV3?R;e30X6c!lh&KbMan`DWFTo(cCYT1kRY20qod?# z?ipqs1u?V{bxo(6wfc7vCK`?UE;={r$E**}M7sup2tblZO`Xn9J+GKQB(Zb!?}bkJ ztpJtjEuPK0KWKlpBgfxof&-?Cb|S5y@Fhs9VlUSxZ{KPa+Zd^znt)zPA${nAsvdcj zDO>0HG7cBDsq_$Dt+B$HI%hdn#|SlqY%;$n{9iMnw+rbe~jxw@K0ofZ=(nEWXKu9Vk1e?%X9uFcH$Rg9=YYqI0HfY z>~RZGeq>r@{>9Mu*SCSMPovZeH1DrsdVCm7GbVNHC5f{bzh>VTXP;}^h>fzNja0kD zhO(r%2r=xKqRllnWJe}y)7U*D2biKe&?a-~qh3z>@8*WFGZ5A+YfHcY{d|^x`uP`; zaYbN9DL+_&UTcuySj4gPeMzeUc?Iuu(Lho%l8F@z z!5mfx30`@sI<>Fs%Dz;6AQQIsG5yYErAVPb?|=Q#zZbOt*%Pt-)n(K4GLiGxNL3E0 zn8X!-bk1n2O#yIeZQo*A+x(H>v`8jWx%*kK*MA%W|IrPh~60wfhfSV3}mRCgyT6*FyX=Lj^V34#x>Q_@yYL+wC%U>Y_Le6DRd87SmE zNZb&;S-SXBBsRT6U^TY>2^;_v`Et7!bdqVmI$oE%NbCJr?j4B(JGx5BSj!zOJmX7a ziNy#$7(QyUt866^QZb|lt4fAk@lp>o&Ex~duK)QO_I&wDvV>IqREVRA{5IwMHgEh+ zZj`-zhp}k59u8$9LlR+>a5&~&7xFtb=26iUYy$h3<{o6sZhXk>=5ohh{N6Zy=>~3# zyw?!tjGnTRirM|YYEeAD%_A@g=Dj9G{T_d+Oh2D37jN!}o_N*3qp{)?B-#!>ocuz} zbr#WrJ{5gpI3LQf!e%G6z@^mv;6^O@jonDNibjet+muVbS>}_oOQ*2PM~RcxK!LWi zsDDgV8PX3H%q*R{+(GLlJ_p!&&mg*ao3?E+38hg!i622{Dqj%{#3sJ`emODPxFBxS z8L?Fb&k)|py2!Fn;g~*^gAM9!>cb)1Eytun2b#0<{~3^x79t$wIoY#5mmR*zz8khm zWv3`$>u5$$CKCk9#TZ6Uy*24{=XkA`H68p+rj*rKz#LVO0t6)#A$umT!DjK}A?5yq zE7TdcSDtAsOTbji_h&3L)+j@mjONC=%RDci_C|Vx3*Boo=zHOCTFV~erx1gq7Qtem<(K&oslO+{|4%FX7Wva z`;;ZcbT2PeTC3i30{h3A&e$+(!Z})M{C>`~q`~-@UsJmdLDy5&=6x8v!ylwElEx=B zyeWG{D!-=E0PUho4_T|=N1oft3#N|?hVYF}d%K#Wt9Z}HdJZZ4jbX)8cn9|z6sXKt zj2PIbZ;wUI3u{3+HnFjJax zE()J7F1fr5&8I&>ak8fQD5R|28nvPhDp}_jcXGOo1Ap5cDlLOx8ruWeYV^qgh+v#H zkLSPdH3>_=W`j;dC5tEjMFB}6ypJL7(3n9rs9k(g4)q+^eTlI%7mA#6#vTD!Rc$tT<_fF;az`zyqy-vOGDTDN$ zf#RF|k9Mirw5ya8%SDu?F@FO+(w)W4*epZ5x!JJUT>AGFqK^=w60PnU!Ch|^s?j#* z&1^STeTrF)dP#3=^Wrj`4tpA?=FD{2^3+Ck1WMb;?+ANCJo~gPPb(Ylwd(hP{?QJ5 zA`vfM%Va39mdar@#usI{Eo7Lo-2vU`l)EZOPA2?y98LbEg-(Bu+B?Jo9NP+~%8RPp(TIH&#~Q<(897q@6eY$=#5xmP_V{$0+-)Ap2i2Y~@)mfHrp;hMkXh)_uu%a1a2LkPjBP2= z9O7ur1`;KJTX13pU_uxItdjRV;tq2qXbD8KP*~U!;1N8AJUFvZV;4`y29$3lOUM|? z8Z~SHxoIhYE0|o<;!cOHsZN=pO9wD2W}u^7LQM$OJZ)KnV0W{}yJY`??T)Y(>*J(^ zrwkN&+f%j2@NkH{XUSwyP{4cBkN`Rx&mZP1!eRT;pUQ^KUk3*SF%hQh#!h`M*mV0m z#2cF;N^sQw@@hzO0c&RXxNb7S5r@}%q${4IMaBG#rkuO=%C57FbI~0_FENBgOvv+S zTT~{1*F^O-9`H4AoE-?jeXj4>cj;wu4xIOQ|0TD)3KIsN~;<)heV{eN7_Q|X@Lzv_0_rK%Yf1(k_NL7(}awnGDv}uQVO35 zLB4E~CLkCsuXLy!uCjhwdEZNAE;<6j3p@+KkL!8CTJ zo179{;MM@3GS1rYcgcDSx{JIW`3_Aia;Xz{&&rji>!%@Ymkr7|?W}`xPk41EpR3SY zlhL5IB}^R&)VlK$8c8e=%~N<)tJVg~`kU}U-wER2vI!4U2&h{R0>MTTgBQ9mWx3aj zvDTWwiw1tzM^=Hj;Opyoe+OcQlhg}E@`@duVK3c zVcL-a%jG%vXZ}Yo$<>dz^_}TXU%9UCX^_7}8F4X45olS*dHQRj8N=&WdqBF#(Xv$+ z$b1BdCKAg&Ll~?kiD%fUzcKqGW~<$ef4jk5jlvPh->WjQ*0c%olwZ^M$=Kl;xpoA{pVj`NBs+oNhAa8 zA_uqF&@Jck-BW5k;(~GpToK!zWXZ{od`$5X)CvzVI0mKnT>nKd)K}i`$j_Nx7xG2M zH=&&rvSpStE-4Ih;54-uy~FMxug~DuxeaM~mUA4Klui`&f$ZU6&D&4bYt9H5=DVk_ja4R(7B&6p5nd zD3v4#fBU5byo)_hvjC3qRkqk;=+JPzwd9_q)MJvIp*s4P|B|!FP^i%oqu-W0`Pya6 z=}^`dK2rQe(A%@AK&Ir9uiLBoN=h8IS60vW>;#iNrAWCKb9^z(Nup)nz`PMC-!AB3 zjk$*cyv@I&!j90ti8F+w)cB4ep<$?T6U2V2C}Uj{>%saOq68 z?%rjgh^zd$ffbfuV#=(m$*?i|?jK&f2aPgV^CbAuj|`<(C;hPBkLA#uyI>3@oh_iW zAfW)Q12s3nSFo4(T?cRI2r8OC#sCQ>*cWjLb>C8zHxZBZ_iPsy1tiu1Iua+E>I3RTpbbgH_O8tgpi9E5V}| z*)NX>V!Qby_oDmjg#y4@#zFn7VZ`~($85C7IH@YOQ~I20^^D5TVPcOU`i-)9N=P1o z+R8HPpGOIE^{Ov@VSyC~Ag#v}K zi|UV&`YR>>l?U9tmZL(%sI|JfbF96kypgQHtus|iboun`e0#~hv46VN=9ajLUI>)Y zr{ z5aYCh_{6hMm@*2-Ldx@XarMlrVuP~{y`%hP+Xrdp@}IuUmRztU>D7y@o72*G5F(;Q*@{ zKX}?EN((@pQ}urU8$sm06GY`P+TQ?KBY>_j(drL*JMgp4Mm|d13T@~gui+{AdDlE~ z+bcG%C9%5gKZ`*Yi4}tJ@iFg*6MR^~R|ZIq6jhiKl5oCz6cv^LY4=KuV~ zkT-+`{Gkth$SYl7FjFGYs*+IK?%v;WN;#s31c-3i&ny!+5)?z`{y)JOvv=hB46 z&?DXPkX5{kiD2lceS{Who0AH1-Z32%;_sK@*n!4A9Ahf4U$%8!^+UFiaaS6 zrw2{-tc<7>L7YQKgW1D}XVxOrn`O&{T=4tsGmKm>JmhSpOjbazL_LW6yFwu;>~8Co zJ25a<7Q=FB4~yRrKfMEzqlOtCsIRb?TdWP9r6UC~gp`Yg^9sS=A;QaxzVl8lyaUDX zWP#g1-B+Zq)b8%?qhxt$I|GxxWS{=@r@QakxBgP**s){qJCQVy%IjUm7rfvF%3?b~ zDwHa1#Xr$X3C{la0-#4aAxWxa_3eiufB4~tFTM0qVGEufl0N+75-N8Hy$th*)?6ry zJys-~ini-NUxu!t{-{>Xy^`^p0c!^-^1_2D@^+v!dt(Od@BZ%ZJi=lI0Bg|)PtY8H zZ&(66=Q+>uunSYZToeOGV5QHa|0U-ZAZD+ry!_7fnrZbib1%SlCaNca(hOzO2Pzz~$u|k4@2?4nqJf&e%rNht=8(Y3IXJt5?E8aybLP(q* zLaXz=>j$l?c4D{j!gw!OWK@Daj3sVBH;sj;E z3<+acoDEUMKq*ZCYvglXY)y%6gLd2zB;N5BeA|Fknk9@J34Uv>hZlxbfDcJuY(h0f z$vX`&q2QaM*&yG~FiwO>^a4m0fR%O0^KQHtP}OhcT#`&Z@rbasNXb@e8X=RdY&(_e z*(#VqOV-;J`Xw0WX!2sH(aXiF4&T8;o~Y!wu~n9Ahfub}?w!3TpL0>BTccH|mbK&Q z4k~vVLrp0*np$DAW0lL49SeVY`)X@E2UT({hOUkB6^sZ)-i9Y2q^TFV1vRu$+s0C$ zd>rFP$f-SpUu_B8wKF*~CEBiCLhY?z7<#kCDbzHqw1(I$D_epT2%0G*8goiRf*wXM zse(bP8k|CwX||L#07$dk@uHZ9T7=lT5*ljs!i&w#%j1z^^2^+80j#Z0yv`RGFBtHc z<$}f#m;lLp@enE(m>6Il$8}kS{sgx(eb#w`!#g#-b!z3*y}e>3ESrU~JJ}Ox^pG^b z-hMP}727xMgOucUm{qr&ol-o8Ob8qy)WFg+`QQKjUzZ^}tRs=r@HPrtdIA$uhBl3- zmJSsd2Ku5k9OhDh&nXy^lpPpWreerl=ECDbrZ~4YhLGL-id=D|09G3$K@55(_{|n4@`5Zvxnu%BKdUe? z0LW`eyOiNwRtms2K++{+i77BWWq9PSFKw`{*V55o^2MpV-~|alX;yd)%VNpDA35*! z;c;rbAoO0NcXZYvoOm4EjgyI~b^=3$LJ}>3xhcDjpx}^{+HO=^^<{@7TPfpdxWpGm znq0Ky$V|LP_41d$T!pdGgSn%NYSwf~bvR{NSa(}%R$R(E^A|3N6OZkAQBqEFC%G7Ov(hiSF(^~*93=ai9Zt=r)mpBJb3Uc z6klAlB9x|rxpsBeHw8KO0x|$A*N5uNM2l8-)~lM2i3(sY5l9S}6@;d0VB;kKJsG}o z1%t09hP+3@I{}!6zQFW07+c=E$e6_zjKCvJwmg!7wVv$?6}Z3%wG8!Wox2Tk`a|A6 zW6vG(Sht2}87!?;X_vHhTBOz=_&N*lre(AWW5z8o_`JEQHAN$#)_ z+M{^vZ!%j46_qJHW{qB77#(B1EtM_3SU?iw2L{R^(Yw%F8MX#;wtTlB|@ApI#F~Lw(8WrJ%X|hDrapM2pHM z*DI!AaEdVgmca8(Q5jmP4yj6JNU4~5%@p%&2_Xs6go%W&gw-0Hd?;ta+&eC&SV_}R zFe*@^*AxnyrWDp4lA-2AQ#R}($($jWSu#hSS{z;^O_@zLgaM%8kwA|vK)Z3}V}1WZ z2w#=JU<@@$F(h<5 z!Dy_+Pzq#$0sHL?AqKO{W>%xIcgNPVNG48BvypG~tZ*_E16xi3mgGwrvN($*a2|u@ zXiQqWXxY-cIPS(@VE9FDS*ODEY@5~NY!$@Jog->iJRND~1dcW$nUH`n1P+F62_`q> zO92H8%!;F!S+$7zVxVVY56DylhKUvl%y{3Yz-k@EiM=;gzEsWM)A{fdP1}QOVGLkH z;2Mk)p}+_}fuygt12)B7S&VX@f`YTfuQ4finx*;7mQ(?Annw)YJ@wfsAOEkOYF@NU zlEW-p`eJTTrY0LPPg$g?CN{0RovjW=m@-zbWv^nE3{u4iio;`u8!pbyQ2Z?-!HSDY zv^07FlxAz4Y)VZ;4(S{jGJG6EQSejzCQULS`@bt3iP>9peH8U_m z0Rvm0mNghQ{A-7+vtua@QE@~GKZ(<8Gvvq+0FTQH9x|ni9*rsH*eZ?~$TXOXkWd3) zShd0MgXM)lPZ=Qw>8ye{={NEcArp#6L14PmMIQ1n_p3|oX*B6otkbaF+XU^hb{oT` zZ2_ph%b$mFL)EuzJxo|{E3Ic@ifkdx8n%!vGhbm$l~k*qUrJymrn+9(aPV9d34KoK zxv<3z{ehK_4;LPA422Q6!wjHtmlyFAUP{A>2e~O!1SbR)84Q3dFY#ncKxiYHf^q6- zf=$2)tcs1Hcnl?o-|Pfj05p8*Ax$CgJa}8B&%Q*s(W8MICBjfoyO0UJPiN-~WpPiX}&Dnff;rwbZ*HPORp(rIx6=WApzI}m^-FJdEu zX}n|Lk+P*XB~!?POvzmg(eAUypatejB-QZZi!Z+7iYwBiZF9CswlcR3Oc6xuuleq~ z?>;X*aXMe9{Obme3}JZDCvRojs`TN<-~avJcRCo_Ii6Si1qxp~sdChQi%RL#r_Jn- zVy?RCs`tI`eQHi4wc^?8^sp&qAtYhz)xl7*Wx7W_yNTL_43%P8nT-UID2PJ}*$t_! zy8%1puwZ6uof5wrppB>R5DC7;-C%CWX=j1pbXe8|wj21}+?`S{02t(&s_J=BPyB5~ zvtlsh8n_t91mKDv(vZM5rv#T5NEepT0BCuPq%)4EH;@SQ0L;n!5|~7rM??BCTrsXW zRn;nwNlI~Iv#+q}S>e|ZHf5HH){vg;Jo1H&2@O0YLP~PXY4jMBPVdhJ63B<474~kC zoaVF62A%x5*--A(Yyi$=IOGJI22~opU}jsi9XoGh0&G$B-TA^;crZ%BEO1KTcAg<- zyP+KxEpX|ZWlrg#p)gq4CHmncm`a})8Vv(OV6*fz`gRyjS1TJ=v^Ex*M$4Qan7|kq zV$cNXmeARQ!N0Ouw`7Dykdsl>XqNs2`lJaB{t_=*GLt22DT~AqfFrgQp^Nnrwv^FA zGQrH3ONNUzjRsbqkPOd@nzR8L>eWrJCgsSaO0uf3MKuLn#xQa`48)mw-N$@_1mh9{B&^D5Baj`7QvDSa^zc4KZzvIsx% ziBI^_1kWYCniQDtrt&AU{nb4e8$45o-N@iryzJqH z4HI)IC@DRd()nva3EqDD?Vcd30Avh%#BdmI@nZ49BDZ^qQ$$i=oO}KKdeL(rOY~#PTu}eE7^qpDi}8TDuSpVN*p#IaP{H55NR% zm|5stbEH-bAzc-L3m`DI$pt_I24KYmEkH#u6ujOP666dCG zP1Vd|GfODDGRQS zeoI`Mkm141Zf)@5VF*lyG-QjmR!H?UFu(-o2YI=8wLw|i@B*I(n2D6Z=FT@Jyn{&! z9=RcA8nSfg3HA(pN&wBU3ah70QnKyLsZUnzH(9{+##qaQp{xn{g;CzBM-VedIW+}V zPjjFqfO5g0VO9#?z;XHIm&ad^lfcDp@qF}E($!@&DqE8l+P&sibCM5s!>A%N{^$9TkL$Yn_Wg z57|SAM4ObEG>mF0X^BnQ)rFe*%E21H=2$(%#Iu~WvStfFFCNz-2a};;J$yTa5TPl} z5WZwFAj2B|L$%w&P-ISpe-&sL)mCto=AW6P1*hI&(m zWklBSH%m5!zo@#5#A~d8Yw;jpc8Pc}^1Z*0muqkOgip6Y* zO$-b%2$TZq>C>nA0nf zAYtG&j*8qY zMZk#x=g^iTu=KUOxZ=UH#UtQm%aon~SyDU_u))+5mM!y*z8J*Jq%@h3W1CNC@D3v+ z_+gL*8CVbB-ND5l%#@h7hA|->_*n;?AcP`wEZp#_w{iKi#^be3AS`YCol-!{g@Qt& z!pt2PNTgl<jHF;n25H>c@e{V z-}_##Wf>}^FM7KF{`<-Nw#lRqtTCkb5d>dh;*#xbsDnEVO(!`o!Y(S_#o(L1eBB>& zj(ioYTL_XGhQfw7%Ol$3$B%n4M>b2YI5=rw)Lc}QdHLW6KlsKszA=p$gxW#?0Fyb= z(^m~c0)BpPxZ#FgXH%9M7YTY+Zg?<|*bEUC_RfABF@%?_@4fxVM?O+^Tk(%Q_L#pL zf(*|!c_%Urj2tbW;8nms#P%I)rYL!&WS%dxG!DkK*Iw(}lUuQDC!g6-s!%x%HzT{bF%YP!qim2DKJu3B|let4jOIxZ#P5n^lki?V_uO-j z?-NgM1!t<5zHT6-GXQ07mhdGbQ3}=Xw#}QFcRhj04+xZ%7!z=(7I%z zHyjKrG7VZ&$pm`*^eGWq<$5s;-nX;bKOGaw4k=q!f`tFi|NKv92W|Te0Ig$41M|4F zj+^7zEdVDM9YaoYArgWQgt^eYG#$1;7)h;>6E8jWv=falqT9Adm6{F=SgXk!#fpmk<8MlNLBMKWIi82DPV`k z)a937?rm@~O!pX(I|F*})q8kSO5d8(W0nMcg26{Ak=t#i^w^m5=xa|OdgvjJaw?u@ zKJ%Hr2*ftRrqP%+^f%IvKKiIBVaO3a6Z`&4zT``I2ZP6HmMpv5Z@=APpn)``cZtWq zwWx%LR#Ym(iz;1z{q;TYLQ-OrEI~ua#L!f;G$L{P$FB*%*R%Q+hJwIDYA~kY1%TW! zYYF%w3@q^UYY(dcP>B$UNWA6Xp=606<4NPl{oB9&8!y0m>r(Kj#w3A;6_}Kpm6z4= zI}z?!F*h|?nDw=%{@E4{y;%bO!qXQ6J)w+{C?ifVF5r>dW>Th6aGc?p_zx29SmN=z zM6!bP(H8flJ~fXDrv|UN@%yJAmXe08hM(TByf%{Ed&mTp@W6!R{|)&Lalgaj)Ks>) z*qbMeZT$V;|NRIhXwhYaQ_3Es^f0&Gc3XejwFhU3@LtMnZ@&3v&q>%YBP0|pjFaLE z#D!fV|DU`&|GE9D>Npk;?MxSmqf)W8Y2fjwaVteTQ0{>t0d#azlXc9+A@;Y`-zOkl4*zAbtd zh4QAGZgQ~f%gfI&_0q*|wAq}jfE13PjpHiPwOAGllZi_fv4zNHEZh8sp!6Keg0Z1S zFlC|jWM?WT7G?w+)k{bAGNlyGzQWBE$2Yy{O)j+bZNK(UtjI(AEJ9_}3-=A*@C|-A z2ovC<=!I4;r$lFdugwHv89fGm$-%pcGI9oBM#B=0G~e-#cRcjaL%k_NN${nY&eAO5 z)9`}E%ceDSFQVrNUj>xK6~}KJxvaQGumSD>)H_(b>UQ{mYN)Wl)kG# zkS_v2@*i@%h^y=VPv8is=LL; zKvThq&4rv<@=T2&Ne1xq)fWp6xm3j^6j5j#yy&rw^5xD_=J~Rm{YukW1_A+Q_0l6? zXro|`8;x2!`?SNNEuw<~K3|&QgCTo*fpB0}bOzAilq_WeGXi_uFd=6TmamJYVVDud z3OB}vrZ^TZ{)R~MO|K5lbf!Q-t|sJCFpPtt#!EQJ8diqY))W^S0$9yZ?#u|H6ENu} zW(Bm>m|=?sZYY^ohoH7>R|->z@rq%-R+hV|4Hp__IAFph-%?;ngSpUpQ=n)X%Fc@L zqGWG2cW8yEH~~6IsiZy}J!6il#NLolO@PZzUo80EZ=hk1KYiS|5QKnFE?;GA4_I-q zrZ$p2JPbr!QN(pd^R4!X!5OF`9>y_?hWB;c>-4q(KqNH+1l;@*)7^L9?YZEJwiA#U z8j>`7+@v7j_C>%qeGy3?XC8=fOh=i zR}I+_*i<4t@bK9fHj6p=z@nH1l>u4AGad(O~_R{Wf3zr+qwWTt4CP%WJ)g! z!+lH+y?)y1BN%9bg~3w1szalZv8;loTEfA>Ml)nN$_)Xg5U9%#0`>{YYDh1HZA1yA zSd;>lCmAt&jxikIL0RAqHeNW`9B@oA0HyJAurZ_OYY5OyMj#9r(@PCFGD4%uoy?H| zSl|pa#$-J0IeM0QX1r>KnwPvHniW(JpAC0IM-GG<9L8c1*q9ZLnT!gADg#s4+WHuZ z!ro8=V#=_dKto?rN|#>B*fz@@av;p)C{|^sbQI|+s{ohnmKhtR)>ns({jl^xsmfOh zAuv=8td?fUDADWLM4NzN`qF`eLuh*VEIVc72#S(ywnkP)P^L>RT zex4m5@*LqSg`VtxrrhWVL^I$U0?UrXyv!0@aRE*#g0)Np5eLCgwIjdeKL!Lg^(6!r!EQ5v5(kx2`_|ODGqw!S`Heh)%Kuii`dNMRq z_3V*>#;laNvw;~6UNmM6WyfBL^lT(zO5b8dG6V8~MX54N0~#S{xRW*bWXho009es9 zWtKg9V~hq_uTX)rj6CHs+=hJO_jQB`LUJauf?6xf7PFv-E<)o+eB*s)@Wf3P-OjiWXr? z#(I=!m@zP`GD4WjEKHo84nDU2m0)s!3`!?=3@vddM>eRoMrPy5*#2jOSZgY^c7-Th z3fe;Ca_YViFzq7mb-(b1FBAe3y>=ulcgCvWO&y=@dXyO3($Q<)G5U?)_zgcmNL~X! z1+uzjM(~U7KlgJ#=U1TZb}6HTv)-2=`|RdLFM83CWK3?E2s`;lz!HG6Qa)VvCcH&o zvHbLn(Xbv=ITC0l;xjKGAboxNP|Em}@(^o>wB__u7LL3uQ)S#q9x}aM=MdnlP-H!Q zEw72yP(<6mHNn@y|^@N z2!6MOhOrbnNa1uQ0Ukr)Xx1>RZ3#Aa2xn~2gx12uUv%NXl8b06rGgL)d9fjAicb$A z+6v1{E@?Uy7~yG zu!=b}2J9bu>@lYZL%FCk!S3?8VbLdOeB5{1U7c5l%<$Z1b>e%SARvnO{O3Ra{qKLj zb1M>s*Q*qw%oODZe&7eZD^96x$KI!@`Y(9F3w#LbH&=mY*}{;mt0aBo>E~q0z%7-Z z3-;KAS?)5&K~G)>e~w3LoNdiU6;wshvovI;ubTSgzJs- zTtBR(p3L5Ua+~7v|DN}}$N3`K5d#vkH~-)8hBsKzG`M9@AGh%BW~ZvT?_mkSuJr-A zSI_M8g)_dM=e=Y7)EmpL#B9*eW4M$B(gfn0yV>$>YNHp0F(?8khYjI-zxR7RnKYK+ z#D|TK!(Frt!LpaKo}Ol062_&(F$qdf6D#HI=;Z=LPlFE|uo@=b1<57Y%*fW|_SCZ3(+Ai@vmxsmHf};wOIMBOm#QEsC&n z?&p5)=iYVKT{eF_!NRFZO0Dd{E>mCwxs_Eq2CC;VPd8^A&1%`l(~!R9pxF?%X+Hy+ zC`}+kax$}*KWO|{K&a7IY4_-)jxF~upCKK;~$J$&`C!#!7f=bd*3NW+&r z(LJu=WaS>k_1N~X*&cY{0gsO?kufa9l`B^qM;?C+1UJ9F?ms7*jRU!>yI8;WYrp2s zl{-tbdc^1(vOIeO3%?3O10H4-(3`cjRF6DynZ;yDfU>;836s(?(Y*DoZ@qNsQn3s{ zX>44=?QPEqefYy4o;8|T_N{UeGvH+iBo3__mly_4$;>k8X|hRX2~*r`3<-K>^g`fM zAkj_vKN~;54!j|djM-~0qpr)2PzKzyX=>RQLZ8r-ENprZQu%Zq5;dqYij}*_VCU z2R`rtiyDnsG1la+x6)qzdI-Z!QU~E65V9w9s$!rB>Ft28_=>N%|Ni@XqE@){0%_IC zE%VD>_A+JR?#zWxHe)%}m_SV#0%?|JyTLDnzLzpS9C4zIM)reAkm=Wc?bo{Dv!*sZ zK9*QNAx7YvyzB?CSyM}ThG?|$V7eXjpD#9NJIXAjSvEN;qg!W3k@pVsO%FVf!WC=! zRavjS3>tgF7%T;rp!6AxBbyU7b!)dHZ!dk@w|$%USyP6*MBCQPteRI1th|PK`O9B^ z+ikabVS4MWw|d&%f@)?deK_dpc+W$4uZUoiTc(E}e%KUWrvhkXUhK)v8weYkrGYd2 zDVNNj8F^>WQ4*4;bXcJsx0DPBnQe1k`Cm(xuyjmb&wna$wNf;1k2y#M@_z}In`I&N z8vS5eJ4c8A?omlEN8k6p_t_*BKO1^wFSHL;zv`>L%74f@OT%=t>yhfFBBO6*MInF9 z*L;mLfYlU{299lMy~rD0<9-TZ2}Q0=2*Z&FJmm}%C`U80bTltHzT-Q-1OGYglul;! zuXx2P?BxD0wQx=84Z$W<`GzxPIQsETXexPnN|d;3iGzJ2J`l%E{< z;0He_oI4X|X>TO6%)UUmz_eAIPyXQ_{=x1tl!bmLlPIn3Ku^@BC^(%ze;yCCC<_)X zGE7Pa4k5<|n;Lz>K2D4k)U{udOJbHG-yFeeZi)25`L~xM>RUNxRTCfs^<@*iB`h-g zm@Lh<<&%0L(8hAdV_lJiiP*Vw=lZ-B&x#pav$(Y_yy-_^27yrMsb`iplkJ- zAKJDa0X4@2xJ>P?z^g-O6HaueX{D=Bh8>En+BW!SP=hmih`FP6rqCmEigmOTcy(#9 zg`8;oyPqGLAOoyQRnJhk^v5T@I$S^Jb9^N2EuxAUPZh==KJX3!f(6nTxc~MsD|^dF z_7=BWVQ=KPHY3pLu+IgK^QkNZ;zG#_COs$`dd%g>MmwJTv%#`0dfk~gtEh}#xzx%@ zQqdGgIK6ctD4JelXXGLEZK;S zy`H|tQXAe&fuqBzm1w(^Rk5-RjipnYLr$5zF>Xq6nF&fSa~e^Y!BGn`1IB@@7+m9k z)yU0W_Z~jlMJM1X1i_9G9YZ}0vjnsyqGwY^rU(&>ZI)@65vBrUz6{!t1TKPZxEKn} zSj7ljLOXkpPeEZ?P|JjgJu~&+2!S3;vmEuPGN!3ejAK5-DzE`^U?@Sb6;PElfq)c? zK0p!4Vi6czSXz(0#4f&|RnuPshO?C_&ZGEL@9N@eL^a#;?~BCCmlD4Gf@msqN&j4;Us($`p;t+4E^1&zj-K?h5YqXEL+ z&=PoOI``InJFlX%X@eQYiu?bSrk<+jawBVZkjfHsNmVHNY^0_jm6}bK)!A^nI47;Z zgx=@avBnHBOOJ`5ZcXSNjJyb~9T|U?h8eexA%q`ceJLV8*lW{i785Cd|-#LB9Pr7UA=l02zpuD=fhM|q)>4f$`%sXtv!f=ECfUAbkZb_GXxGz%}<5LKr97PdO zA2(I%DOPcDYjv2>R~CiRS}7Vrg*rv_r_TjL%;94`QE2FAc#P1ZE69|keEC*1Q)G2u zX^OBDmf7qdvc`;tz@=)(&56(cZ4#>1i4HhjA{9?o4L%#k73C(Z_j`&^#979tuyJ$Z)PZW5-1i%qkP zBWk!V!s-@h&=RstX2T$y^vhlaC8^M}Gg=S68b>XmgP}8~47Cm_eN)U@u=L$%QwldY z%g|4&yo_7!2&)^ph7ye)4N2J+rK!~9&gm)Xn;OFTS`mOP0Ruw~cm)sHFkec>z!f|u z46@O)8Sv8yZ3zTy@gZ|yuj7+hi&ZJAPXvaW;VBq$DN~fddbKfiP!mBJsD`-k8Q??D zm=GB~O*Bqdlc&i`B1mfJRUGn3W0hAm38bvatr`QfS|vIjp9|ym1BS!RVNPjd`=HAI z1KxAbJ%(wVB0W~`wCkA$|GW3R=RHq!zMAc%FImbCa0^H2PN;v07m``r|@jBoCT6y@^xVaN^_&5k_4EFsaH>HK{KVc?uDi&(-Sgg-;_R9 z1PE=F99ed*rtcJ8xbbZm4lW{ge6#EoMa8>Df-+>5eX(emtua=mr<5>d3t(#O!wfUE+8pCdDxl<}mWroxr3U834%DhSeX2a1R#++I9gQg~JA!wr~2oWsW96QLMPk_7z$52yV8nq=W9RnKudh$~zc~dIP(h80jWpZT5ffG!% zc0ut4J9j+U>xT?sR+(it2gQQ6v_uMl)(cK7S8&&EO-7?4QZ-A% zpne;6Gz_?OCL&~yalTPkL_^HY0yiYIRP;fyq@3QY_jUFq8`^L_UE+}Ol)ktKWEM|KIBLFBE|Ei9 zYPRKLY_`3Q)BwjBewO<{^!D7j<<8Z`J2DFZ6GvDfGrIua#MQe#K0jKk|#uFdb>!EJWN01ET3`d^SC^bWU>q4-^EA6$lPGG}8ot>H(ba>8YV3UCm z8cV}w72jpTu%#q)LCVr_qwyL9u^Uob6)7CX%!}lVNuLxU}^BDESDvRrN*K=HQ57t`ZmT!E)BsF zz-FnReX`S@vJDBmdm}xsaK>dheDcgCVV>ObtPm0)&=5+@`nCT+PfC6DO8Szge9Mu z%3y{=Y-!vY6;~kN9rETkzxii>_GiE8o4zS!_e{(%w5-AnAbdlOWwFlQw;${%+rVBD zOVD~z#1f)f6}M72D=H~@9QRRTW+%Y(;u}u8k-(nZzGWkdDrrSAg@e`$LI&Y87(V2P zv2k2vpBWI`cok@cyFSwCMPzS^;7r8~prJ-k0ey!9Zb7+bM zMB}9;f2Mi~VufV;iflO$mVQ)eH1rJRbyCits=N?z$ZDvt=|v9{_%LBvt0qOHQCrOmzFDI~qb zd`wO$6Cb@3GJ{XUo|#6bXPi5|+X$gOV<-^t`n*_bDLugGiijQ!V+RbcWPcg7s4xQ!U^#MkI7pVJxMl%Dt3OeW#TAs7y#Wv!@DqVtN8`!V zw(Ovml!j;3_WfX&FJFH2(MP4PhPU5-yYH`a|Iznqa7Tvm^`c}G+JQnt)(iUipa1#3 z&)uz+ueS5G?9Rf<)mN$(*8+(ilQ`plz4$mnafp)iZ4cly){YQQoQrOj4SIdm6M8lw zdKvGeq~^eF17_vkmY^>wO0WXe1WIGm`rloj@y%jo&=%Dv;P8+C_>XLW_?I`&gCnoe z!=cv%!kNAP6QB45v!;BhATxSH0{pU56Eec={UlDg+dqaZr929L$tjsf`L`g25Kf|+9h+A>+q%kWUN%yJe0wq_*v`^{j9JF@tM?TgIjSR`oNYK7E1{Pg zjj1#dLV}XBE41hSeCw_+_CTGcz75Lk>eZ{hyZ!OU9~T%`AZ#Er10|4V2O}*j91Iy0 z&b!SIKKP*bw0&D3J(tLEEJ?@M5B}f}+Agh=uk&});I*1rO+=QU#O15^=cY=l>yDaImCopRTKlZVYxx-Tybr!3#=(*^X&L#hjJMQomv^3(jI)t4m zoJa|fJ%$9mSiS|$vfg&vZQD2^GV?;;3vN6$_EwzZbs;07+Ny9lTE{>C^FMdZ^XRKO zP-<+FF>xF1oAMoXyiC!wKNZ&XfEi;9$EE$L{y6KInbMpQ$ktjfsD&eQmzUYHE3_fK z#AmqjKcaC7$7$|WuX>eRF$nk^;rg_g#0RwcV4|sB6?3!e(VSoTm0xjIqH*4Ei{nPe z7w=Z35@BC`t`|RO3L+vh(M$M4QarB<+3dW zYGa5(QzO%p3GFLG?h1=i+_qUcgzZ%Zo$J~O!a?9t1=`iKN$9&yKz5OKM)JRJk%Xi1 zn)b#wzR~aPU}eda&Q}9mxNyN6W-z(L93Uw26Hh$hXIPx8@wETlB>A^EU!ToDzHId2 za|h_xY~XWr5OHB3UpAsMV;A;ximKALpQM~zT z0|2Da2s=}tEOG8w@kP?>YFokv$OU-r0@Z%!cYfy=fAJS>74|N9fBL6?>dM$}Vlv>x zMPHHK<@n(m-{7n!T|Kkend~ai^PcxS7UcsXw?GY*54sHL?M3BtgQgasIdk+~7ak{+ z6`3&ur!ZOJii%gpmBTE1IK35N7NDSolbczXiXsX$Ubee`tnfoldI3%9SgD z&_HJ2Z%P=Z(K8bfVb2GM!X_rd9pCVj5gereC`~k4Cp`}9fKPrpLq34q8Z(GFAt)DJ zS1G>e;Ym;;vZjnKy*RMc4+mAEQ8YHtrlf`#wq4p5H{5W8m#utF(dglql>G!>^|Dl- z@;Z>hkyF({EP>$bt(se|emWFLe?I(H zfy_*A)@?KnWJ~X0vxQ7aXLde%Gen6C1FH~Ji^ZUIG$lbpdeLD5=QY)i#te_okxSr6 z`Z=M_BcUA+avd%45q;S!yCc_2I#1_#b)*6=SAH_xU9V59gl^lnVXA<>;n|6)IVd{{ zrnRUttqyR4*K7|w@W7w^$)9+!)W)OdrEkRvr9(J-D>eAQkx?818o1$UmRvZqH9-;6 zE91!fm~eq$cX7$RSlOErg}yyw(~2ux!)6&ck?Ogy)HBc;daK^C&@5jFPA?tV- z>~!Za?<>M_Rth!ImxaW=V6e|ZUOTu7bKZQ%myHs4P5WC z=tU_5Bn=sx4cwXBPZX2OqV2Sg&x*gB2`D5$5^yzgWwlNv!#_gpV3!BfL34>5S;A#S zLyrwXVn_fn#fFjw4;uO~P0XRE7sV5}KALefVRgXe>7K#|0X=Rd z{^+BR`nfu{JF-)Jzl`P`Uni3`q+Vm(k(GP<;d)U;!vaBT-3djp-0f0tIr!Z{U+_cE zQlG$HF1Vd^T@EmpY}p9}AGn&>74*D>)5r)Y2tl*oxUlKyu*cBe8G>={dM*;@!jU~d zLzpGVlEEBbt1#ymuXe8LN1_XGt;WJY)nxYG2NPhM-C6$yqNVydBNXknV^vFxZiCS4qk&)+h zWD4xnL|<4 zEu8J$j^%5KdC^OO4ULGBB50Tdj<8v+x|zzRL}Jbn4zzZQ!nQrOlYP?5RTfIQF;ycP z@$fSQ*LDP|fkH^l`5g1|Id8@*y-X_hf63}k2+S^}9cH6>UiiLpgooJJt)NG_Ol?YZl?N6#KS@|l9!nS9C5G;gn&6f(zFkR$ty*=rCYko^=q*cmo^OrHyG`1_Ggd{8~c z@#(XXOLdUA7lM8p*;~zWH~jG*|M5HSxWmRUWP^5F6d7JIpCXKK7OG$a7cqQ6F`?I6 zDO>K`xpO{B^D@!foL0xGabd47k+Tq3`pO8nzTEYiI{>={8T7C!>ue~w&xT=aIE7jw zCo?cBL4Y==-R|6FOGp!x2DT_^K zG0V&>mf4;BhZRiM7n#!Ng;NUdeB;I)popfb6&E&;W2;XdflD$QhJUD zVwmz|$HK4u-FfGoVv|dQvssG=&eMF}gfKMAMi?*Ad@4a^#)iJE2%e$w@mtFoAtZZ>T%Eq;LUm9GBpf;8)jM@^X{Ff+C(6C|-s|ON;B6E?kl9bs0(363lVBnV&0g^lW(9$)T&DQebEjk$ zPkLUhK26*XXR)2vK8<_~Y+;Jp+@Pi+W z|E#hh&Zi@=;Wg1`SJ)x$0K6gR=;&S{VzI)(zpXv|@WX8+TeM?}JE#qC!|Yo`LXF12C#}aQtDxXy;4Iw(`24{vd%1LXLSBX= zvuqD^3bkq?^0K=;v1fhBxiD*2Gw`s^V~;)N{0SKX*m!0sN6Iz>uQ@8Y7o|T5_mS8L}%W1!K;im~+FV!FzDzz|>tgA89~*px_B z(kDcW4S_~K4bFyUruGR#i~*_+2{&`T-Q)|u@C$R<0A%k@*Ehdm?ld=Nzz-Jtr5&JySWQh`-fA)qk42<~6Ue0U*c}_n#Mii1jQ_npb={0zZHLyraOa@F{Rk8ux%bCR)4Qf#brd zEYkO4MwQx)G}UCNmIy~v9mPV-?16BHrve17L1rv2*pT%Z1mdW4x9V8-h%TO7vUj}j zIvnW8*&=rA3n8LS=y^}i@H*)M!DmJ8+C3SK(~CF5oB=v67-&OpJ1$=a$g{Bu%6`Wk zcTiUF>=~#A8~Pvmp&xQHN@n2u8$G%-mjKEv<6~VqNNspBgaS#&Fq<$-gh5I@OHXz> zF6_@PPE85gy69a3oB{kt1?KeLq_lY)Uxv{JPT-i=yWaIKkGE5%&&DiasY(CL^x~?y zM^bQeZWlB@%Yw6c@WBU_-PuDa#^y+d9*}Y+RZN>laY81ye*k&}*gc}#osOe@rKtWB zG@f8RG;JGLQ;yjrWZ)m=T=%_TODXy`|01^f3^-aZW%0Jf```b5UvA!tb7ZMCWaDXf zPx843h`EfE$m$oG0T(zhwKj<<4T4$1q_ZtNM{W><_E5ctk{I!Ul`#G|(XeqTW$#6Tf2Tt{<>HXA~BJoFrC+~T;KHuO5oEh%I&eLZ`5S$i5&*2U`vO0lwJcE%QR zAd}YtOtd>gC7@@QtN%Ve@pm)*idVd%^d9-@>A#UeeZjn0bLg{navg^V`eA9-)9_iMHquLm^dCO<>-ae z&-~2K(3tXX-rmhJMPEVK&|}#7M;5VN_6Rd8M}m#Pco~$k7Hmh&qzQ_#a5MxhKmxCN z!YFCn-9GWe6aMqjb=Nzld{wOSJ{jq3f8vRl)v+6ayUX#evp~aV89`_%%gZf-t5-wr zW3%BU7ppG|`CPJ(Ia5)%bhg6K^$!$6jlwqZxZ%$3pYj@;qs zX`r>Q(lGvuzxWGB9Q{7ncA#}>xA;uWoF+bt>PWi;HD z0(%F$b)*EV$HZ2&A$JxwWM=*=*#_Xwew0h(Q_HK}AEM+17$A{kYnGCMq1KVcCgqUk zTWQz?(u#6qlNo~o=^-$n&z{Q)eZBv3ug`rsD^OmhloDoph%#83e1)J1lQ4jj;E@7k zZH(76pFAwn+b(9|3mrW|Q$;sxh-Vz;WUT_70xAU?_Ij=Cf;9|>S(}D_iEOBurPTOx z5sO^J49?iY90bTC?-Qh{8fG~XTBZYh@W))~)C9gEFTyhJ#M&URJsdAWvj{j%>%n$Z zJ57tCWrG``wc~<61AC0U7NA~ZiE6NhgqZ8$J4Sfvo2p+L(+E1boKZkn6B;IBSFT)f zg(f)R3W^VWL%ku(Glm;?S!gh)nW8CovS0D^hNV(l(C_~4@Al4wk}3-h@gXy#)K@pX zw^j5`k$T9h+}4eT7jVWqoAebSgUJ_2##S%^LwFes&MK4j_7>604<8Wx zGG=U|*YSlU9KM4by~qy~B~2Vgxgo&81Qf!Z(#ab(%VD*uZt2+{c3HX~Bd=ZyB8@su zcHtyBVF+yK3DzcKa#b)S^DTd}sN{N%geiTqptNxpnhlLveTR)eWU!{_n@XnF$Pkn! z0zkyl40+L;HO!2LoDI#1T0F<4(0Y2Y1ae<&7DMuy>605}TnO0@ZI0ZrqHzJX&j?zv z2r*Z;ij!VXvjH2pUUZHT=!Dg9Y5z>hlCp@C8e;gD*FG9%?LZqqW%yQ7#uC|f*{R$% z8%@#mU@aM9E@myn)J?T%xUi}C1WJ_B1AO}$&3xn60~6w{&IL089hrc zmldba`YiVrUYW_RodcPGDgr01*hyK1FOUj&*w#B|-sn~bZ*kpr+ik9Pm55o~W;4*+ zu@+rKW)R41G|1G=1YRsT0z6R*kPAT%w!XXsYV*M-XO<;-8Vwq3${G`tG=hB~i(r@b zT+GTVC3lT-!?Ia=(cw3AHc<`uNba)7Hc7Gj!@(tK7(h?PJ%h#rC2A=isQuuC+dMfB;FXS1q+Cn~<~>%NYRDoY#^ z@>Mib7TS=nUbQ_D_#glAAKt6?k5P|5{uHWCmerPo8Fq zi>gXf-4vEaH3o2lcC#IK)WorQtV$2dqRS1V=)lpLhWH{~n zmWMlEE^c%>{`H3JL(sE<%nVurCfdF#2*E`!A^}=ZH2Yk3>cy(fECeY8P8l&#hAJhJ zW1oNkoGN3M7rjm36u^-@Q9$KRL(g#l`7Pzy88YM2*dtp$N2 z83aG~L&GeFW(gG&RYFy2&y$^PeRHcXX1#Ld3S>`K$-;kJdK!;? zv34>CwPLU?yWSJ-{@=%q4r5*_05E6&+4qY=dr9}kv#Fws*x{;k2D zOux20QwNzpv=Ihb^z8)Ofl8;Cj*bouF58yT3auP@8CIEn_PiL+fyS8EzOzTnEt7^V zGI~n6Oi80b-xNzLsDSwQ{L>g$aAt8!fP*ueAx!SD4KVzfZFr)78II!;_Cz>4jq!)C zGB}rMD_C}>CY-XXiE4)vD}BP4ZA76LqT?E}T9Nb0WqOroxeq5;v(*+3KAzg4AD3X$ zSVVv{EHz%hCAdLvhGosbp_du21NMT#XT#n>DqJ^SSPkYGh1JJTaK!*5hSDQMDJ$VM zDU~Dxvfhw^#!_QLSx?~DkWj5F3NOYXR5Cq~WToL{m{4We)1;3fJ!SZc;+QmqP-TH@ zhPiVwgphBG%J)QVRf11XF!zmI9L&YdI2)RYH5^bvj~=n;5i}aUai&i;1fmJ2WuvFj z*NWIi2}kcr!I|`R!K%gGNu}-KVCw`eLHwNy_-Z>{=%)?|`jqv{q16Xc zL7dsG*X%KR1sy@{*0%YO_|vl`v?FLOOd^` z<5^d4jg)c(D##fFE!}tWr&IxV82*ESv9E!6POKh%_WVIrCE6)yQco~FaNTCCvnFEL@C@k`42VU z7TBSeSD^=iKqHD4Ro_qVOLvsCN-ix=E#t|z3jn!5DR*Luu3UDPBRcZvDc4S8O0#;F zlZ%3sDM8zIRQ3P~3~<}F84UxJVuBT85x4n^Sc54?y^;XiblCd4ZzIg=Xv# zpx3jE%m~BnNFm4KatU_ndurs+xXVC7Uj}+TSklxv`XEhe%l0KT9HDtB#6kDyp5U06ndb%2qX{TefBW3;bN;x z#KryaR@?`*Prte~ixvy13ZP^iDpdgqQxel?80#76O<|+YQs3$#P1YEj<=A@bP0?r( z5}g@2Wj#&HR*mYjr`O0U^fe8bEihz_v0;I7Y?e#MXE?|A1*%_qp#hmjb^-y{+YtK6 zj@P)bXNjR%Hhc|L+mJGnP4O~SnxW|p(=0KQd1;e7C}bRL?BPUUEDaeCtHZ)F)>tmx zHw>0T2ZL%jO|QtUQ-)9q1GOPAv&-3JFC*@33^y#7!)4I)>_4I!w!mVJ7034i_9f9;(un+FK@&QW4{T-qZ<(a4u^ZJ=UeNbuUv zw_14K$cb?b&L~J9avP6~p#tInwtfTOta8aq&k=deaKmhd02veopg!NW;eXdhRgt_ve@cu3~OW0vKCw5RzWXTHpMclRge(K94QT1GE2UOcj#0w(GLh< zK@}^^G#f6aXyULj^^`*?*laRYc?e}xP`(8v(9h{{Fe7##F0VimgaFtMr%|q;6wOpN z1HKe8j%1tA27^<~Y>X68Jg8elbn=$BqgnXzG;1UUZMlJgm5 z_^Yj^!Ii3Dn3aYp{0g8bnru@haEYrS%Vbbo48vq^SkHh!PpELF2+5R_8ym{$Lof`7 z86gnmD|A!`IZGhv6AA?F_{L?tfOABn9gRjp0&R6#WQezcd@=m?g-ETR20;S#C6lLHL;Ol9G&X^62Awl&hGc%;Do|KxQ zsW8b7>1uAVWS#x&hoWR}ijbUemR``#4n>y{d>nl5fNzsBg(`eKGRbENx9uxjttsE| z=1m=eoJ?Nyq8F(cN>j-xTE?Al7;Dwm+c|Rq0PB$9E{g{reDLER|M<@6DIv@u=8YvE zjxde^o(?vU`0EYZY~(oW)2o;eX1jJY%$-ebDD4!+ z29Bmg%z)2EYF|O#=}swnv%H1|_W<=dUZb=6OGD3|FH7zr>rDd1*t1#^Hn_13fev85%NEO0NLC zpltqCcmnk7KdX3UK z2%w62VTv33jJeadI(nAul^Xu=Om9fffNxPa(u5E1BKw})M?H(F?J*c*dG?~%Jh#VhsHl`eMN~bTccyga8m7P5s`h4|#?XD6br_Y!f zjh+{S4jlMw5YUT)C!1(wX~>FnuFM~?K*Hrj!8;{OFYsIG~G%;Ta zCko0~IK8;R5;XsQB`!f~-qi7sjXf|qPWlWcoKldNHj1X<%-&K~>TCqnhWvmu%PRxE zA>fwGfTp+rtJu++V1DU^6|=}@9)&@7jH(=c|GVMY`!w~{|`mt}AeaK{Oo9a%DSn$y^OU8=8 zot!`xOU>0m^qnA-5V%Co5Dw-lS#7Ifm@Twr3cp?yNgbdaH&H;bgiM2&#w+}wCL&`r ztP3WTxb=Q2Wr1iwX#{9mdIEdRx+Dxq1{#-D4@fcs82h4FZy>V^-9BJ}L8kP+(emei z{^wCqN?-cq_xuLb;Y8L1BU@1n-uAY)`M4c|L)zDKd9ekM0eqN@SA4Hs82s;|pFXgV zrg;7IPyaMyO?Bj^SnS(I5GeoVZ~n%o`;_o~)mz_*+!O>2vOo-gxJmM4B4T8k6E&ao z`%Rxt0RX|OX#!%Hi1oVHy$&Xl`|w$sQkHNqp}|}VzQ4jW4n{#0R%J<1utDW{m5gG^NxeFu_amhfW zBMWgUQFW_r517(x{R9LT_VD4bF(g3d_{bxVJpAy(+=2VABD<^d8Zy)KLXW`35#fsm z9hnvpAKxV-1V9`haHM2LC>Q@9^p}76mvR(6p7ce};P_q?2?8$=G?X;p{_ESPHegNB zFCbVS0 z2~O#s{NyJwp|_14twP|#*enq4G=)IJx8W<11!CU`*(_r%8~n9oX81d+0CWcM=3J|g zJ#Ypr#O4(s1L1&7`a<}yQi8}#K>{!I&R%`Ow?zr~^F^cD(iq|<90NUi zl|uAV25SY`Bal5zE7yGSzemB6AQRSzj*PY%bY3p{y%_#6_5a<1i6}nBAaDQr~+)lkBOd{ z{yEQm?sFMwq5C#u-{{KObccuCV8em7z&7F&PdovOXShn^ToB{xWf>Ub=Y;OaAvY`h z3t#v`zbZw;Mnw2iGDBB%Z-9dp8X0D;85b^GC`lk}%8HH8^?ePi(o9xPtPec!0GrMx z5Xfe^*mi!*$p40j8~LfiF#;%5|KJb)VAdN$96;%%UG3jv-}BX1RSyAa2_nmKq^u z$k%&gemBiOu1}iQv2@1lM1qXu1zCbNkKRV|&F}8dY)*ApqOr8QoB<@o%tNZE}@q+qBgq`q} z2o8j+SFd`-(h`fJaWn;xap`aKi7KcLZ8%)22^l!Ygq8*@7Y$pSc_GFgXBrIwHWo1) zFXx!i_=ae|0->G^%tFYW+>1JIf=Zd!Hpv4T$b$L}N_R7uSOu`aMM2)I61f?2zrxIU zOs3H22Q%3$FJ#(^W@$9;e&E7S1pv=Lh^Yx`ehKgjEdpc>Psq$B zjTYM)%M%(Jp!hOlPbS2W%s?NLL3m054)8)FUusy<2uFZG=~mRniqB3=34$_(0~uxv z^$qzF7^JT_LD4w0oTH_Ne~T(Qd&88NgltGgl^1~xLD3Ros3sgK>lM&}=O)_W&3I@C z)E)|1j}I65!qJo2r!fUqbS>HhQ<@=6l<-+DUuN}u17s<+K-j}ynvD#$DFR3OvE(bt zR3oZ}(rK$Orc`0Q1k0LNCDOw*WkJ~^#u25i1bne7QqX2ry~i!YVqUR0-TOiv z=VIrz(h+Ec?GTk{47_3NHIi$dszUa3^QB9d^!<1lvuJPNqz6S%OzwU%)7x6eQ2M<* z|N8#sH^12eDL#YYyn)lu{$ayXNuB-Q``-8ZVJ7<|s!PH<-481<)DVyee6V71F|-!+ zh8>$UTH#cKv1aH*x?D176Ok_iX6-RD%nqoMGBhg+5Y^yY&;+m(oh+yWge8QQBPCcG zQ;rdOaw#mCM5#2(g0J_d89P864&t^PdgmXO0MTYvL;}vyE-&~A3>Zg`0sN@|ZlGqk zA;e_(@Xdf10e&3F0vW26ULY>YB1)dnH5go zG8NqopR*!lIPA^N8mW(3RV9M$1k;dZATY;(QX3}pm@tD0u!!XHUDH{04OeJxxDtFK+fF?D%1+rg?FJGi+tVz))Of$G3{h9aRj$%7r@uGLkRBY@7*G>x~el zAUeQuk6V|X!IpczunAA0Tb)1&mJq75Vjk8v$m5dL5q$| z+XJ^;A}<^qQ@m&lF(HT|5kpgvhnWlapwyPq1<#R!7&G>iiXfaoj`>OfQ)^i}aa0FT z=?Q!(;aJ2N(u8R!5ySLH_UwVfOc0$cO#(E`z32SMBagV(=^0zbEH(JkCmb1ZX|_ZY z$i0ql#}U+vTK5)h1~!BkE`3Lits-cQz0-W*!Ub;u-hA`TFL}vJe60E67r(fZ2A37} zJtg8UipI>%4G<}NxY!#5cdQ#?rWh1)2y6&#lF;B9@+oX`8b&!YlYo{V1bf){Y06o7wrvZC?sB}@66YBwM!TRE6@RM2BX$qZ>; z8W{*ov%twS6ix^(sQYw`L2)ydGkV?PFc7z(WP)=mdzQlKZ5RqO<;x2iw%)!*l1&;o)x-g*=8^^O__Rhi zw1XqVo}=C>n5{m;&`?=`I9Us4A;6*`V#UPANA;$nM^G+T2;io`(i7-o-WqWSBAk+X zUr>lNaptAr;5D2EK~p_F0yo@nL#5m9Jq;!bkSuWscH5E$K1@Oxal!lxzTgX%rw_Cl zz+`;&>Q&L5YI&6l{>q{_Y+6?KA|kK%;2J>SWbW$ET|L>;WXZ8iX_C><*bCum%^k8* z=h7PK`_LLlDTKxg8h219YFr?Qh#|*@MFdEX#`*K-z0u;`W_X}JrNe+(p}`ua!GTi23qju5fMi(# z_00a~fBwgF){d0c3m~OF&O34TDS5>T4SPeHEaCJU%$SSbekfV?&Rt|)xiFyYyd4dF zDL2Fua!UBn9ENs#^(nh%X@&qjFPhG}3^YOM>rIi7aa_O@5oU4*xlE5_GTQe1bT z2by9QJxh!3^g;tU11CBMk<*JI8}|A+n!ID@62O?o6dNs)`k}EQu-EWsBU4Y6A%;H$ zLV0m=027V zExPo~viInZPx6>ivhk71z4zYhcbg5NRSw1k{bP?k=2>IULWW~|dGXgT0jcjgwN)1~ z!Nbe%c*i?bBr;?0ltR(vB|9Y&nhQSCEDEdK9T8Qo%r#*B-gdhNp>{;&sA zfb+!}rq6m^r9y*WBJ5#G*~8<$)s3+sCT?^*i+a;dH_4hZOfF=;p1Sa5wN4pAGXUZ@ zAbErv&O7gPuv9?G5>X$r zBl+URiylnj!cho%mfXoZlRcnBKK^VvZ3zJllp(eqm2UOk^rkm?Sjqzv^k#))hH3~n z9Eh?##Xd&}a5M|;gM7;J)eDg$n%(}YjMp#2Y-48i5kr|>~B%a@Dhwejm-_d1Vwn#EIe zcMBe?C9u@uGs0)vf82zja+f0$G7V?AE01eFl-`$iH|+y;QyQ*}9j;XtOI~9?2+SB* zT@MHYVPHy<`i3~Mxp3ivDLa7DgT{_APa- z1>zof#wF|(ty_3EYCzH)*_VYhVMYTi0giB5tQfRiX!apD#a#o-Ma4Xb;AsFSqY|W{ z4+{2U2Fpn3bvFxX`pk>gnA@z4M*#bfaTRThU#gxdK7s+_`g}Mt|_Z z2VE2ebvg9#W&~I=82eK8%L9&|fBn~gg}~Qc6mINUhA(sidoK=T=b=t}231e$d23;4 ztf+i?YXkTX1?nYE)!66}m{qpu-}imr=Umgty7B@^h`GbSdCCTW?6t!0|NifLrA}s+ z0fx~bxcr#K#KpV44T?}i_M+=4I|vDe91(!&rpoyuKtuIOjt?8RWuDx3J~hixBd`Ih zPjh6CGZ!HUKqiGHCOz`robcS=7k$we`H-mU&00)eM+VJIEJ8^Uyrbj)i3m2{mw)+} zTY5opoBfb|zSDEhAWM1zOUi`$G?e4GAuP!RMFTvUphN|#-*BHHdo8rF^bLewNBOXl zzNu`I$M+av4X|1=JBPicQ7HGIUN)=(ua~kkr}wO=qs&3-GA0YR(QY^<)&>W|Z~fM9 zMc7RMgjTj25yQ3_AunYi%n}fO?8ko0v*BoDv+P;Y+qzCfKB=^gfm5Xw0|7q>tj%NGfp7P7>K1sBYS62=fnP= z&i;2Xx^(GMmdparg{*Ye$d$`~cOua2Fnmfuj*#^NWgLfaI_T2Vq}+*_mnl%z6f2i< zHvpF}Uk*RQ41ka+)yK!vT>^Hl%LFPe7p)NO7BUdN(=z~`eVDeZZQuZLqoc4g3N{;k z&1oDNHw<<=N2gqu0C#VpU9(#3nL(GZ)m?L2-7B;PIfyiZRd1@~bI&VHwDctjifmQv zOM(pbA(W)vFb$gmxs~yR0YLcdDH#xG^jzTB>uz7@)xx<|k|FEmqF#(U$Y~%rN@>EO z(cowVvunkU4gs@cMi>rRuOP17tB&P1khmN((1X>isB@fG7?p+B?9}RpKjkgA+#;hA zoLKa5z?#yhCxF$PrFYBNQyPXE`{~&WDvH-Db6@O{QeJD@Rad5Y!6axl0N=}%z8=!W3;`0-U+?6Xv(Zs)`ZH!odB)3d1PToDUmVGJxAO7#v5NLfBCz=`#Yz$R?4haCG(0QIU(dBLzzBDGFY-q)ry(( zAc2N{KnSHdI3u)1OM?wf!%>doP9d|2WtgTumzmv(AS1^3OFT3!y@rh)wl{|Ae3Ye9yVEyzjmDnVyrzv7d(89{8)(PY=brXV7xr-_aEG%Lc^s;7|!p#>VwRfwcMHYVDNS!Eb( z@G#wukd>X66*jV2GJt?#^zE4eY=rP&^pFxKoQbDMO4dq!Vm!0uQNon{LnNMQj_|8DbER zb14(cJ+$;%!_^$D?MKN`GG&-S5;7OCltt1mDxPwg7;Z=GF{ zr8boh3})5_(3?FIc_c%%4v)(C)mc6DLUKv!bbS;|DgFWgV@sni+EY;-&P1s7ETzgI zV;VLXwJ_0{4POv2!5Z#+=9u-Y%#y1olC1iUFk5D~hX41*%Xno8g!QWOVgHg6X^&2nE=7KD+iWv`+nkmhcwqdyRpXF4%%Ggj};5x!eOlYLFQA%xUm<)I;_HhJHxaIuxGlrXWmmq(2nvSp28_9tD?$ z13|KVAjlWwWVZ?voBH$8P_KniKb$etuW{aKna6VENHF?B9lu38nKmmI6;KT@Q2&S~m z`QoB}1DhcvjD=G`$iUXpj(gQf`$=s3UauiG{c)blk=q092+!mXhn2UTG(L#4Cy$(sNuON4$6n_f#JWwP8abccneCs#VchV!{R zoMg*IB1+durrlLKC4LL3SrIPQ3K&wZnpHMyhZjJG>MW!cvzK8gt@XMzdl(JZu3Hju zoN{FCj_!oXR19`YLp@nzNY?f)u&EU*k@Po@FeMUrtP-X^Qb?}s8}Q!I4QhDdU0==X zH!%#A$N@Bh#!wT_3>g}hW82=8)hSa`ienWp;53yZJ(&hzT&Ee=qaV%X3MJCXS$Kx} z0w(h%9*riyrJxs>)3O}fmZzec90#!1%oKqm4k^VTLQdN*0@ISK0KdiARN)mcO^brzy5RxCWR6ffY650T05}EU zn6!xMe-xau;0uZ7y20eSbl@8n*ikP4?s5cU*0O5CJBkb-0OVj3(iw77*feQYOtyxn z_=TZO%*+i{=@ecVz;gs)c1#5>ynSquuY`NeI(t(xDou&}kkLdgRPn2lWo*5=Rnp3`f{IJ+EPiO%qH4O-&d@Vz|o1QyBE3;v$Sbcc|JGXbG8^Eq?tV*YI+e zW|iY#kvA(Jb+;iAXA#nO{ujKm&*}@QqyQkvULBETX9#>FUN%lo4UzL(Z56Yn-_&Ha zt>CR7TTN7~gmfyZ!$-vo^bmUfy3MCV9gC@ke50e>cx`r7t}?62H_G7|<{atqn^i|W zypZ%0{Rl%vL|D}dvcU9)ahe_5n-ZeUguIW$)EV;d8Ir}LZ_^y5%8|%r2OvA! zQE^Vi45^tNHZJmV)Wj6MnzTO%b~#^1!71^JU(5vNu5IK~_(}oL{eh@Dd7Z-3!wAMw zV)i}_xr~mQ2*9j>fi)L^%#VU8C!Xq4rpdY97$UD_%N+)7Og4B6WL7VJ1SVNs z5Ioau7z~5$^|zowRWXaCa#3>cge6E1*;C|lOka+xuVOW;jh;+jUIcm?!@eh&tO8nA z;m749&(+z$M#Mk?di^aI*qJA?f*z zd`$$~WRX)R#$2RVSi?-n8d*|$=QmSI5uT=Im^%Uzv;*3!{J#!y0T$5oa=B*Rwcr9xXSP~fq{v{Y~EMc$O>pSq{t2J zNwx#n3kL(u;lvZ0R)x$M5o5FD@^45}&{v~7d-nh{V1~obS6Kl}Sc5ER!^{&lu!aB~ z|58BCMYLwg^4C`vy(xIYI315ez8Avm^)z^*8q5fLd|>euQc;018LKcw*m3~N%Yj6w zEPG~}OzM%u$t3b~`Ip!G-o{)C|z-Taf`O<4- z7)k)WX4u)mY_c3xjC{0ZXbRN@nJ&;DF6b)PUVC%^~p76fsqN?N?yRDF&m7gav`@O1K0@g zLSmvpGSSlzYVZe_7f$Op43|5sbR5mGsk~}SZ$$a7_iO(I3E^5m3cCWbDyi>) z*tKk7G&xe*EnxKZdvcn>*i2!dKiVKMo2rbW(x#*vo`%XUfI>>9$FPEVoZ;aDBTpOA zsmkLw%EvSTksg3OK~LjH_4G`-ujk3o#S)+cCa-!^Sy_0mF#Og5hVsErsE5Iz(RfOs zAw6=>(Wjvo8>KKp1~7DA=?Qo+BKH7gf+;ST^A14JXi_?Hi^QKKn2m%9m~)3vdC|)& zFuft>-e4#^_&6hHC{9yM}hzyE0{_; zwylNd*IA=t_{c{-f@fe^O^;s;$Z7JWq%`nHO;8CK2Dx~idg>_}ww0);R$F?T1kx&) z{qToBY&MfTD!bz3nqZ&!t8A4tn_e&sS%ox3Z`QitF{IZx@+VHFAekUcjwCiyZUw~8 zkcoa~0WPKGU5{DAsV`qqdGz8v_uMp{g5v>H z4LU$Tx;q3>7(Kc>M>j}F%J-eW<9Pps?YN(3yFb@?ogbkDp3H^GYey2-z)iY;VKj-{ zZv^EV&D9efrr2@g0%tZ_HGDsrFYmVz!zqan?r=Y#N$B6L)N1T%GaBwDSiucG z4Opv?6Sh%}&2b~5;jX{qCB~nIuJmZ+s+K}~7rM(< zvtme|jlAzRM2!+6kRy!sWRAg>QG7PMcIXJa` zHFGb)&R~(DEI{|N(u8%`BoxKnEQ~L&LH;JR_@+W!MlZXl6n|!wte?%}{5c5Y=olvtxDBD|S$h~+ zyHUm<+0E0YwZY`k64!l=CeS)=;yRSLpU{rBd|3W-5e0&at!a0`t?7&w&FK6fj+*#Z zX8X1aa9`nCyleuZ#Js7Z3%=&Ts+wqd3nG8~@BKdKT;1q^@+0a*wc}Ei2&DYmmWZ|K zpKjjneFjiuhLnL@?G?Ey5(p~8CvNBh z0Kd^P-p}_-y5S7wjQe-(Rl|D^6;m@mjo^=2M2^&f2CE zof4OddjRhG83KkkTg}c>zI9=)^_b)n5nSK2i{0J`psi1;;5U1vgV9~0gQ)=di+aLGu>E@re9fqpbN7i~z)+Os1 zo#^oUK#w+!Ed%(G&)-HlU`S+`@Nt`~`0{i|7R^Zv&-9a)e_+@&M(?=a>hP<)n|oV6 z>hwG+9DNa`FXZ*G2uE{#s~ddr3t@{+-U8z1q4Cn@mV03LB!ABnv=izx11#<*9Fshe zG5YKsKir+=)L&tY2tcL_vk__by-X|qS}m1*+VJMS;h6*uBvzl_ND4hm3NFM4W!-oD z)AAjYh4k=&5`B@w*~@YkZ5CXTx>g`r&3tm{PAi$&BQuc*zkh&{$dn#pB1#g0znB1EfU*JBC!GUR%-tH;~f-y!VB`cDmYBkLOQmkht)XVA- zcZe|s9N2i#>*{_`h0I#Ld{S`HVs3*B^A*N=c>QG#Z`#*j}K)c4jfo zXVmNEa%IU5SMSw)+L|PX1&?{{d?GV6O_|y~T&!ZW2#%coxKw7Amy-6|LkmQzUDB5p%Ni930FV-Uy#z@EW zHyV|gUJ?@!dj>HSY#^UWQ+uVOXsg63!i8GCu$l(oSWZOt9}IOo3=#as`l2n{Upv2r z3G1wk^Y7S_rwaefuu6N&j*Zdnqro;LJa;Fi=my8o^VFvlpR)^h_BXHq56LN4m{HHo zvXm%w-TdViCv2Cu`b46%E#9JuB`pm_(LgaEg;cgO;^R$@Ah3_*p{(*Ug-7~np-=`vnM+D$F+(A z8nnNFSY|$5^9j_n82GriXvIq$hWDi z-{<0*hPvP{(}egPvJlNt@9RZ%B}IqetT(-N#D*yC^A8FHhz%GCXiw}dWcKcVD4Y(} zCgpO*67>OQ^%*52G)tMkD}cU}fv?9{N|1 zw@MU3pn=KIU{ykU>Q2GQ&M@#}{_QLm!4)2^z-bEwZ+b`}r$sa-nQ_x+p3ubD$hrOD zP!-4$rkkd8Ir}&TVQouz=hP-D0j7p*mI3HdaFoiiumR#FIAH!5SnJg3)&+cIo#Fef zwT33z!w5AVu0pk4KEPNU18z#yblN24mruF+5g?v0cycXIT)rShtysABk9L3DpHfi^ zm4YTD0JBRms0AeYsrWDCl`w4~7@w>M5FWS&Q2Q+du1JiH+X~D`DL(g5+38J!UQ>Zb z>M21PxW)LLz+M|F&bK7tct(yH`H>i0=IA9M=rGHK<8YIe3I{YqL{}K=iD(e)e7g%; zY{>P!1TEME3YV)F#4}SD>mgPluf@p_a!6JH+!&Z9>@Pc9Bmky7)RSrE2lh(W>N|;V zfsS4T@q@^Euxbf79(BxQpUpqV*Zt0{);;txXzJzdw#4p+u*Zk^-=1$?TRast@A@3~ zsYlH+S}s%AG3kY&rf$7ug$kJ--mzCrK(^tkC<%?P%#X+qIU=c(D2-Ps=YoAr3vuz> zo6ZV?6(QzON=04U;MUE^rwX|cg0BgcN=r>uLtA!i8LU2(PUJS$sDzDHur}zJbZ0z`2OEN-Pv&1+*Ap0+L!SfK@<2z7U`2E7 z3`{LNOltqmZIA|hgHJd|%6^BZ3d ze2$)%!voX+u~ON>k#(gRj*egRZY%cNI!{iyuTVh>NF1z}{FB|*eSEY`nN zF{{;^0VEw-u~NCMA1)CV5G5{|ASVdO$%|v=YhPusGoH`jVBO-jkz1p1J|}(}3IugR=mwqzq42mNvY4E%?9$UPSL7Xi2Pd_<{g8}5jC+{Swh8$SFTLxsu{IPogJ@r1{_r<_!jy6@Bwes&SW_sqI zkQ{atji2;sHVMdalv2;W!RM=sy?_yC0K!&*B+L&+vKj~{rHaI3@p$?t*!`Hsu{2AE zLljg66b17r2nUoE&^7Res~cmivctl_sl!Ms@p{Y34p{RAM57QDcSE(n)z?LaiHf-kKUHMWXDSe+-x zgK%vi-b^wh93_0|XuSeSej&{QHF^bs>43N1o6o12Jjg$>R2nE4WZHdV6j2E|Q>6+q zGgrp36kU>;q~ob%pxhFM2yPSoOteKrvC*`I`%Pu7_F&LF3o-u1;-9nx3 zi;XW+{!J5oqpbP6Sd&ay)DYquh(8#c(rV^)nV+&K@OM(+cJWyUZK7xf2@+dClEb+T z7#hi}TbD7}t@K|B+QCH;)e6Q|)^w5jr!DRGp#?Qp=j*4QK8&75Y$-+bjuGZzvIsZ= z)uuZSk6zo5$%=2dQa+xcomMfNInwOvy4bq+9LAK8Fx~L2G{^fsl22>s-GMZ)vpEfP zeeS-oT~>uEORFjyaCR1kA)PC&fd0CDlY~0=yY{hs@A5~yVC^HDLiX0EC(n7&Sp7O! z@-wnf*536-O&v5k3UjCy3%$ObJmbptctJ6V#{Uad!nj4Dr2q!*n{6mvh=fXV?Unta zqxroF|2n6luG7kXY$}s42Lky|ygRE(qtsgX*1BZ>*d zA$r^BO=Qv_y!H80)u2}Uu`?RWm*8!He94*4X41pJ_&)eL`r#j4k`Om^t}k-N+m<>Ytlt*LzV$tGFb!4Ay$r4^n?Gr$*zI#3q**hO>*D$@bGIe&szcLAK+Z z(iMv)au3Az1SGw#A_v;+V1}QjZJaz925%d-$jWCt{%geR;Q#}-ahBiS(RKW$3#wS; zlkwG#DS?2e8jfd^BtWz>L~@7)7em5k|7HyVh$%e(?U2^Rh~ch6b7=1XH5n%L>(!!N z3h9DIhj!H5t%`uJKbmLKbTGw9!x87*8r^MQQUP!UZ&hBFCiy%N&TG*G5gnkfABe75 zM`Pv>+x}b0ij~~&n#i(F*;-_j@t7s^OzK!3ZW8|!17nzNgaxh~n)!@LUAKtwV4A3E za_f#L@BCBtbz|(Pi1RXQGkv7Yhl`?Gj+vl1BzcJiJ1Rb%)kxCuRMT);FMUxn68bq$ zpd}E^__YPJ0SNav#-hUQcq7d$Q&=NbmwcQWC|j%S+k}aV`DmI6mXB{|v*yHC%ofm=D&yyCdWqDdZ)>zuWI@{PHtVAljdb1*3fSBr!)dQ2 z7&hdu{3aBsKC_>7IEZYs`&Ev?6i1Z<4QMx}`}XM-hQ9OgcDgbsYw%@e(Ab2ZC?b34 zgp5d^d%B0aOQb;Pk^P)kkJHFnFX@j_l$l5&+Zp~hL(~)HIGI}ept!Z;(TK*f@`BaK z@w|zRFmbQ6X*k!P`(8l%dP#FI0%uUPgeGRmK#`P za1xqt%=B>!)ie5;b-0=-z3$A!ygAU1<8x*pTGdF=9JE14-5z|RFyaIT2?nd5Bv@Yq z_@`i({1_*bd{lm2AmjAF$HS}^LSC{6(U1mqgm5q?!^a<6S0xR?vQB39z8K5RUx>&T zxGgb5s!pu4n8|*Bh(QmG!}1m?P7lZHKahPOmlo|bK60aFXY@CZ|Kp7x--h?)MWj|c z5mE@Abk+XpXp|J;X#UahX?lBff0EBh;EK^c_vw#A{aUz?awj#2w4@PWxKRP_E zM>dnfBZuQs@6nGF-Vv6@S_Y-)&J9+jlm zLu)xspJow*^;*%&Wd`R0-DMkZ<^~8SW=b{2EP4KMW`nR$&MC@`@`liS&?;H1s%0Kd zTd^eq)*asK~8|LPlB^BJe<{N zcjkCOK5WjWz*(h^4Mm%Ice87pixoa*Uh8b?Zr6LF3nv>Uln?zk(imWmWxpR0&%-NKdp+?x+HuAa|Ga;6br@D!1 zdR*^ClEl>>cVyaYFflYGmIu+oq((T<{6m&N-&Z-!a8+;r~@BG0eg@9gOGgOPsHsLYwsZfx8f^y>{#U|R~ez+p-o7&C( zZNy%Mj$E+9)R*93$;QGh;s?Fc{9d^A$&bU8Wmz@q7&bVeO{rE&e4#OhSW>!0RSGvq z>X&g?s`E=J>s_+-rjs-5Syn}S+2DSORTN1ka`ooYS^Pz`Tz9c~XX{1O`$v}$Mcen+ z*s{HDmKHbvx;4x;Vn-Z@eA+Lv&tnT8yT*jFr@w+I6$wv+uhH!undEeN2+;Zb=d&xT z?-IhR6FzU4d}_<(AR-*~;6{w)Qf>dL!&NS8I3@bg%~)W^O2mBJeDlZ(wua(q;FkIk z+|64iChu1~?@Y?`ru^_C;EIydxqXBb_Td{qyC-b==_hHSqY;~QGLA3ZjIh^6LATR* zO1aLfsS_O7yn2~wLRm@m!I*X`9fVtw2a4uNcn6No5CR;vW&*)R*A!zpkj@*7PX)t@ zZ)8Focwt^83C9YEHI<8@2tG0xq_{frBYqhowj2cjIRHkUOoD&CHZI+=hz^7gvApwW zxSJfXy33F!g6zS$NrRw3UrIH~@YkTF&eYJuIK{>)RcIkFmmaPkQ4D9ZP4NM%INu1F zzMm2k0B9BGzLxDSV*ov>U;U3VuuGk`Y@cOnC*oliSI)nH3i$fz*&%W^%6MJ>CXtH3`2ofDx( z>V#u@49``JR`%BIKWix|)vJponepC03_AH_zrH_0+FQgqq?3H0j^HLzNF)~vgx`e={MR^~ zqItDI-IIjta!`u$6>0P5?)gO!$Z=mLEK61&OtPAkRvX-8vVAD%1aN$)s6j2SUw128 zz43C)#2<3Mdsnddu1Z|AZSJVbE=>P(L5O~^!REmRvzKH8=w>yx z3mqXgAR=$np?Fpf_acU=kzd2;gyE}fdauOR>$+^tbuS_4@o-Mr#mFq7{xL3bD9%Fl z(-;y~yLrO-?VgfjvrJuo!{vpq=LhBH?|xT|epa0(zWZei$8Q~7`LX5_QS!-P3w%^! zFwZ{d_w%<0rP0khBGx7GRo09i*Z7ORDi`qiq@+q4iO8thk$#!FH$o%iPAEdL#9*7p z(!~fQ)LUc6?YQ-pVZ;JR&{R;X-Ia88qo@HIb|#GKsb#cY2*^(sdN73~(CSn-m)vw_ zVy-sk5r4zO{|Ixo6p@vAC|Ceu4IlA*T1z7~MDV<9>vV#>>}v3+KG!anf?zEHU7v?A z(?GAeOMworVeIeWd0*mUdyq4uT^EYgi5OQw-iurqSGS}Fs% zI~R&qsx9x&dY!*Zw0nNTKcO=b6T~>*)Tg-?Hl?=e6b`&!T00?1Z_hnZ#m#>X@tcWl zj+}k@(^IPC{+U<1-L9?co9XnA>6$OPr!_DXNI1-8ySQq2v0wk7YEZ**(`}r)3ZSTU ze^KXrX{q|r!2wJV&c{9Yf*vXlTT^ou<6VT{&-};bgd|9Ig(yaa(+iY9CI|`YAGrj# z{hAA4hHG;R!|H)_e_M>D)b}+*`yFtsipVr8{cp_HD4gM5By)?v?oHDKq@sw^HG(+!D?`_tI&p)p z;V~u*k5_Q4Q-U3Ja0x6!lm8xM@R+O!G64pZOF8u>1}cDH>JheTE`dBAL0$25GJ+Xt zu03Zzek+Q#)uH0S)DQ7zrwgO+GSD44^XE;wehiLQ$9mh3#c#|Ha{3s{?l@ZB1f6-E za^?o=82>arcNgf2KxzJWm;d8U?xh{UN89xsHNvnDBwOB5e57Uek5q@P-ge+WWZAsnU5O?4ej`hBh@Q~3?=9Gkm8xI%; z-W5pAGZ~H+tfxWLE=Q59S+TomDB=`bhW}b5dd1h{-QEXw?kS2{3nohs`c&iXrmobE z6gd`jL^AU@U-8%4qD*{=@>aD|y9eLX=ebbac^Brr+WQs0LGt{syDp>6$^^Jt)ureT zJAQr4eP!yV4K(Js-^m6Gn+rHq+%jk`9B=Nqi#UAsLmePsTGQ@grK{ffegCh{FiHB4 zmg3lQ1T6zQ9ys9rJomf18AC~QFXJnR0Am$?v1c6ijmbXd>?DRb-)PLkTt*_T)Q&cYr&UvipJJ(__XQyHlT&t8S&g zfI9*c80=O1-T4n9_*m4sm$ShGC_x8nMZ9hR^4iuj6kq4!sg*HTuKDgqwB-(N?h&w_ zmSqlF-Na#k-0h(sIm)pr7L_b`V_9z@D%gw(*A*Jc9KwAg3m!QGib+dtb*^7JJh@=% zHlN%25t1(M@nUFBLySDoU=~d)?nI-W>dY#j$$Fkb$Fqw6#%8TY3qMOnu)?3+`f-$o z2jH8losbAG|K>_sEs@7QP4-<5$Md+6pTB)5C3HGyDH- z8KNAzdZzZEyH(JrEuTVEYl4QDc_HapgJ(~!vLGDmzuJfyxM0 z5+`BS9sb}G`XC;ivd zB;jBos&Tt0ZcXerSnlhQjABwm{)GDef;!s}4>)>Ko(jLEnP>mFI&YNRY9=zqX*@gW zE_IUCp@M2D_z;q`qM!7uZF7KyAqSPLz1?23J6&iodZrLDU+u_62Fk7RyZnOp;Witg*@n22Ss52u$NB8r>h+ZH4_NFf^Ou1tBHiSMbLx-dkNiNR)&rVCFF_|_rD*&Kq7tv@`0u{sdcC0i^B(i>$xB~p|41Gi^v7?`VLVP$xBL0@QuOBU^%|pi z*VzM1aigdz>cPLbR!Nd=TC?G<$xrE+pygT`rDJtpOVatZ}zrXKCu_r zy3DIO=t}?>Bd79&E@7a0{!Bzc#%`u%#CKarQh2+-AzirU4W%;{Vw(YKR4f_t1r zhT?@xt~K~xW+*~p7RYg=MBEzdU9 z*;r`-7*Fn}DcR%4zia=kR1Rf%qoc=|HK(Ew#&-!119gYFwuzY0YX(QRNy|+MPQS-8 z{XM?m*($P&>Iu$FMe9yj$@M8HH>-ho#Ml#Ij$!|4ZVU4G3cj74nN<$29GHflkh(p= z)@-=4>#w|Y7BV2x3?=1VcoI|b;WkUr{RY?bYBEqnDVuhu-XH47He%fj@+J-grXy#@EVG5hH*i7=j_4 zIhl%rE<-==$ueTLc*ZMwdFTBuz@&!0)+M|m4fieFvD&LUsxI;P!1DL&*zMQ0lOL*j z00z4;JiPg;V9I5l)RYtv_n%ZR~`3S_0v$#Pn3M`;S@Wb7gP ztJ6f6w~hdAjnUm^1MztQM39aTnkNw_<*c--VV#uIiOr?>lGIc)sgdvB;J@edE1V-r zPT!ouQ5b&^oRef+6zNL?^Uw#bzHbb^iTXIttap~5j%0l3WCXA#3bA?A`6b<=2ht9P zR}IgEgJ)3p=%)u|U1gM0L0t2y>%!FYr9#^IM&_#L!e91stTH&ezI7;p3-!Y-Zbqx` zWzNBI0KtJ4Shx$E7Wt0edb%K=w3W9}D6C&@k><}^B7=6Iex)trl&;6zkEiSQdrQ}d z$+s0Bz_VRxBImM}xmU)yINjMCY_-^Jhb9Wo-&p>>j@i)s$<%`e1e>R~)jt9dsb*WXu5+>%sQ z#qtwV_p?ko6Y<<15_lem0Kk1Q-z@pAhxnTTs28~ZV$BQ;m$5J$A__5=1$`~9)h>rI z8xNVTX^)c~k@wWpf|IaIbW02b=-Bj~F^t?hH{bl7dgFJsAxAhSVV=`?NL|k~@`^qJ zPe4X0#C+(tn%S_hTF<(5&S6|VXhRP}C3xV3lfFY^f4PiJCeg(E(2|u~0BtmXUSi|^ zLr)wFyv*qplX<|3+wp3*H@y(sK%K(4z5K1)dEIq+;p$cIP#5*DJ$cN}ZE)iO+LZO! z>L;6cJC~(*0u52_?`JNTuP9_YD^Oc_RHHXI20uulGVQ?|2%c5 zC(o4Yu}^Q%`Pd=$+x5RcuAk|`o02l`!9E$)7Tz2hDvc^Cf9}@elb2W<=HU{n?*)0H z?Vy(;{L5H3zZHi;T-%507#-tDt@B(Njbin#w{`)nD{wn zU8r(5fk_X?J_=Nx64XW2LLAPB<)-Xz)qyeI9X(ZP#g5Oqcqtj%ABy*>8QWPSfDA6t z49p-Ot{?PX*~07&F_6Us7(1P%u3u=D^35pDrbp|LN)D{@YWq}})hU@OKi8cJx5AXw zoJ~_@tz=huvqj)9-^`!=a$UTrZm$@Ac4DSjAnX|JSM-a#-joBpq)<%kH|I<73Ct0c zNjQmH@I?bDve?9g-zg?j^irPH$Dng~5`F-#3)N1>py>aT@SokC)5He1N24!c7Q{dY zU*a2rtM0`>nWf4DjDG)Cgh*G%(N|cfZy9ADt8B;xOOS)r0}#UnWup&XgV zNq08v9KP3ly(u~+Rwd%psB?e*$)Y-h#SkG}$tAxeV@TDh#F)sSc44t}FASTkGqPjm zv`L=^>at8D?cO;y%+FhV(|#J(n;Eu6{Yk;ee`IE17pqd(Kn=SLkJWAN?@NK@{fP~z z26#*RD*`8+8N{r3wZCa=k=LolK9tRhkw&{%XRSkn+WZ>k6hZwXvmQ9z-rvP{u<;of zs^Ic4u$Cl^Wh_E)c^Afn!0xX3cE%#A`t3!pX^jKSv@P-zvz31tjFg$##K|F)5KF^1 z-Z_TCOsRqLJ>fA~bLmYNu)yo6E+4NzQMJrx3(=i4H8tNP<~OY<1F+10olgccwkE(z z%$TgqVRga=_&38c^Ax_NJp=LJmE+_?<8mpBUcu!@+jw5sqj)0iP~7P4N(`p0QtpQI z1PC=%`ptIEu=g6=ZO{wjX{^E9y(ZrHDozgm$v0Vn~nft70(N zHBp3pdLSEjGRZkPZRMgScSCh8wlv3L(4^R3K=zmKRdY4gRF;ETNQMZ`K<$q*vR0c| zt&i$H^SLvr3?F?@fKzUA=6?xj<2A`&pM{bn`3KKGpb81-baM=w&PR=clbAYvy0Rc=5`|EuWD0oZ>f4BEr{1(HQTfU^ zCQ4k!2`JId6O1DO334RlfTeX=MjJy!fY_j|1SWv&gA)NVRH$T{>DLpr?$5=#8Jn4E zn`q;DY@lM6lJA6SRpGSCgRsWjN_Mm^p>XDg-AsH;+2yuOE+UC3h#1H$fZa@R%t#BU zr$@chh3w{b3Zb5b-~w{Kl?(p_VU|+P`ORfH;K}gtiC%3*&fpxJd01eMHgQAZ%`D|p zW3c@ux)7{+*c^vh`Ph((8jeet5=Rg%$7Ehw+=>i2$=~8%2-mv$s3ZpAuW`IDv$M6w z+p2`N5}z80dssFHpxLAs5yE;;ID~PC`wG1*aGbcr8f32bLrCBY3Od%?>so0%)@;L( zbIrvRKq(An>m57mybNpfBItWI;aGlO4uJfUGnLIm3K0R~X)B@hV6UV5u;*}N1*Fy; zLl(=m&8Qh1WHK_(39k_Swe2d6n^VWs6(?)&_`eQRQa6{RJ>q!xv^;6SN+0&%$d)O=dSd-&}ioR_(@d)Q*V+s@LAd zS&4c^d|CZ|(!h(a^EA<_b%!62mvqD)kmWSnTvvm9rk;c%s@I7qgmPt%K5D$R5oz!n zj?43;2nNenb5z1TNso(d@t39KFvJcwwyG_ws-A?;l6`v)7pMB^wQIV98n9%uVHS~` z$L28MChFUj0AH5N)o7)`U-*vEiL(()0YV*p*|MXEjiCwg!KoA5PjFa8FI3uCJ%`1(tyF$XOevr67ALuXqG zetVci*znTwa#Rs@K z>y1Isb>_Lv+aEReuLP`K^qfYVu&U)+$<~j^W0VfOTe~x0j8?GVDxYir)AV|tDkbpu zMfFQ->iYVyVEq;kcmKKfKL2{(Oc8F`Q&y0g!YWb?!H)D&ujNZ{WdueywWR1T!c8^` zaiia=BO5Zbi|rPyH!3SjXnQQ+PW=K^u@>XxlhNg>UQ`<@@>L6?Mf5V`_rH=j4qh`z zcKI*h%RM#x^F&m5SJ9(0_WDGVkadEc1f)y=bW>3@UZO-ZM_w4%(@-7Bf z$b-$T^FhP*N!;jABHfvI@on1wE)?2JM^N-^y5h9W=pJ~=(nqRNO6@bv+ceGJRoslO z;#i9|!TL*GP>6)Ja<$z6{=Z6Q@s%ZzcQ$#NE1TGmc-1X~ltvV;XM=$vn5xUx=+tj4 zpryHe)(&|ZF|Og*i?dv|DfD_%D3EU0k*ux^tE0GBrQ;qgOc<9Xp~7c`H*Lr5-3S`{e|B%v`X@-s7O=xJK3U@wthDv))Hg~=ZJ&K(NUFV_yHKOHtJY2J z2yYF?J?$SMA|t}9%djHvrQi_6ET6yo1W}xC?y1~I(lECchD%xT(A0 ziVLX_;^pQgf{HSe(fWqXVqhZ18y}0Y|KA1h(jyp+Sko?nJWRZoZ<|YNK&-&^_d4dd z=hR@~N!QcgLv4rOh}Ru8Bbr$hzlJr1-R$>jZEgJ_FjoUN>OdmTLd)h+LMV_(rBnOK9uaC&_`VB7q!-&H$%N@pKXLX1F~e$mqmd2Z*q_f(?)0TBhe& z@5rB8lk+`pytuRaci6Je}Dfq(#+3r%$aze15=R~Cz=RTpd7wHuoR;G1vQ8| zzn%-?03Xm(@D)bd3wM5N>aEiz9De@3E$%SLlZea7JaDrDf9CT~5)NW>T580mEoLAgLYbv&~c~*=nwsY2ZFmnypo z)9H0LTqfTa`;sSFj~a!~L^hn(Tx6aH`{ixm7a!8&DmCMicy-($_!S?Orf3j{IygQ~ zgvvmWhP8T4)9s4bok_*WaFXK#aNq_@?E8HMzM<~5j?BRf{##kh8fpb86fe3_tg1#R zq+8`8_GG3eSVYD*0+P<|x%$52Z}OM=Ge?#7yV$4CiF1mLyzI;eU*(`rq~nW5VeIa% zta&yTGJj=z?TnG9SiGPhBGh9!0ku(QP79$IDQr< zQcT_Qz)sLVD=+`{V^Z`a4M`;{DM{fOlR%fG>6a#7$ozqE(Oe?IPX}3*26Q80v%uqq zfOj3acq{0jwKV8>F4*}Cz?T?{tr^)*N^D*{jmPIB3ML+o+)q9^TiJ^Cz3P`c8JF!H zS^$;RhcJA6_jMIoQ!naXXZrlfJTp1M}Fu)dI-nfI^SaaB9|Y+&{Lysz92 zqeaV8#7gW&CexH6>Bv>2V}rLnzroL)s&;h|$K@I87t5$1CvZMy9|s6@=VUxl=tHzO zh$8$!(6YGGo7N$Aw(AwPPhr{%W?BZlegm7#o#v@I7w_J5$sWozDvsX85H6vp zJonSRAZ(8Wd|HH+y0`tGxqA%(fq;dfpVneu(T!C+zBgf!EhxB^TV1MUX`85!TMPeAr^Uhr_l*NXgfYIs3AQuj@d;v-nx{ z_#V^QP9nXwd!qY?GvQv&i%useXr@e2-AEF>U|yMfC^+LejUX}TFMRy81i)27GG@i1 z7G5jIFZLuZlc7@HIi8ycAYHLxnc~Ay%tVTtF!;ptFcuG?r-C%aX4nq>Yog8twJHAq zMDuk>B>IOyUD949>1Rrf!P)%myagz+TT;f$GE!FrAF)U)Nt45h8KMi!3pAW}tCsmK zm-U)~-(VhzikfvxU1uU74_D8L8(48F%VSw8^6SCn)@{(XPVqyFmu!g)003qvhvYeJ z4)aoyzph^*&-t!#N3pqaxtYB|sK0#a4!}efldW5W^?n1O*KI5SQrT`I$zl(vLE3Uv zW532Z%Y&03&jL250_avwsBQILZ26r2o)v!na%lm)>4>j7n*=TbREVgehX}&32=2wN zGu*NVLSXE5x~AUEluc_y!4ieU#lc5rt%%YHv13Y3JySqu3R)3)4&tJUMl zLX>6w&!-1pba{n{`9%TMQsah+_b)U^G6+QPtnQHHmuDFOc;@R+A4vdcqtPplxeGcP z2YraB49ngp$<4~2^`Noj3lO>^8=8v9alVPKLY+AkEBxm~=qUMp^O_>@PH)%?BZSUC zDwyECLEB2!Do|Sp1^0&+U2FdXyhTwr#`J;N*d^HJJP? zrz(iD9`Jmet|@&(9g_YlbGo5Puit)}`qJj&i%z$wh0OCk(T z9$T{FL7_?G0nYf;7O&rsCND4M39%xIF=d>L9EpEaV_)_#2f@iNS$3-_m!~l8_L-aX z8~Q(XITWk#)DFHhds_1TyMz-ty_}2V&(6&ikJF2e%OU~&2ZqvLw_pGEyC)dngc-(Oovp#?X{Z-rO&|=wikFgF1 z%)&BrzdxOlbHb_L)_X;}Bl1OxitP92FnRR4hdJN+a|nHc!XosszQR0um+K`ZvDMj3 z%tqaCXTKy@bsYd7i;Js+hp?NT4Jh{E_@tF)|KL8k4N-xBZIOxoKTV6k93x9s3^@^G zPJV;#S4}5M!JVc!oqJfWVR@Cvhzyx|?*T*I0WfRQLUlLPDDt=JxZ1v^@kEp{%_tJD zFHW-!GXI%lggdpk+$`YJz-xGyn3nl{vtlOQM|m@Acw9t*?(gNwN0$5wb7^Lx)w@w> zy=_)lv^f#NZR^(nXJ)a;kCDoE(I+0$vny3q8|ig3Wz&x;2t&bNHJibEWY^spwL@_} zVV||YidA7%x+x}66+kDu&8AMPv^dI!K~z6ZsPiDDs0R|YDh-E+j;)I?0!G8* zZ10ANoGczc@~A;8LfUCHf_5%t-oQO_2^)#a5l?AT=1^e9s+m|x!g*e2$-^AhUuXRE zITHVMH@f0X<-FA~3wV4tPXGEWD?Y1%MWQ$TRHdOGXf7UW6N@RDo68`dB0x+hJ^tC%Om}XPIU!;@JUoais{|6tLy$ zF;pR@MNt+_Bx`~b%X=*)Q2B3oOgCNiZMn9)3cGp;#VhV5N^ks5(Wy11QseBr?6QQx zPP&}kj`^x6h~UKcfG9WC52ZR}^(z2c1qU&a?wD9^XLRJtevIbR)2hl~js z9-%nodJoEb3gD8gksPs~Vw5wo-x}%=W$!;zW6sxFNV_Gpq>F4NCN08$;d@n+~!y=HPT#kag?!>V+ z=JjOl4P8J?PTy}nVZYLXGz$B_+gsnNdK;wY)?@^b>FQeWUa^Vb$L)?KZ}E`lAZhYx-bsbR5JXS`|SB%u3wvu z{VVEd?wULGO+-b0{v0ncG1P0gkoj#(w)%!?i9xEZG5;s^d5d|+QM_i&D20ot$GRqy zJ72ZyBfu1}yY3i$RC5;*q9RVcU0H$02bT z9JRqNy|@k3RzG&+&3>sx>|gnHZYFkLJJH2wT0-&->3@H!AwC~IOCwn17E=V-jh;20 zu)Ot+z4&y?*)?4(=-{-+oA0f*j+K>;K)TS(K`K65}SpEQsz!>6_#)K6N0 z48?oHZ&!)=*t!UH^hOjgYq4nJf187iPka8PbpBQQQnvGZ;N=$2TB{4fF$@wO6YJNE zB{V(z9!jeF#D;Q|;$oNr<_?W#!kLY9VrMi4SfL$R(c;?>7u}XS8Y%}2zx>1H!T$pl zLF&F7bwkqkC0ryK{oV8i@@+Z%{g<4%I?a`Q9Pxn1S|_yn30+*4Dz(Ton7zk!>N$wwb=`>v+6F0!NsOR&OYbl&3rj)p23Nh*pqbXYe>>*T%!l zwrzHb9F^0BzUnb#b>=)^wi+Tt?zqn5${$s|2mRqkD}HByB0m$qgA{Fewq(pa>LW~^ zAwUHj&V4*n%SsSxAu>!VaUxeKTxQ#RGWW1S&y$vbI6Q^j!x=sH$HUX4A)ihbY?E#A*SzL6Ff^j~qRKjv z*=kavq4y{nJugVQKyDJa++ndz%E)C}3|5*fO+gAqkop29Fdaf7v7cGI{dJCt1OBk!uVzFsicCOj7QV>gi^MS5 z&|`=_zR&Y5TLl4VLS-d#NY7yO*fB>ny&Y@I5v)6WzUm<}u^B8YSoxaeEAj%~^w<~z z3+BD=eXj?%_@z0@;Wt%zDc|yzxBS*`{T3ILa?B|iL#b`ZKRK)dXNqI$=U2H+C8f9}J!{7xuIG!A!Yy`DV9EfFr%5OtfNdKVxuJc0_wT zAOXf>tgl?T;>U10#=%(0g^31_%_Rep7wxgf9+P;1akm~I0uaxG4?g&=cfD&4A4srz z<^;4w%a$((K0}VmWrT(wmtKVYO4+HVfN4Z)NU5GGFJ@Dstz1qo9zg7Y*P|iCgNn(Y z{K=oZ;SFyZf{~Rr&z+W$m$$&eCLOW|$Uh!(Sv~K-g4EtV9)U@OhcQ z4$nzwD812AL)z=WaZMgRNX|9-Wial_~LC7kKL;Tygo025S5DjNI~Pds725>!m& z*hKy{U-LB=X^E2S@5^t zVsokUiwgFb9caqYp(oQg1N3-eTiuDsncTKyCAN->c;JCEOaOD9ab)=c3XkyxE?_Zt z05b7IX6{$=NH1{*xX12u4czs@IROcb*&f7?xx+-tpZS@e*{dTMe(4#;?=H=c+ZfX8 z0ffi4GY{MG((CyyHymBJde0`HCFW830yfknB2&xJ-k>ohFBPLvYFCZ>@4sK-mOx%r zh6x_U@s@;rWhvoNsf?RlF9pGPfx)IG_$|5(;0IWcYpfz{IXat>HMV%Tz;F@c)mQuU z`*Oikg6WZ97FDkGwmL6`p%2`$x*39GVja_qKOV?wGr8Hzg!xSYGN-`m4LbSmePM}#`ZZA$z zNmH_%^5qoMN>IuLK)Xoya8eMkApzLVAcGMMVFa8N20Wuw0bT-6-3Ym+w`FZCLjlku zX^Jdg=EF>YH)~G|ui92a`ZP$$^;YomK4s?ukw);1HjrQLP{}MYcM=T zMb_vG35HM##m@^QjIyt^WXd=RLQ+YztRkT-=7jJxcwpqksp8PHRZ>b*0AcinM5uK5 z!xL17K4vlvITw0EoUHWH&qV@x)9`R@wryY*6~k_4=cFSxgdyz#u`wx^1Uwt&PY$0? z6|nR+Kp9#6&Y5wg-+D6G>Ef%7Pb5 zz>4jxlp!QcdV&b^h?a7uHeQf01Qx%_kS9U=1ehk15tzKt&$Icj&6LZc%z!g!8dG`^ z6WX9;QwqTi!BDv^RVMV+Q79e8@x-8?=;00FHIZmDbti;eR7V~~RmqCMS{hPVs ziJX-wPANr#4S>=xM_>S&DKLg$Ysvv)+w`Tz1gBRFUK0Qai5_`+oEm;p1e=@p3*FT{ zBaxN1$n_elOyoHGm8Rmv$u;w2u0;+^Q+6}VJ6p8Ldb`w6v}Aiy{9YT05Szw}9%ri` zp?(&TzDSrKZQQ|(Q{akEX}6HEiI8hyC<_1{J^e)phYn1Cl>d{;=SKxBu?^5xSyw-$ zUb6@E^3_AOb12(gc2V+LkhV=dJTIDUrk_1&HrfmWo9!&;P$J~r!NRtSlK|lLX7w>R zj$nEdh|>K;R{@-D>|!wG2m;nbf=BRVJ->#m^aV+9baaelP9rLqqH=h$;vzg`@feEM z$7D{k+!<=9$OXWv6GiwEM?P(@nW;N12(?nkk)Y=4BOakPln9LCtBIbHMv9%`!>DsicN#OUU!FEH^g>Vb-=#0w%Os6<-S z01`!mMo^)m2?%HZ04kOD*YTmtE%X%S$bVUXaJnpxIv__iv)AaAMR;C)=Nv^e;i`h2*Kf@I3x~ z#K&S3rD;fvt|db`7!>ZGMZH@BuCZpeAssP#t}T=ZamQ+xz{q_P<(6A+(Oyd_=y#3$ zd3j5Cu@S=CwRDZpGI$KOe^B@DaS}zS79n_CwHt4|@q-`yAhqz8UAw(K2jGXKP`C?? z9LW_|T;b2;l4lpcT}X!E5A71KQOeh}GKNg~B#PXU+O6tM?aJ}DOV6A+!_Uc6T?|>g zS*h)QH><-u+WV+SJqo|T`mZ8MspavzbTI#bhy>V`_3w=S6sSvS31*HNK>z)lH)oxx zq5^qF_R@fv0;Rj>J@5J8?Z>1jwFSatf`)~^HCt?4F zWs3~3?xvJj`iumsDN}ZT@zkF!Ti!lySORSMLm$^)f4xKoa-3j3-{U(46oO4^qoQzn zr2EX1K&k5zqak+XxozhT|1X_VE5S>E2dNU&Vkt3I2IZdrK55Vu(d4#7sSdzXPEkTE zJ!~0TgaoW&c|42y_#pne&B*qbs{I>Q;X(wG;U&W_LG;-L^`!N>Ad8HyX2#ZZx1;pR<+x7`KEA-ixe%4rF(%2LS8c*s*V{Ez+Ej{#2z4aM?o zKCff0<6aT3z4lrkG|{6#witGK%#O=bp7Io)d8Oxfm~>55v@&YFcCcfn0L&45$xB}1 zmgckzjK>x!b~)YBKlzhC>8!5`OL}^Cw@&Mc$H$|7@ArPsMb*&S#II%My$(Vh#!mp*zxa#4=%@iyX&r+^ z=p1{ledHq_arp?qDI~yZro>iJ$dx0hPv8>6wkwtZQEW3La(`~!C$;lPmodz%Ty|;p ze%E^=DSXW6)dLNkmZj`@T*aCbUo7sAE5rJpZY1E@a^z8EgB-$y!U`P zgifqocyBNKpAG)*>O0|PC;t(Tc!WT(ZWhQ4yI#>fj%4xZ@DT7x9X}O#p@pC3WWy-%+0TBq z7c4e5x978-^{ja#l|p{BkcN-nKKjv*_9>s1cafP++n+xhAq-1#U^|-s{ znrpl{hO`UAUr6MTk9*wXoD=+Z@ysq`X=5NvAyZTrF%5yGu*=V=TbziTa*4{CYQ z9ROHj7&Xf5Q{LWad)*aI(*5ptzqS%H_zdaZ{N^{i?TPfice$p{{jk;ckVp`EtT@@; zsIhs{xjWk;(kxbucsz;T@s4*OUy29ln#Ryw>U~Cm4vD}e0IX$_P?2r+KbkoV{R1nE z@Ii*>QK{Sb10VPRk2j5}z-yaSyR zSKPheR&s0i1jy4#m=YZcc(2fY$LR^+d^*4mWT$jsy9OR-$zS{OgSe*{lK(x>FBk+G@OBw=%F8IwUGIP0qJdwC7t3y2wE%Sgilux~IOpE7Jm)#j z@y1J3cbr|Av3r`i>%Akv<0kc6;m`m4&(EKdx-r@~y{7ohG=4AVQ~{F_vl)*$L+ro& z%fEQu=DF+44|&K#{KPmSwXP}%Hk6}Y*T}=%0SE?Tcrg@+v-=;%bvu! zEFZ0ZZ}AHrKefL4)vxxC26m_IY$4K3$Xnm~)_&#Ds~i9HdF(>Wt}jUqFtnW|TflZ1 z<&uJI=L%S9j+QMUmXkUoHnD~fOK(Dc;TL}4iBEiD0#aMxXgm8s&a!%eVZaR(Zt}5F z!>CY#mOQ!)Pp(+(JeCe$f;g+hJB)HS!(hnnatd^^;qTCqRR8e7Fv`WCgn*QA>Rko$ zYdS#3CBd%Nh__|wPUbOmX7mfuy&59WuyieZg%fBQ`Gmma=do+XNo^ro&4%H$1sT3@ z%TCh#%8>x+;(-JsMr<=YkDrGsZSuypnnmF;z`EjEgdA1=C7#EU7;jcIhxjs%@+Fuv zKfLZ$g)^oKFnJA5BO7^m805rQI$gl!jXbben@`C zl0Vv+C9*3}PgEU}GxEf6wv?iO*Bbzy<;fY^OUB)-1FpE^!kp!;G#+Bum;(T_r;yoo_2aR- zB+nKr1dkKT;PeAPs%#m-R>C%KO1SDNMAXu-gHB}ewceIY!p_Gm|0Zz09M8-e7U=K3!7II#WG44Od?~l#}kHZc)Q`*3`>5+3ISs?!ql#)3JDKp z*ac&7Hd>mO*}>?nZ(xi9&0Vi@*+P{);XG_e17P@D2IK7ZNCRghOwQG;MxN4@q7HCw zGX3x4Wr?3`yK*rCCw0Z#65dmij0BfrFA{k&+7(r=#Ey&!g=BBgTf7+zMw!A5wS?rfw7YfxFL^Y>#GGLcoPbO7-LeCnRi!yC-40#!PSKRS zIa}KS&)NBDwzPBTs5nso9lug^#v(D#Kr%MAUT>D+0PzINhIB{t8Z*oQ=@G&?Hv)gp z0Z!r7%NQ^~GRkGHKjD}B#ZICz`00^_F=~?mvy2)6c8%pO`_39#F(P|$sfwQSb-U7Z zgGkCP&Gpa>UpdVc63o_|pU9_7{$*5;tfI^UYkn9}CFPefSjG^pjEYKL`U4b&A!7h} zEQe>1;~B%2H#fb@DUl<*;UG^3Q^|0 zOFK?ts8UPKJkp;zCzSNFCPPvqI_U<&XwWlc6txt5n^+C&6o=pcm!`3-tPdyn~g(wzqIhOodz&>6C`6J zZaY?i1ax0?-&H9f21o{gITAel_*GFe!{_H&-F*eFGT!M+YSXD?3Tw{1pyW}e($1-W z7vNc>4{_EaxxnJb*=_>#c3vOZqG}GKw#Nh9R)l*S7@)ZlcD3ydJfU-5FQhO!EyrUw zUkaxTprlaGsQw~X61@m%kZKKEhWn6nFF0EK2>WNJ;c%N1KZID<8-WVyURyL9tX znv6JYm0Cu&tsXI(B5%99_(>fNY#zhEr?O8Xbue+7yZ;%sW*2AIg@@f)m)HB*5*5X> zNx%3-pD4-)6D-<+l^Uk(LNtVJOld0vkMO~W1@M$v!Db1WejeoU7d4orK{Jdj41qe& zSOQjd{4h#G8$Zl6aD^uxZB^J3!n3Pq!to=IAF@a7>@sS_cpzhgbd98tE{u5+4IYL+ z4E_j_tYlwW-pxATpmwM_A#)xMmFbYcbTFKm49DgW-dPe7%&B7-bNu#3VS|xHEP#9D zI3A1==UfP|bmqOxoL*nR5LSg;IYJDT$wenmJ(S9Dxsx@D-HN0QiWG9aF6H;7oNe-zz@kOJHr5GOT?Z;g>*tXOJ2%b z4%>joz+iVNSi0H0aHGqLpJs`)ZgsA9Q+#eegW4damv-^Sru1#W%>WfPFG9V#$gwJE zl>?w_Hg8lB>ShgPm#cOoFPtB3^NR4om#&j{g&hLf5%QQ*(j{RNV1Q9YNmn;KKRjnq z=Kjq)zvZDZfHAa1E8xddY>9|5B+4-BXIs;G%RF|G&`EF?9>$2=Zmux%L=7)Bc*HNO6yUV)Joxs&` zT2GCAJQC{ zF=H86YWzzLkV-H^QaE_9Dvci|1HhJNZ*sVpYNA5Bam}O#8NK9>SUMZPnV6%Z_{1?Tb$I&9J*5R}~ zo@MjKpA=BRp1joeGPx9lqQPmm;|p&HH|o?fWNfYUb^$FICmx<{`M1A#@S`&CrXHS) zWmGJ-Zn;#EM^(ZovS17rk{7<%@>&i!8IYuM z?zSk|1)8fRwn8|C8>y<0NgBq9QKqq1Ij6jMfCVC-jvd11hnH84X(;vH5VSQA5scjccU>VcPbo!LnXjC74AMWyQ6Xl%)dl-oSd`{-rlHoL*h5UTJFUI5D#Dw zlZ|9Am^1zWm3h1bQYS@Xip`I33<7K!TP>T(R<;xdu%&sr-IC`nHONb?xsuHpDJQ!} z=#l5=p^EU#OGdql8Gek(4iHa?Mo&xl-dG^4)x*4-eaLN1+sD|&pVmX_Tvqa=BW9U& zm+G%Dw1uiV*HHGgSWEP%*b9LTVjq72CX5)7QeyZ2`QJ)^spJWW^VVi z^R99X+nk#z4LnO#UB5|fsi6*8@9hv8mhrS?Gt4lk#IjE{G-nQ$z!>7xnI&N`zx=3U zvpfA1-qw&%#cpXv8-Ot#K+Eo57)xCztN+XLZ$>x0I*713IIm9t2woo z=DSk|Txi1?Z~%H(cODMa7~X@?@#vg5nFa0*fd??#bpkttdyzS1$6ScVMaOau?cP8? z{^1v87Scbj>_u`CKrJL=R2>3`H`6orlrTvdDI}f%N~FjT#THUj8DlFXj}XYa!k~xp z)S4}8Y}yoIVy-x2i1av5QYW*sVUl)3@mx=9^WADw~G7Wo{-JkGaXk(TOc{5}! zV0guyq&Zu%H;hLsnZTtnQNu=nE3{0s6}5Sx6$qAKMz32ss4a0~>yE0q)nc~P7D$Fb zW2xb|Eiisjg(pKU%#}6LDjgaL{b}29bm_zD0a!9+Sz?QaX!2&}zmTW880PQkx zMtcO0*+2qQXBbsf_7y)c@i2BHCmwzUnr&2T37f8MF9+o?w#>piRP`qTm*%@`2VCxsK_|g!@jqj)PSkGg z9YqI5R0n1klgFv+t`7{~IXr~t2XOY#8Vnw>EqCX@aXRf@0y^-A_%VRN1F+3*y_SWn zQb;{164P94tz&};S1C45FJXN!D9)LOyjyZf1+$bfA)jG`cqNcm-pU*eqn~nWJym9F z*9g4y3qfwwE?ev+fL*t1tR-WzuyGd4fNUV?q64hhmdi;Rt$9e6unV!>7v6c3A=mWq z5FyOj-(D!g*ZoxB>G%Cusl=|W+OR>I*_+uaYnAqOi zF{rhL0h_t%=yw8+2t#Eo0jNIop%2}7RZryg?PCf1(Pp zwDqDFy~x86UK?Nf(wA~dx8z*f|MXA)FELBI)L{NDk7GP{1kb==Iys;C z#3%gm>K++gi};t|iMnLmojTwQI7ljT_81PNo77=DQX{12mB)*7>&fm2IXOr0{b(+c z;W_3uVI0Cca>#v~ocqF|eZ?zYA|?((GLmsy4SsKzPMc8#wwRD zEALU`c7Ed<-`E`rsRT1H9!@q+cc^berd&gOfvPWbEw+2AdDDe$&)vwrDRGrZN7%CXLS%&~ie0@DWu$?vbsmYcdvYZXfR_Mx-7vdSRf-C^zA=2?tf#a#!u-~6 z{Z@avz*}$^H%W8eEFA#v6Of=fq9^5G40%i1d~)$ekfdU!xfBk=Ti)`PTW`JfNXX~; ze_uPe!v66e|IwExKKQ{8R-2NrC>u+EMaXUG3%q?zEj-&S#O!}T^2KOibB{Syr0A4h zu=y$QhZMZ7KkQ)-v*qsqXeF)jW0S()6L`;i-XjE_0O6w8?mcbAQ~UTMk3S?CcJYuj z)pBApD+B|cY)jv43+$VJMNz`0KRw7b;mRwoyy>Q!%Ip60hYozVXz62-D}&6 zU;JW!&A`tVmN3R>SQ=@QJ+%x&;qQI#dwspBw~W4J7+z2CP}Pjt`x|*B75Mjm|My_u z@-5#YmWG~ykZePguM+k;N{pg-{L!fX=!Z^XCSD+zyjuG1cfZ?Tkm!0zo$@+NEo4r9 zwz+xHYRbjFtZpHzQp zgGV@wEC%ILcrW5h*+2d1PZOhWkWNC{=+_yTex9gTI%BZy^82cLeMU~RShdJVk|^D< z4#;c8-i9ojbIHEjbf6ojN1WNxVfIbL4|&K#oFC_~`^ZsYI6n@Izc=D-x~2aC z!r^fg`OSLb)U7(>m^n_}ybiV_?eXF-qKK8tITXw3*vn=4u6Mo5H|u+;a~d68Kh$~p zJ?KFX0zf4@m_XU-#Oyox{kp&S{GQ)^h+q4~(ek#z_o;%JG*sD91o8kB5d7 z3Q51?K|FGRL;S$>lrgD1mUyne{(7%;z9dkGm7p8=WwB*wnr|HTm%{3aYvc@gJ%a4& zlZVi|VO`ZIUryc6Y54ieB2mqmlId)VpP`53VbC#$0gqbPX~vTsVV&oxs~C)7&aext z`O0zOIGtgK`H_!&WIA<{(NHGibMA4Ed$1&yRkaXZJVds6u+R$NxJ$`HC?`Qeq?E~4 z2gMcuCxCZR*ccRJlLfvyrQoy`11BVaa6DDW#v?DVT^fp_0YD%R+GGQ&W2A*Eo$;DoVN3StfoNLxw}$d1P{ zDta78Rp`82kZAE6MX8H6Q4*C)EGN7f24?`_3{J6-@$;yTPD_I^mzdEN6zD?O1+L*} zspWB%c~U}H46bs8R1s%t8F-S^C4-cO&AB^jLYk2i)1iu13Wi}z`9zvs*(nDhj}&<1 zRWc8-r6@B74-NqGXbW!(l`Y7~E13tNgCtgz1H&ja24Db*bl6~PrYJVU94(_hh=mb| z3av8JzIdlr2NR`N9wu(UUQmS4V9?y7gN@&=*{%?`FaeA$aPyR~w2PDY z2pQ68NSDS|Y-qIsixsZ9Jta;E=;vlK$=4-!RbzGW8zxgm=oW7$v043-#d z4uGqGGuZ^&G9&jCr9r*$YJ>?dBMc8cdexIwfqv5NS_lC^ct}reabPgWj!*;56>GLM z3+I#{VZjWwnI1-CKdGt=F8gDvZ;h zQvH_7Hka6?BGFmF6Ct($!aEHWU@64x;>qs)V*q%xG7X9dG;B54wv4TN&jiMc*(@m}o0Aa( z0X#Ai<9x7r*gOnL2~e^&T567N2t*a$6+y+xkfJ+NL+}VeD9V;uR5ebEDpM3g2$)3r zYzEeWG-Ooc%rITz1m}mx))kk9LOMB>z`r|W5eQ($Q-3Tmd`d z#A1bC8lOEKaM7Km9ngcAC zq_%KE8ZZ(iSeY<(c|_T=l(C{#O*p=+Y9ghRg)sQ)MVL|QL7tNk5~gwx;>m-p(l~hl zw6P}l6jc!oY$0ZNJnVXy7&8n%1{b22o0jI)s70K>qTtO4Q)L%323>_IJHu`-aFwv) zY+1u0T|p)Ib0SEvxgsk(!N#UuM+c}&t8$0Qh9$1b7Rhc)Zd`2dPUNTncpf&b*g71X zN-CBh0KF4iue-E%)#-hrT^S9u$wJP?U};oJ1SWOh^wXm> zoTzYeZdo@kv;|JRz3L;8ky@}A5Mn4P58Esjrx|kZ*i^DTJ$M3G!WYZ#+&B;lk#y=! zhICVf=8y!Vb=W-Gy3Jq=9+G-~k<PG&+jN8I}Of zYP%A-gV@4JLRGpv-CPukr39vX3Jd@VDqAa6mZ&iIEb8QQGTbuo-JZhPVEk-GFCt=T z2!@}Z$89Tn+GyZ3t3?WI{Fr$lg&-^ik(PGL;z4Q|Pua~v#NzR%wFnT42L_m(LTmuF z&I}+Bm0fi~O2j_ZK%QE$arQhxf*-AAnDieaCj;0hN~MtGp%tzWTQE_{XxF8zA#5;- z($Mv)G?IdCxi{oUc!29e?vDt6@#&2Znpb$BP*Ob5co4@f9oZ95* zD4;f(6DkWl8yi4`cmRN<_E9o_-B(gu9dXQE3?D6e!V{k0myH;B$h+*a%Y29!RaHz0 zLTLE)U;lOg63lRUZ0S)|2V+a;9Yep)4I_{eLw38Jcuoq1I}M^N4VVs4fvZNhq;MK5 zFxhQcCd|xsJI$j?Ih+ulmEfGJYD4xQl zFbL*nR0!Ly<%1vmU>{ElELIAf21AH{to2D3%Lqwz0PcVP`v-v6D0(&l|L_0#XpuiW zOFyUI_TlG5f(pNRH44WGkDq=YQ=o?`cpeON8cxeiH{JBpKmF5vrcw!<*f7AD4NK{4 zIZF)T1|Dp-^omX%c-NOmdCidg_!08VgVmoc^pE}iNFIh-CpIceAJ+2`?}|c#L5k9G z###BC-T?JW@)VjcjnAGAbRHZ72h7Ro>?yw!TOS;l3{JM$;c=+UZjD}Gj&w{4Ax=09 z+tGDLf6do?O@6asiw3h#xjpS^Poo4LGmj7JxbNJ3QX6!dfeo~efBfVB;UE4Xa^MmH z6J{ARV}@85cS=wAZ~o?Q{{7$oed#!1RK&3CUVG*}Da_WI)W$jc=G zJ)^*o+r|96=RME=s1b5%KZ!vnF=%}d`%Av$OAvZq(I6IoyYG3=d!GLEr&k@>hiFNi zt_sWs79s_shhfh*03IQ=OHsr_n%TuNcxs3!Bz~9S*(EkkPME!V5`#xLF!2n|2Rz^b zZ+OEScm#%FAY`lk>Z`Bz0aG93=t2um=iU^YLIB8MU|<`J6~3*{>tFJ`%##8ayFKEF za8a>Tr+8=Dku^iQB+q)*v-~Z1FJoYq9^$sx*c9VatA4ZO^T=9^!Ob8Fr*7iJ&xw;^ z3145#hEjj&mww5MIs+S8UB=9CZOr$)=RNOx-~0MpmA3`({&M|w*IlO;gA7PBNy3Zu zck2CrLtY>W;30-8NwLx3Dsu9ejjnFp2tcGn1k2T3jXC`2ua@0L^Beh_k>tr>Qz9>I z4V;kKWY~g~j`K%;_~VzxNRH&|G$QdE(E7o^_sz4 z#L9QQI$)gB#>%%8MPAv#c(lkaZ31i8SA4}+_+Q)Zb+3DM6iKRBdZb`qam5ur!0xly zmO{khbSmhUX{D*0f^TgiH2kLkFA$XV@`tRGf$I1xLkDeh0>xVay?c?7rx9 z9qE!N81KyAqZdFBw($ZQu57UgzA}2&JIJt%Sd)8P6jg@d%%c_LLTg z*4^YDR0wSv0&j^;I6wnTQoD5$A(TP2Z2$m307*naRF5qo{9^G791j?<;Ya0P{ncOX zsUo$(hU|sr%fI}~zx7+c^?mPqpZl74FE`g-d#z9Sk_3QU(}YlBFuwb{zuV7Ue#lJE zst-q&gn7Im_bF_g^mlcLK`R~4H+;i4sKWp8AaAE-mk^Jwb~DVJ^w96HnXw7bB5;5=KKUjT#_S=k*OFHx2{MVYEBvAb6}{h;7~ z%TfS39&+u?RCfel-(A_naXOrq>yVK~3|i@(^f5VXliF7kv42e9<> zho2JYWM6sZm0o(gH!O2Th%>|QLfCfQEjV@7T$!>XH}iPkibu(^Pzb=F&=C|amQe~l z(GbW2`QdHt9cji;E=aj7<&EDZ5Lgx-LzjZlFOk;ihPQxqK)6^T3^Q6GwzSSqB(~uB z#g>R+@LL)M3c=aT`5V9S8|mS(G=tH=Db~_4_1hG}=<(Xm))|G6+$JZ*KL>Yx=^w}Cx6y`T0_puuJ8sMwX49tM%9s1?Xi z0+_1?X$c^(uJTwC2_R#Ew%Dk?_j|wBYX(4%3{HduAT;xvf8|$xMRO1u3Pe~^wjsoo z_3z3h;#qbK^3wP$>459*44Au%og9aO?Lc7pR6OR2tx@n^T)y)=zZ1sr&kLSooaO4Z!?GuU5aG{#?sKK!Jk=2B zCrs~VdReJ63gI_~%KvfZoe(AC*=rDB6v%+7Ud<7!8@4o@O}VX2j_pTKv`cCLsYU5$ z8hRZ>HIkxrqIfDil9e7#NQq>@9K+B4?9XZ)WF#3#jIp2doaeAzVlx@$y#oW>6Dx zBpKMm!Z7%G*bFn99)`IeU+{pLIbBE$-9MEes^Z-S$;e-Wgrq!Xtq^PO1+qX}6`0hz zWD8r@lE+XT3sI8x&Ht$+3OASqio-^R$I+3n3*!>T2bb z7oJhC)KV52B4l_da3X9(j*zolJenic%~KZqg~x0L1`{O_n6^_QBoF4nj{$GQ0NH7T z=P}zVYA}ql0MKK%6qc&6Ta7SA0&G>3t>GbFI-Uk9_b>)*@L+|gGy(bBGIwcO%F=Uz zU<~1H8OQ~4wq;)KhPgC?^fk!iz+Zlq8he(5whFkg~2IG?ZB2b z0iKQ7tb9wb(gB~P+r9wN2~Z+#6n1~41RU?eX; zyN7^XTLs#RaAG&{CmsZ(5XnaCwwW$8f+Wf1L2B1H#0qIZ4u;Kxhe%F_C1W|;lHHaA z;9;14%QPgQO4<1_@aTDj*c!XSx-MpHFjCa&H1G&PC8?2cvg=rqxTsv6;n==N9%|OmC(Cn5-5~X+eo=LYiR!HZh(;mapZr42I3?tf%Ln*o=!ry!5@*e7_B$~@H$+%#_=WVsDTFi6Htp?q<0|iIm&+1l*$rO;c6ZO|N$rwE z7Q-!S)*lQEGtA0kSEF>S-KbU>^<;kD0*2u+$IJ*{3R_(foLzkY_MEL1xn*xy1$N0q zbwzfOd0a<7X(fY4VB|c;ZdBQIgD_8`Fh}(4G933%3m{8vFyH%&W@A1&YN}uQ4QNU8#TxYALE>{=K+{>=Mj5>A)dFrFgt{fU$=>4 zd8powcL$Z-u|IMjAygf<0;zR_AF0eo>cVXo>@(4!E!%4->h9(hySqb#XC--ZW?zz9#J(?1}1Zj`ieoCaIE@*=Fs{HG$n z)4}S}8NH%>8cWrt!fh>MRIe>1Lw1I{3L&SKcxHKWfsGb zL!e#wBl;9M@3=caz-lh4)4+oYlG8!4>!p-)cG|JCYTe6!8E`_D2L-^DB^%=8SipDUkmie7V`e~?SM)pMA zWsSY%d?wWrAl+QMF;1@GJVN%8p9jx*gzQayv7An9XV2+vv^>PG`Cy%PM>ku@Av{1O zyPZ9#ExWr9;oTR#)By-I$|7&Kd4U~5GbQsj!b6Ob6dvL~t)Jj|=_MdlWmibXl9L{q z&9(v*m3Uhzj0bEPdlK7okDeNiQx;KXRHKJxtxm>CI8TDXR-(Y=&3TgWb7EA44K0Iy zrFlFms_vDVf3mZyw=F#>>YRyA4mfzG4^b!}^$ZuFV(QeCAr^*{dbT6WAf3lx9O-F+ z0oVl&A)^Cg)L!c;6Uac6ldqYm^#B4}GhEb~tW3Q-qR7W{ONJvXnuaI}aCC$d2C2W~`d45i% z=ez?~SuPgOiuNL~OjI#@8*`ot<?S`Qf$|jSxBP!Esqo+9JoNRIY1e~bXU z$+sjmcJT?=^KyzFjur6N=fmgBBE5vR#0V~XG z#Q+TB{*=W*XBXb?YNjWThY@~yESp!zS)yfQhVLnmumN0%K`J(?hPgYi5|)LOv^L7f zuxr3yL_HT$%ca=e4$P(LVg(rIF8=?D$MN%u;>PTDa|k^#j@M<^(1&h4#_?i-k-If| zw8HN`2ZjO0F0hd#cnkqHiEsPi0f61@V|N}G7xx9e&r?%XTNsj3NR`ntZIm$5lQv7> zdE^e5vr$OlC!dS0hd3v29`$JeBV%^&;s&WMoT7~M*RDD_Wo9UC%5fE3BT>Q!HoF&M zocg^ESxzAlL>t=^vN^lN(P7KnX{Z2zkOA0Ym$j|b!$aZ;86iv|oa|;W2E1143)@J^ z@aK^+u;nV<#a&L`6Ve{R6I^dmfz7B8&bj4k5x{J9L^ka>T(6Gln^=aM3R@>qQdIae za`ITl)-wX0bHxvnTC?F@2pGI9wDAPkZmuU=20|H2C-C@DiK0sn>`7ggHT8L|X(&5M zY^u=UXEV&z&O>${Gn>cgqOAG@aMv7_aA#?&rLvFl8Bnq;`7@dKjxMbm(@#4YN^^rO zi(Lr-aJJ_ra?b)B!Q1VQh^I$ur+vlT5`Vi4k_KbRHoI9EI2rO9-D{Y=Y4fsY$vJ@+ zXmnH8pKi}GCUO!8$ExPhhbD2%GRziNx6`%A-sxc)BQZ(d@d^s8ccDiC~&3 z*VXhZC%e)TRvT@N&Mv*q>!d!6|DqVDey<}dDRe7_8k$^&E;i|HrHm8It{0(Bn5tm- zEf1+541mf*PE*A4*RL+WEtvjqM*aYU84obzvaN>%9P$q{z>;U>pJfK*5*+zp4&@sE zD%P^9I};n~?Gwa|)2tQBwCmDnzVe4EV-v6)03Ic7Cxj!F-LhWs*MAYhv3c~c%Ys3o zRab==KS?$Vy0XT(T^0sru%CqPdB zpQP+luQK)4ysRQ5&x|2Fry=B=m=gl_X0OImF`N**7nTNF@~%ot$*zBRTmBvA;#+kPRxe;XmSnbg&5dMC(&f5&?{F-IT<{% zv&}7w>gi;rp+c%ofsmDXDtV|xNl_G|W=3TRqq z&2ljZh9pCOb=lW(`d+*KPoKR>k2|{<%~Hf{nWvfGpeL%FyLPepSN1qFunlvx=0Ew9 zKk?OezP7Gh%eDYJCH_!`@3q6w@!~Xwa12yp4W8cdX!W1|>7QP4#TCBCPsjKLrhx&; z_Kots)e5to`zG%XfB3^c_=7(fGUl0qG@WHsRBhXahpwT!Te=$tLAnI#?#3YmB$W^t z`k_lkP`aBzx;rIjK$Ma0kdpEn-?hHq^KZ{u`@Z*e9p`b{zmwU=LAh-KgOa(Wk%dWu zRBKtjzVBX&?d~xvvpnT=ZE+?T$KB&3WlZ;$bdy-hDmS7m*?)KB=H9i@T->?9oZU^z z>UG|BOo6)B%hicJ(UKsg@-v%|)ZcNk1De5|yDvJIns{R0u)en``;S-eamvQQ3F`kz zXYyQ;uKBxVr*&>7P6aW2_8O7Siof^mn_bKiSeDUsLvhG83ka2^xECC#;vj1Y1PUo2+Y=E5;W;C@ahjrbz3fWf8YUG;QV z|M%``7*mB&y0g;?bno5Qy<_6MSGcu$X6l;dCCgU8-=oqvE9xe(FTT!G+u!p``3PM=7ilS%qp)|!Wo<7# zMtE8&h`2ALdd`!xyJxm9r^jp>zG>MDx>S?4-@!xvEcE0%`L`E)$HsB8*8YNAqA~@7=Wvj&D1t8<=bm*kX=IV|Bv$VS+9XU0irMBk43#{!hccWrenJB_PBVZMnrfIA=iz z2-hNG(&&x7K0)O1GwM()8NH?C%;Bdvm{&Wz4v`6CdM@Fr>Sb;{(XW$2GQ%amuURm! zbe7w?BA8UF;C=HigZVaX^;I18+wgl%rbcK`1p7@ZjDu8BAqwor<;r(<^pf!VtNL}D zj%8)Jy974QAN#N>~WdeY8<)ds&0*+(Qs`RI~r%HqnlW8KbRn1qqsJ{8nz`BEE1|Q;`KwB zsnnQ7`M_;yh9?>j7WGA80>=(@*D#d{-dgf&M6c$S4>jCGmS;cFO&VslKLeDEem54R z?y5yE^#Ml1@%02aU6_<>blJoKEYqC=1I2ao{ev`0nNcP12ZznfmA8$DVg9x^dky>n z5~E46!j>9)r{h!q6*BX4a8>EEX)Q@G)bNWmbe`VYNB{0iY-PaioQ#~{EU@x?BdUUD zNCnfnrpqo3R!R8_BYbQ@kztZ*MwZYurE9xaarp5@iK5J<->A?2bE`H5F?&t-J&Bh z9*$z{IWzGpZahMIm9!wpYY1WrThfx(lj-Ylx>P(7Ev8gMe}i+Lt2{=$fT)iaYYq-u zFvqzYX;&HmIGCYp`F6bKIJ+X^_*shmSYp*6PE>h52sRXj=aeterC#DpO{Cn31{b^d zJY2@+O|Zqru4zY!3(0s{t~CviP?r_HT6I=L&uly+0)(r|^rfDGP%<#(j*dtb7JAqB z*B6^Q*@?b((Y&>>M_v|Bm~pM!`>D6-t5xn$2PvMmU0W*bX}ftOPJXJ$#1CHy_L}5@ zg`|E(XKeuozFKlNYR_XbVnjQZ%YtMh**BP0;0!0Bu!fceTcy6fL#C3!dkn}HCi!qN zepMWjW9hXH0BlYD)J@ly^@H$pk{ydTFq9$0nO0w#bOhA zTsji?rdJ^Hj(0}wI+w4!VA1;*BJ-8inHi>sFs+-yL1lcrzHA^8mr({Xvr(g8nwsX< zHv2FZ!=Tl;lA!Jj&aOp zWK@c#xJt&Ys(bx~qWr3G_VjIBaXKy}Jgjq6{)Dxfe#(BkWXYaw5hvuq-DFf1u~ghp zbkvmMc5D|Z(el}{Xn7-Zp!7hN)irfdj1+>c$^Iq;>@LzpgpcXQp_ss~b(E0)>)-W- z37t|2OhaNp_pf<(+q7O@4O#lsBy=yzd>Rp}-!wDFUvh&Jeoh_E%AVfBIPsrWoOLuX z9$v|bhl1hRW31VV=W!UvsBS;w$m1pfSY3ppA*p)r4-B9C4W5esrf2k@f&%O$@)&Du zOI5JSbZ``@8nc1Q9=}nq{P0^s8BammKmx?`4N8_^_Vk5Q_sJNp-f+g>DV-k3en&}e zk?^~P!l${|=Oy#JA8^jpbH%3kRZAQ|SGGcY;-2XODMV34C>PY#5X!h8C!bmfxt0!U&2M*O*er zER z0dLNS@QYSJxb^%}N>|eG#lL##OLM~Q#48Ro$5&0-1?64(k!T92?ukpT$YYOAD@@b4 zMWP6d9fpC0ddef_+el^-%ARG!ye!>28)03~n<{?OJ0v36ft;Os=Y$>D;fbBW$&|h` z;d`JNIVp32n!y~mcVv6ZOiy1*o# z|AK93s;}63{=E2C|7qo6AWog`%t9G_*wnNYF3?%R>pKqm4}|mJ0N2hvc)mMqsm=v#zTt6bLxsE zAUg0VggYm6;yrQ@!l+PFF|a3a9~ib92t{KB=j@|6WFzgS{*CF2jv?u8>hqh(?sd}F z1UmSRtO338-yzx0Z`x;TqaD&l?e^!MZd3k##Yi>uv-$mTCCGa#J-)Vx!~RuG8~f1N z?)So=LQ}P4UqBD4)vk9}ni#ENUuCa2@oB16ijWs80Ot=NmYLq}Xrj*aC^PP@_MM7d zTR2B&9@c6V2{Ie}@T7o!^O@qf<10*~X)?gJQ&E??xa{byUC+fUWqTTlR=@(C6hT-_kG zT4{}|ir*>7kEfF>mpi!M=I`A~Xk~L!vSH^u_Ft3|!SU4CI+JZwF2cPX9t+!WG`&X(0 z_&3i}Z1KFv?XVvms(%?Z#cVE;m_uhPgzbveci$|(14!;x-V#Tu``l#>8p`5qg!b%) zVy^zmT=9Aoxu@-OG{LD@QnL&-oD=F2__)hEOB=`Vx#v7byHQ#sux)jHGMs6~@Gm{p zivy0mfBy>#=~$_k?EqNQ|MFmv2!kxpwMC;mfWhj~9{9QXQM_MY&vj40*0uvh;jNTS z3yQ2%xDA$4)6O`r8AvmBP{#Pe(_|vvssuIwrNxIwhe6`F|Fz4RU!Z9DHnZgn z@eR!q&a@{z9EFOj-{P=tbQX0RNFKRa!biu-zo6~a<&f*h;bjKqqpPB|Ln<2VFh5$_ zFvvCJ>R&MeFYadj2=F17N9y+^!SV}#frq~L$E_=2->Bb{`V7!WS7#D#hSP?k9^xi7 z@5%1JuC5>V5RrQ9Fy_Lpo9@%&jC9Q6$0!Aq8(&$I0&*P3F{d=a9m);>6QnKs-W$O<$4C*UFMh{w9e>L=kFZUu|D77=s zelH1o9hcz8iLF*T)*W@OApR9Uta!6lFUK;D)d^kTYH01*%KOz1P6SqZ4-RswjKF%n zu^jwFO@S_31(M&WmPYk4898^2<;67#1)4`Me1-czx3ydmyJRV5BuIelPSeHLmAbFL z5)@Dtyxkk`qYL-}eG{oqouUqifK|w%qJf>;3&6@ySb{toAc~a{IQU(E zgU2fGZcxw6(xM+jCw?HYRFeHn94*&EBc+`#aY7JwR>cCJa!(XcVFSDj*fYd}qAf(A zw0~hyO0|LXd{dtA>G0Bsb`+iEetqjRa+f?~cbnnl@|zs6^Fpz=Ex+SH5TDr5-Ava2 zee8Pcif*=^YMr20j<30h;;^X+3qOs>jQ)>Eo267qRLkKgz*4D*GAoTd%Aq@obaF4e z2QXK+N;Fc(N@-vT!DP9=t-<^xl4?g)>bS(&3b%2*H2-;tsh9{ZwbFPH*l%n(e8frL ztPBh!NSa7`nS7~1C&rZ`)j9N4P_^y#V(iXRXkesK zPeGC26qzEXDXkwMF5cM|9()Z}sEcC2y^ko%`f$f_nb^PKTM>l=lSpZ*H7-5m*t&_= zipO|kOGHV~m80&zya?UrAU)34LWE&!7DqCN7NUiPFCp5BJ}K1cZhUeImW3kCcz%=l zojStMfSBBMA$4Ze{$2$?nrc!PLxuEDCUHTcndrL0K(p%|E61 z5BSgpkbWQ~F`iPtwCm%i;-WcMS~NBY17+7`@U1rSgWHeFwD?o{2mjmB_<@$96bW@& z?7KW0LpSUe4oEDmBJtDelMQ&QczuU~=tr*>=Kz9)ITP}%ad|fo&N`;FeF7r>0po{h z)Lai9c2N{@2(gvqOqU=d=ouK$?EG*^k(;sOc6`w=h@%_9FS2IgZYDU%Nn*3$MB#M; zQ28&`b%utfK|riZ_^mgl_CcfHpMQcIw(p%G8pNi>xPL;4FV6>(T2;zG1!*}&XNj_@*J^0eg=tl33d)3<1X zMXaQbHONXUVU9X+wZPcKOqN0XVpZLFIL#E*eyWna){YaANDfP`$1NWX;;srKJbN9% zCzXpK2E3^YQl}N9%n)dh0Eu>dcTRGm4g2OvyX*Tpv1(th8IhgT61gOvZZzt?vM~i1 zBk?j-g-w-H@?$Df^V#nsM+;XC8m`V-9%9c9l-%0<4OZS6)4-+zGKLzo^@N)b_Xf@> zTJwjgxR}_tXhfUdQtDxo>AoacYN$ExoZ3A`7}3ZjD0k!>sZ1rSTUjP(hG<7;qrjqZ zHLuDj8f=s6ygyNSgEiSf!b4>*1ExW_gDI@qmVIibf={g8)qTo-y;XTW!aTAFa)(OF zR2YSqdtwCHc#(oJVc)o8K&%+VX$99jFCemd{rhsXSsD4r=SZBXZqlZ{kAv(Dd4&rp zLP~1(6B!oxk{mk3{D3M7XQ>%G=h^>g(+w#GlfJC4BdB!Mwe1=kDF<|YBGX4|V*8*m z8vQgrt<-U&_cHo~!1)&^yyhW8R5P@-+bW(?QsgOJn$Ah;Mvt04Vd(t*;&WEozcJZs z6en~0fw0ERz#&MWIUg1OOtv=JEPoZ$Y`0t;Bn%hE;L4A+PClNY0#v#@q16K;&#>8j z^g%#c_l9k{KaG(zlac20#E#F>ZQmY$%!+WrtOr6X#PgWRG|{I(K_6plS2chA99zdf z8K24Mb!kez%|{(k_#5k_HiE{f6uo&ryM}(#fQayl>0-d^ug^pqbWKnM8$BF5{3QWs zU-~LGBl0`6fD_d{e+G|OuZ_lYU1N-JM*gvMS$W4XXlsN%65N=?%B(4?=V$DXttQD% z3_w!~8R8t7w~ONGHFvBYHUX6z3o?vn--8#sRaH#_Ni1Q`Q>n((Q8H)(Ym8fSHgRtb3TL6<`a@%-?2np9?gM!g zOptZirKaEzoIb-CF>mNj!)ubY=J+p8#gl#9Y2H(q!YxH|(e?z2mZ_Hw zR5Sau$P4vQUE?bOcv?{jcfG%XwmPfc?DnEsOmsiS_~$vXj~PMQYK}Zf%;o)n>`6fo z_5(EO3`L7QcsSjBcco`%H)EZK14{N{K~iM)uz7`a8ss0&s}Al4D?fqk7RRVRt-`DA zRN_YyG!O2{nCx6-%!jmTMsdze6UHCIn#sl@+;emT9>I`(S>0nd!=B3;%)^p!IvQ73 z<%A3T?-dAM71gM4!o}c$=s4$Bzbw`WkLCu}E~=>qOez<|_5%n%^|Q!Xbka1i@;Dmj z+jHZkx&){fNnS@&CcZGhcZ5&p;*=lGU}M0HKIk92a5Nf|0`#37GWHcC0l6HubUS^J%lvm{@@`5Khn<7JgJfYUcweHw$ke{=i>z$gxIA*w0~S zg|n?H)?kJ+-8GwzY{dB>*&W9bpzTQe^)h_qs2#s}NTx>NNnN)aUHE|5c%2c{v64jz zH}lvr7yPRSPp~*J>fDWzt8VtlsTJH!wvOBZH+wB|!ff|rtjL%e9p%uao8GcE@~t+X zuA2N{*OpRitN6u;Yw%s*;M8R51Wm*fKSgX-F!;)9s4BqFDo$OdrE;KgFQmFV#M9p8 zp87q_ECC<|@JR%Wwc(_fH6m`*?nODaX{)wj`pAnSJvMI2M5_v;mCyNtWcjZ0=v z4K~!e|L(;K>1YhaA~OK99wj6*^ae(PJ1(kz)`Q*_ob zLI`gkuf2?4fc3jF0}3;qTcL?Lk``Iz+}Wi7nW^k(&n>U~SAI?$eM=$_*TYmmuCBC~ z1t;bguP5uq1h)T%gb9eCTeFyZDD4VI^g#{I%$Bcz;KL2HgPv0DN}i3VJHNhYa0ZgG z&hpcOq#H%7*qMN!?2nM;y^D6W5VO&auiGDe2)9OU-K8~K^C9czf?OsP%6MAYlVH5> z?QF&TKz6bgfizROsg^XxCW3_N-MX$uZpj$sRwHAzpcb%lBeu?d_QttCPj_sYO8AsE zd|r)S#&s&&SgL_^Bm&1z-+%tK8MFQ`24TlDgf+EyuxjI#)40&5M&N9L1%FO%_s05J z;&vq!XQz#3Q6YAD(X!^y?Ebvb{;<}2i-Ho}#Xk~N6_k{^OYK5N(+&PbP{EaN0Lh#- zMGiYw&#Uk&-A*+mt7GQrgfxY(`mGeFzK<_y>iSth62irL!o3+GS#05G@8GIXR*Ey!2Rmx1uNAlHdQr7#Vbn&&!T3sJ3d&&`G z55gpx-Qa;7mk}GJl5fK0H{C$iz|EHlXDJn=}$?RopbZL=-@`1ri;^9bLZS4Gl;iQnLxLjQoQU`091dy65Wj`s!cu z9+Jm;?`bO+KTyf)P&Y3Bzl?HG zxop@kCq?Wzs!lJp0%cltzdPdonSH$}8G9}?Xf4N{pwO3U0f|m2St^&|`h*w1u;aAu zJ+{x1M1HY2!q@4hf@!D|Uq;&0PK$GYKYIQRvvivMYkK*?NjM-V(vC?F*q{p}9uyt; zUEw8#`Q>-H2jQr#w)JN+XB`R=gF%`_3(=!%(Ny^jAc(hV`!k>VSLYQ$klYpxU|rY% ze!<=er#$9AZ$^D+xcr`1*bq%mW`@vgF)g5bPDi*(4PN@`oT_(^1D^ZV>(-Ie-Dbu6 z$;1T;aBpSuiGZ1=8z(oUqWMO%6Zrom+Z=f|j>GRH)j+!x*w0kKrG2yRI{dz-%_R?K zPIHoBk`2zR0mw%4fs92^w0hFh8~h)sq{gTE@S>2P%B-$2To}^2bV2-_r5R~PzsJ_5 ze;}&WG$M)Yfs~V#49$xJgDl?EV(c+c${CD2GHg^@H;r1)@}V;X00aemGSj&FNEp_f zddsy;W)}Vc5{Q0ZVgp=6U$no$T7#nsP;JI&!eYEt*Pmo@8YmPb2Va$~mEWj+M5y+| ziWr|dGhZmk-|6+j{9knTz`oZ8NDO5c>=hJMy*B*QLlBZvb+YBKXr?Or*^JR=>U-dS zBE9Ko9+NOM*TN#|y%YKm=}Zw6Jt=QW9>4K%zZC5)=9@U!;OgGdH`WWxh^ZxA&yY3=P}Xlu0K>ggu|r z!jCiHO#I7|@UW$K<{>N6o!U`VLltfL8el@Jfow^o8?3IrlR5WQGD*pIe+Ok!CLc~zxhA+I7dc5!bE4Rf^#(q)8g`T|B)CXlWq#@>y-|9Xe zp}%K(1VCxov3ESZL-SA3Z~6`xm<@{9B52??d0r?fukG|`ydR%uaqZz4PYmaUrC0kf zd33F+O-Rl|kqh2NUDS&cWsJgyEw1-DERvu!Wzjx-wBOE25VDGph5R2S2?52{(`SDR z+d~htj;dy!3nuE3D$Z@N5aCO4BWa?DITT+};KpUJpP_wVn|guC3EiZTi%19H1>!o= z1G&-AL|I7UJ^QdRO?S{$D9*m1VUQKFn4Z=j{FLu9GY-8u3Fw_OytvGDe7;FL%ivdM z{CcIX?wCIElV+%X+*7J+J!yan`^>@ST4aU?x0S&N)Q8U?!#n(bEi?B@J?SqBaT)!= zW6@qybOz(uzUA9D(0gqaGC~zI%Jk>Cc>bC)kV{ej5>V0Z&2!0y?+6oO+A6eSj|BPJR;|Eu{XrIoSu$>=8e-&Eaz1km?Njjkh8#r>(N zMH^31j{EIvo|vu#@WOlXFB#7~jITkspA^H9L#e#s>f8S^M{;I$MI^o+E$>n3F{~dH z&i7gAN9$@yirRBr;||s(*W5}*16U@-I9(*2t`EOg^@koBc z-DDU9w?8DGJg*?wNyOnau!OXdvFlCAbnr2?;=1{{*S)s5^|9Zo2sV>l*=&2bT1^uW3y|dD0B;r2k_4%%p0IqZBr&ck zzFtfC3)2}S-*-8_l`)s-@pkI)xCQIef5uP(o3jrv*dba$5bWva-^ZWp#ogUtcLOu& zUhn<4rf+4;DbRq14>svSokya!qdX180U8IHDPd!!^2)j~OOvW4{E)Fn-Bl@qHFkyA zykAFH${u#zrr)Jdy&ET1#~(=8ZVp~pQoVLE`%n3lSV(0i4*-oB%3Jt`OoiR_s37GAHCZwBhM#&PrGma=Qx%9HhzBTgX%o~Gom*+gq^|E z?ej0!82EU#(yU1-h+np@c@}k4OC7a1CLbxn0GJ_CK(iPCPoU)9Xbb-Z#m)*^y*nR% zpD$*wvTU$NibQ1-BdArUa3z`@rbU4_ytAhVotyv33AS&Moral9bI6Z3e7Rn){wmL= zX#=&PlV`e`ZpY-pFz|r&{VQbtw_6yS#PpmvScOr&i!vT^|wBe(lbB1{t$1xQ;Uv3;`XN zGDIwzb{`ne(6}hO!Wlm=#55!k5nGa<{Fz<(U|5<20Z@EMiFtLCcR>m}+)n>N6KFduFK2e$4Cp8EO#hC@K)dl9cN;YM*^>NY$bg)kMcu^ zT`WIwex$7HO8Qd1e<2H8`gEfC7%!>^eUQ)Cv_Xb3TYq>NF^7A@DKvOaQ+r!DYf~|q`NBq4I8mvoa_8D> zLxkwZ0m%ShZikudfRpTXDRdn2hOrX1PL1U#aCAUCjxkt)3&})D%!hvt=zm)_-r>)~ zYF1NqIa|ZkZ=x^yz-5*%AxIcWb7?R~%umV-46;%W8d6jBSdD1Q+?Av~q(y#`MiWc2F>yEc*h+DGnU z_TizX9Q|_u_82i{vxH1~iyX={_`FZ-7GZ4PQih*`4ZBPchz8Yv zJ*mAkdIg3SrN4~wnaCJbK$eS3wznbph#;b?DcQ>Jwo1M5p!07_E0ZcrVkcH#ra764 zqo5lW`wlq@zQmE`{8lykFEG%@*3LIAXEXRf+A-R@A}6OoL)Ca68hM~%^MwW0w7&&~ ztDzDQVU$P7mWuWYzonUqW(rhB!|@`4v2Q|=tv=~Lu&d@QJLaG;anvFh5P2MD7H>c> zTlxGBm?dHvw>ZE`8>2|)@c z)>4#gfFFuo*?DiO*-a9u>2T^LshypO!vru>1IOE&{&f(+FhXMmDr4K1Sv*fkb^1-85QFfn z!bF&VNLxf(D0bkk!Ou4)1Oq~%K?i!CnzWSzCW6fxa8T9Od&8_ZXuK8@-?-j#;yv$8 zYTCewOZdVPHTx`HpS*$&kLqg<^N*0L!4X~5Z@&*)sYJ2R2JsB}6~ZQyMl%<$maDz4 zDe!gy5o;{yxG-5D+=t3+-t7-EMmbxbf|pqERyjemw7+5cL{b)s#vm3CJm>gPRV|D{ zka;yF=+a*_n6b{3GcIx|cjWR=oRIdr_XBEj2-=f`%f1k-mWT2-0-@07(TVK%{D`qH>r~$;IHVNa8}sX7me!GS@beZjvp`l8FWt z5U0GGZ9&ine;n=$(k-lh>5eC))#y3IXR#4+A?XQb<06Zmj&?PMoPZBr?WnVGuww;M~#J#y|{0 zs&Y3ZipfJh@>ELTfe0y^qUU zGMx_h`#(22uf#P@f|^H4BejZ7HXjE2s9wh@^gg$&r=$8HqeFn^_Lf3a+LAh)X>cYV zK>_2zZKaS@HDHw?CQHc!G=IEfAt)7FMn91Io{ag;)l4$+u>l7!$R zYYgLfo$<+RSxtjLi;iwz575I9pF90@v8IL1K6if+z}y_}uB$n)^~<~atOXBcaP|9t z7r;?#$@;f^0S;_{ECPu*j!-#?qEw0e6Y7RN*V}5uiccDj~+NmwYJa=Q6p` zjj);U)$|S?aNT+Nh3_-A2&4^J&h!14ZeYxB+Jg-KM0EDUV0L0ZQnYJd9%MoO8jK72&Vl}kb?#-W2a zOv`P>Y%Dr+1A%oNIJVtYdHWnGm$jpi6zbAS&FKTp&f=CH0Z&T@7Nkj3<+MM@2xfnO9<=7H5a^*3CIVZxD6^hXDZovhk5^E*6b8H0DH$nHWU)9R6lA zS*mH57Fuh364FXM|VRXn5Z<*`>K*M=F+kg|18&&vaBksJM}zcrG|S~GjqTq z$TBn)$SOI&kAZzQ8nX!xXUSIp=Y9Lik8oVNC?-G{F)e&^720A~Y zlBfiS5NGD@;f3$m--qVrB;KK%mQS#@Zh>to?yG;mcMpo*h@sGqGUk46Oe$;roO=+v zLC&iQWuiDXEW9(KDgAxTyFF}?*h_xWaj2`#HSj;8D{L$I&4yAJyHd)t3C2Fxt~efv zu(m`3!LOgKJZSJ-0EN4%CxJdgBzTU?i&ZQjlW(;^H8yqU{IkFRh$JgioJDfr;4<^( zo&sF=d8Pa0?HDS8-`Ceb5Yoi6xN56RTi_fu(R;O~R~oCFv+Sy_tzRt6bMRa8p?G^bA7j+r4BdpcXKB`od3hMwE%o64fCp5= z{JOOk7;!MG%D}xi{2%j2x(U}V4+Om9My(8}H8ID2tjYXWL@dYahy!F| zX?2HNHzm<*oOk~2{0}-_nRWK#7`yZCpxV3hSaL%twG-oZj9R$7z_c1_q4nsPV$~ zKLJRQd147vFYF@2y2Kf|izb;g-SIHV$`oTv9ubZTrd)p$2gEc53i{o3y533W%#P<( z;Sxm=kN3~I=aKUf0nl2wqbHoPQ~mqc+^N16GaGP(9Fga~8Bpqphar^bQ7ws%k64!k`+ zM(Nq6oP~cMcJJHB8ttk0NR_69i$ICDW~C|5Niqs5`ox18rDL{YN%Kc}Zji4`(K*Zz zToZHNhgsiT=NZYU4NT=`YWt>7M%~ABgDHIiYIE%L?E%@zN9PtE$E}90|W`z26@Scn2ry9Rw39j^T~# zDVm$e^AfhZ;2Te!fy%~agtY;jW#51GGpv`RT$v`U6JX(Pp6=_EzqcvOe{V-K^s!73 zfw6F>H|4*H`B0rUuAIu=cy36szJD6iJD+ZTijcWKs1}$V7}H1%$*;~UK+qpkSscaT z)KIf`kB7E}F2Bkz;)mk@{6v+S+b_-OzyL_wJ!iNp zlj_DRlO|YDX5fs-^qDC|5@{?ww$@0U6{ zvnK@l9+P}K!O*hulP69e0nWg`4As-OLdOMTJk4^miw3l~5ERXc#N(NUmVGG230k_1 z#Ji^0W{v})x|#qRN#^_z4~IzI%y7CX_Y@9abMw&VE75=vl{94Pt2z^++T!~muIFjJ zO8u`P^XB#2sG-Q;?j^j@Ub5gZl6WCD)1rcXJU8189%HmECT{tW_PAH_sNos+w>gtz z2+CX6edH|EtX=_K}>BPC2vvY^GOQJmTFM%2?)p!Vonld5|GmhEK` z^&d^;ud)KNwf{rTtl2{u)tMach-6v%6ywE2p$ z(IAiNG73nO#iq}?SDG<&sNCfoQq43_ykI zN0cu2jjBSlj|AsjhcxZ#V&t^K?))6`bJxi1=kk|J`7uZ%e9OH>x&T}gT6!uQd)7_l z<#Ns}rd>5^OtD4DJrsxrXCED&(c=0}p@0zv;l)TTWUB8B6aVv%@QW74AUBX6g!~BJ z{p5w_&|i}Kfxj5;Ryz|@t_RuV)&^@@Uq38iPVrDX?45l^7n@8?{3I#4BbhVJGE&pS znOGWWX9R$FS!$exRu3lgKe%zHg;8{*RC5rgKg1qUGtph%M#_aj9I6fYlgdqs)q=IbaAfg=DY3>fhuPekU;zT5eyw=Gu{8PD~IYogK@&wp$E z-EUSN8g_Q|fnrTqj0{3@OaJp0!bKMOqwH#;AA?xT!J^3?WJC8Y#L2(8=WKwW_+zT@ z77x~%g5G_G!XNUHM#oW*7rM4eRSf&0LDq{DzZ1m?nprR6`c|_b5GMI=l;{eVW|l#$ zed<3D^vQl>TIy$wSJOY4mq?73hC>lu3ghJ_16PWfIZNOb%bVwnWEu@&7VtzUoUA{N za=yX59XlH>0~=d&NQ~b~S~&j&Juz={W4kV*YU{;lzIeqg0c}|0pYE#*Y82QHA&MX< zA@oQvG+6n_8WyebJEA53JO@AnqhiItF8INg-Ilm@T%D-Wwr3}$h9$8Y%oPFSNTID>%urs+T?<_ zYRQH|2)Q;aeAJYY7d`BWS7!GoaS@Y)PP-sTkfW&r-)-X6XOBMVmV;A97GkxsBk!${ zUj#MM;6OmimFViLHLc!M-tiH4sVgj5Ksc(GdHiSlYNuKHDpq#|Mc6O$!mqc#5_7hs zwKJJu%tAsPdqv1Cd+O&iAQRWp<YBxd>2ZTZlU zIg$8^Nim7TjtTPev)NZe#A;1a!$Pe*1wegqH^9k$8Sk^G-OsSvAq*iGfd$H#pA?cD zoWrU1f67AaGc>k6*>YV1n>2Z|)jt;`h0#z>VoU+}9H*d>YtF#yNh3mdP0z@P-9$f0 z#nBy6pXS)z-%LwtS}N`scaLgTSke})$GD<4o+W>IQthWg`#79F3JW1&o2}bXJUdB6 zZC!x~9s-KtzMp(=%3o)M*+y|;^g?hj#mc#XHo7%z3c3#bFykC;16EL`BFrp^DMC^n zwMM15Zvkte@oUi{E{jFCD75wT&ox}#mgxro5{8aLRB$idpe+XxGcZ8gC6TgJCi?N+ zh+$#(|TwieYQ&0C^I^X zSQf6D)xd`x^q<|!O!IP!@D-E1jr&>FLe;%Ot^Q54nnQS(j6a}xCxdI6zd^fo`X!!4 zn1&(Mq^4r~cR`a2T^WkQ+$_^Vk!m|5we*q*ClZBjA4wV!paTt2tw&$JF^&giBr|X% z^~*Vn=`r9_!by-!JlgBVtVaKkMvKZer^6Y3Q_4Zd3a)3UBgWUIIDtead{p}E6wKQO;D$Vg zt@#vK?WIw>owy{iW@%J^a|!R{B|wR{zF^vs5^Lny_gCag0XUA6W5g^&uX;66l{O9L zuebD4%pqm~m<#^YEAOu_eSc-xuxcB5J;5z2QXyYxJ=7Ank za0>+3obdstAn62lK2~ZnXilXY`LSyvi?G=v^4o>$A!jQ!$fJmQ4gJbuuq^XK>?b=r z2o*gBO>nno(K{P|`)f)4W$kA3Lx(-7k~I@tC4owiu#!b3e-f(3=2&S%7z~QZtXsUp zS8B#OaK?;qiX&r+sH?a|T*aJK6PwLA@NDI6bH6R?gm*J z(TlzKK-S#1M4gAa`1h&?qh=baQLbY&^pFP-mac@7#YY@0JEK%eLb$eMISFtW_KuHo z3}XscEd91T%anrmeI(MJhR)GwTO6Ensoc7f`pH*#>*E*6=op=B2p5wxMdq`*|5W)z zhSJ>v_MbyyHPJpk5Gt>+Bo2%hOI;q>3c9LMJPGF;d(v|Wm+Py637Z2$ zn?IG}rND~O3wd=3)tJBZu<96|_j!M|G)vsp*Oa)7;_t-n%VT<*m|>sn)|pR(&ajQI zqYN$0#wwS@R*3O)KooIDYxZICN-MM)KTlg2RHhTXL`)A@8ddkDln69mr*^}PJ(xme zd34U>l*Tk&iTj(3TxXWaC~3kAmdQL-*#{}oZ&7I5Z?Em3)aHGi0EOzTv6_n9(DaR! zTf*iy9-wNqe6#u(7thi!Diu{xmHqki4QP8=)jW1rdNcr6c^Wz6kS2o5;7p#GR3XCV zA(r?rVR$s5fO7)z*1$Y+vRXw}a<71g_!Ta)8pcLN&mqhxJRbL;EPJ^5O#&j(T(^AX zkERpSfQGk+49``25n-1ZhaFH2aIEfC1g6cRn_?^GbMp!-#sd)pgnV&UePA{3vxZ>c zr|zJ1DGuGI|D)-=!`Xb_J{)_+UcncQQDVldS$l7>XQ*9DDJ5zZv1^Z7wYM5Es;Jm| zj|!z`)v8fi6n)d*alC)$zb8kY-1l{TKIb{YE79lzzE$;z5HuWuz&D)j*PXo?3=Rif zv@MZe1(JtS%B^r(j2kOJjuOx9vT=TTW`g6nKw#O1mK*G#8K+=@kv_bwq`*P0YDJ#~ zU7@iVFS>9Q9Qj=BF$8%UI~4CKA;&QahFZIkpjdwu9@ks7uQ_K(XxHPI<8)Scy)oAq zUZ7F<<+5#XkLM1a(<;6{AbFxa8@Mbo@`fE%?0BHo5s370FZ0-!E>Aj5v3=}(1;JTb zBr9yTAUG3!_Ovl5!c%&QOkEvG`XOr>4sg(a`#l!GY+``f+%`9r#Bo22R>eq2V%%q7 zRXC{Z{jB~e1C%2tQOf6!QJubmg8g-eR@0?%uTwt@@N{<4_wx7Pr9)jy#W+dDSV+ik z<)DJ`0AQ{q3GhN=sC#O^YRgx$hXc&5xwIo49SWhKWzEnq?TzxB7 z9=X>JJF_NpNW*Q37n=TrY`S?+tYJb%9XoGG3LAek2=mPdJuoD#CZTfE1o#4K#PzB0<$-O?zx-z0woyXTMS{%t&FeLXPZ z&?ec2kD9OGD;Yd#-lMJ>V0fS{;A+}N0|ENs!cm%6R-5^7@XtELrWc4C-MsPHnm`|3 z%P&_=OCc}|oR;Vhm@(*R^<3(aUwZGMl5|DwS?+0V=wP}^mDaOD;p=P>O0V*U_LBt? zGDWOMzx;gfNOJS2IXka)>nm%aS#S2!FXb|}$qW0gWj$$Ikknx9LkUk?pgw?LGeTjl zEM@}>#lv2G1yYaw65XG?Fzl4@;fQc@^9p%=y~lOLz>sX6NFF^a1B6Rx*)ryeJ+y7l zJgpee2HO%vVgvG?yU|LAuj!-x;`>P;tl^KVn@5~$Ea0efQJI}v0INCds5#F|o75!N zQZV%tsL2Mv&aLL@L`3`cs59BXsbb$9cvvc5KoLfGWD}z>%2J5F0>9{rQn+7FwR*DD z(jh1?$#XNyrJUZWBi_YUsjbkt6$#l=(QVjgwN9wemzW`Whd-b$U5V0<8Xgw0&wa@!o6$h^;`w;EqwTM(UEqAs34emh!CuFou)lbD}2;R}yU@t{=2W zn*t=u9{Yyty-pY0i_13l6P51mO6#@A8OmXm5GNZ-0W{=lKe^jqUL_;?(T4_dlGdwK zXl%towMjchKYCS!JJ%g^@0DpNIjroLP#JSagQH!HYC@libUN`n?MpLy5;Tdg)aju9 zOG1soMjELOx4hlaa)`s|1-UM^mXByGbCXy>Sdi9qpHEXG9@HX=`?6@-vBTIjjKSTg zIsBy`2J#RXP7iUv(${w#qH8pMCulmGrbMH!MVsCuFjt;VW1d2e`xNZ;!WfTi z8ag;5`xjM0L78Yq%1@35tUej)1rYKx>M|R;SI|V3soFX}UrHc&a@fjN3?hC~l^si% z9Tw^j|4E$uR*yz@h2m8U2oxn9HKt6{)=kc%Y|^X=CUvddc8FbM%u}jy&%o$!*^fXzO|lOGSk|yW1+F zzs56j49^%MRdZ5f7l-T_K+z*TS{)%5+g}_Xt)=<7Zy(89z;$L4GIfUdR%35xvdMx9Jsv5 zu4o6>KKFDk4E%1%7a@)vnAxH%Zv~6#0)22#hestP^uePWT6S6vU-)C6vl$nf$TdY| zz9!N&2#3^TMXO-Sb6hoUydacLC|gGjrNhxF5Yke(Xm~8yvKc+1$s(7wsS@1Q!PM`KV+!F9UqZWoOpVi@iKwuTaHG@ zYEZiP3*zrZQHJhI+jb^aviM6qJ`>D`t0c{WX`hZE>SJNsv+W-I$0e6@>kH3>Jgd+t z-!X+RIvY#pI2*!Ock=LT3#22w%r3uhD~M56*Eg|^Ds4@N_n>A)3&cOaQgAIY9vvIW zLLkUqBr^~{C}a7;RJP9I=$@maL7!-s<$2HCb7Tu~aqX;~@O>HBQUaG%Sx|$l#QKcSHVmUvJ#_k0Cg|NpE&(Yi1))vLf~p0tkewF zO^A`%#NmfBG5L$dWwoK20!o^P2dUrAtm`j-OxE!zBxhpy2L6rM-Pny%1$0L0cYLFd)cZ@tmKyYmVuFoIu;DM!>BA zM}?3^k14aoC&ZdCC8nuenvdrCBR)!#CjXmU^w05CQ4EE_t+Rm^%cOVCf<9!G%>Fzu zY9rB*G~X#AND>BFc$BBVIaOx4Gc~Py=62O7q}uyiN>qXbCfvAN^~D2dm?Uk0Re0gt+<%P$w89dlKnTbKmJXQ~t8d5war&Gc(wB2OKp4j3HE*_`u-e9x>C9#8)>L10mrQ(-GTYick7YHx6P*j1VClvbJsVCuYd1nO2gIUE5fIk)K3?_?NZtmZy6*H_`P z)i>M5H;$#;QeGJ{K#DW8AsZAj`A8)C=Sf?Q#~ zG^^H2&qlLjGQSX%Ahc5c2K!)+xbY$_cfBH%#aquwkH?3fpR=rEeJB3kc)INq@7_MZ+-2aSdqtlgDID1J0pQYh>2s! zS^M^JF!3aF8>{E%&gk#-J7dRRT}1f5nBQYrefQOrdM~9J>Fdi5z9ryy>;41;;wrw* zZ#;!YL#ejHu$sNA?vs$|I=?^V!XcwCrElthx|$(7`2C&cAMieKlMc9(!rRaAbXY0F zHw#{_YsKNgK|FZj^_#zKr_F7itA+gn1OGztpN$v@lIN57sVQtxC!acx=yYM+_wLqD z?h3KiTa#E=pUS5 zFAjf^?>3{~nD#_CNLn1J@A__@L5C0L{3H9Bjw^3LoA_8~CuyTfeg(jZ#N|Uf9iHs2 z6=8VgM+X@S1u~~emjL{iLM_at1P08 zMHDVf9yM%X7t&smI8JU^Cof18Pz$xZEla$uwWHH4|Dw&Nc9?yz@#NwXL-6MB$@)V3 zps4S(FySs`uy?YR&L3qyWq|$P1OMDB1~M$r!wZ`M_g3Yvz7%DKkXFkY` z9$EVFZ39!AplK{B_vdDu#t|ppV|)3B&8L|!9p`Z}uJoC!r9sXON zr_7r@cREwMH^4G*^qwDxlzJ^4mh?Px;I--b{h$71w1q4a+q`Kj-6!K;nGLVFt$+Z{ z<+Jp&H!r&)?Q!JiS0A+XXqC~W_&Us3r&fIXbiI;2+BacJViuy_-{C%^^~(*$Eer~< zgbb0)BfQ?qh7S{rM|pO$JvgGahM?u-!<_@K&yvveS@XX-`mfRnQbzdKGFT%~#pfwr zqy~D?z3S6*MfDKGZN`u0R?DD|GKSRs+r7I)3RgIIu2F1VcCB~54pVQk3i(tNcBGPU zcV9O!?_4)U!RAKte6Q|ooSHID94!$>(V<>enExN8;;SFKVk6=gq*_ZBvs zXIF(u1yy4gcZUI-`#ZSbxz335ZIMRgflZq@ZA_99`{7G?ImS3wPE-tYbjg;?cgMZ~cg4RP2T?f%km$pTyM z=Y1)HsXp=wm4)oIG8gT$5s-9~PwvjNr%T-1QQZ6#-g`_vy~|n>{325GB8cIDBV?1e$=Q znXc~CGCLxt=|GCv{JPq1oD*&S;!T@G^MQQst~vplM)~K>wF+{8zAPrZ#4adgVCZYA z;|Jw%vWO;h#RYvY0>)b)a5~_l%rKHdXQ3HHo9<$Co3opgPn0MYId50G_|vahu#BN% zz0T$M!h#xf=c~j)(vKYdCZ3^LYDnB=IaTajmm5ut|Aw^CmkCBtSBS_roud-;v3^qH zQ6)*!7+9x28>o7=v!G*sW-jUyTOTqz5AT+98Q15-5h*HR&}}GJB}GCAEhr~50N>XH zK5jPSTwqs~#S#My9jc~;c^H#fxJA8;t=s0{N(hVs#pa4jx}zoMb19;GYLW?JN$OAp zBVBrVd;7N~H53$ab=~3GQH^{`NSSLS6Md@9$dL&ot97UXr4kTc*9yq$M;hR1LOQ{S9RN=bt`N&{p z+k(Jc`o__7m=h&BlrQ2N1j4u?z&LK3ND4|W3r6qP&(G84n}A9B5mnkAYP&U8wr0!X zV-BE$C$?}o?t12Bzyx!yvU8TFw(6*mj{YX_$`?n+dmw60^1zsd#Dbdm47pftfmiD^vUKI?N26ftmnor39KqNE&Qj_|_}> zV6j?l>WQR68@^7)y9mJ>ToVye(#HlTL_Wu1$!*18 zsWp^$Q;TzMK4CjXLSf1V9&B*%GT=W$)cTL5r|=45l^?ImB)dwdNxYZrRgM@xI*nAB zCk4nvr(~MQ5V~eNuSj(VYh}v(oDmkTEDZU3_xq0?sw~43->b5#HQ}A0(Pu%tdexWf zL6z0YO ztxeyAR5#9aPY_E0vUe*B6V9{xp;Ufv)3(H-VwJ?>E zL?w#;SnP)c*El(yj>y<5t1+5tHWCyMLz=q^eakpS0N6_t~yN zGmenFE!&XF+S9anHLiitRfwI&BzAF2jpT!6Y#hn($$@&7l&?bAh$|iPPxFn@ zhS=%Bkk8SwNY9$LaNSBBhhn@bQI7;*KFS(*3R(Nfh7**ROLg>{`fk@l<2^?^HJ5{5 zs%V`iOdY`fD5FYX*?6w{SIk!?DV(|~0Tp8IOMb1rg~rzOdI2+sh=ZbqU*-fL({(Ci zXPKCrwtmM|>TdtdxEVtj?Ir?>ir=5sWp1A2z*!=_{ih^0syp$ddO{3DtJZwFS`CaEslU#?S_FIqkXnxE#th{JRo`e5efmXsLZg&?eLU6?x;y z90`NGdWLTBb_ld0=uuRe)MyI{AKFs{8wJ^({R!^rN8)dTlT%%o-*UC{=OGV@arpURfFS@r#4@^F8#Xd-oE;0m_wg}ZN~cFUZ| zHj$AM{G(&oyDphsfug5C;pFD9;de}hYdQaI(xdh7<{f>s+bRw3urfUPLpZah*Vii0kqqqT*@pD!eeVoos3V4;~?p3o81Qah(~`aX!Z1 zJJDzHnpeFc_aBWfmX{N4EFzZTEAF?p4)%c#zn?5lmzs*`8xp@JviiT%GpiSWR zF~P3Nm$R5ub4|dzkKMQDC*JAIY(uV|e_A+LngxsE^%9dSq=PBiiqrr7 zyv^e&$MNPB!?U0~W8fE4-XAsz;i1FoS#P=>OpO`5|HMVlCbTMoCUwH+cdOr2T4jnC zqzK_-x+P!Qe$e@&S}njr&Eq{QWyJJR0rrFE;6+vfp}0*woE+qOz= zRhb&q2m^r~zNLZ}7O=50%Ak*A;&Mj4spz)3J?k!t=K`-cG zgT*bY;l$V`+>$3;;MOI1ge0z)!^^G%INV0+S#@zN0>rU1?2i4#$(n3AF5E?$vRP?B z$@F-t?z_-b`=`tLIPqxqyw;Iuo4P+08dt($*W(on|1hAxio_;TtPZu~Wb48(n<{^x zjCO$z?3I200l^>_$G}^gLskM|yk|e$H^j#YpXQxHucY-Z-E<4z(Lcr}?{3b@siGuQ zP&C&ey{LD0o@00Eq)>M9pL4PJKpr4h>Ci8qZO79eZ|=?w*#6N8-&O6#WIuwclunAx z5HQ+opuF!m6uGZy!&7W~l)Ue9SdpH|Qtz%OTvKRABZKf(kc|@78%B%MIKOz4k1OIs zlz&Y9jT(Puq}?qbYR#GKg1XTUMSAeg{-eaBPd7fJ+I=P2f2DmcMg1n04{Rm71i(#w zHOAvsA1SKEG6iv5-o7|TS(_*{UAHuZDXO~O^Vxg#mi=nWw^ZODdkK&W_?@+h9n9Hy z&#$HSoM$#j*)sTEE}93Qe`~-^-S+IhC5{fnY={rMb&=Gq9eL{#qGPB&{l#w7#{&)T z2M%q^E3+qdV||;D-=Z76H&}~!J|r+9de4<-+O*-@o03Nlw>Q6fjT*A6^F*=foc=ej zV32ptfpwPA49#8+IqckH65grj8!#JFps8v}&MaqnuvyJ_SgoRVZ~Bx=d(|XnHS?uV zpum7{-P-Q_C)x?EMmq_cOhAQ|=HfS^oZyD)Uu*HFIm#pM%kQNDYz%j$JyO(?rbTR< z*x+4P@?qSt$t|$utnnoa2q39Ig;-Le&;^%Oj~qWf8#zvmd9w25w+sO4q89x*JHh31 z^hlk*uRr<_s8o2zb%+rysw+FDuV zjSMlU+vC8!w-bKheU8N8f^x2ywFH?GFSEYWr`)Cta7hpgC3lm^hTfoX8Capssn&sM zP?Dd4rlL?pPtyny*u7Br0^71}p31RdCWMvD9Z_r;->iK8^zEFnRY&#~*FAjGK@p+p zOV~`FO+MuF6Utlnu>EQ4z?g%>?Joc)~At8DQECZIQHgix=d?E23$P zQG)-PlSVOJ@RvN^&41>EOY)vdF6VL-4p2C9piEC)OR2XMHU;We4tr(G75;4hi;PB= zE}eXZ;Y!Qp(BWy@BjX$VfK#=Aw(Ccd|-cHnPc)uT_hW|@xVvL-})BF@f z5>Ho5IPo8RWVPTtNn7upH*fT}paollS>0XsQ{dfcnE>@`OQ|kQw;-DiZ3zylKG*)M zSQZ&D@<`6@lEE=g&i8E9GQ{W>S7NpJ8Cr79#1T3J&vI?ZanXrdg0f_8?g&Z(TpnqR zu_BwlpOIx~rmBemuGZ!;xW}ohi2c6d=_!(L_6JtkKKz$gZlpuF=}tLp9pT_j{)E!Z znBwsK=0n*~cT@>*M~K&x~u1Zm)Bd>YaDUUGnIf88%qk1wYquj(*Uja6w4mh-HuFs+upX3!fJO?)2yYYys{Wi znd4IORv!)hH6SY#hP9~$%i_9M0%iTBFVS)-H8mr0P`-IBRLHHIX;Cv;6aNUO1p?BX z%m^^-h)O3v{jQ-P!<523SoC&_r|@g53N)Gb_s7H^Rn2%hL`W($-+$qKvV2Dvdk%65 zFxriv2CTrgbBoMpw!nSBR3ZLo&N#T&w?Ny0VVUj;ne^E?&p>d#9F$qIQ8c$^0hyS8 zfwwFRrk_7p2IQWam8|XY{(*CoQz10b zp`UWPQ~<$~w2&B*#f}9r=+jBJP=QrGGmb&LbL~IBLx>;dqJG z!vw0VDp7VFIX-{gE9JMpGYluS5Mjd4$mL0uQ*Q2r211`gJ-N6gOn8$}kZL9oowbjk zT&jFDLXgLY+?74z5wJ*u#Shd(&!ga#K`G!mfkCJ|&G=~1pFs(CE=vcIiI_S|jq}@Z zXUg=MSU$<^SIJV&fPh^vmwNlxT4NUOISxj|>Am*5Y9bTo^P`O&LCdEkPk1DP03fc# z@n>VmlDVix={y<>p$H&=%mLIapVn+jzbIf>hg20%P?34`eW!_uwT+t7Y_;s`C$Bd! z0TxA6i`l@mrer~L(TdCJ?YGYv4+~PD2tg49xc7^eL!+Kpp6!v@NL-$E<}qdz74i9} zwLpIcw=24e&pMW^V+<;#V^oLM#!2QOkKh)R)NPU5d>MS3YhjYB3Qf!x*-re@{WM4- zI&r?nA=bTAnp=Vku=s_6jw>i1yQ-D07GiIX!MC! z%B?usqOLuOiTDYFok>u7CDHe)aIFE9j!>D484UlHIJj-RgJm#mOPlaGqeZS;TEFZl zZbDi)`@YGXu(4_?f{s<9<;|zUnbRegc-sSd5@e>UG+ly*`voinSw&;|3_KaYM2S~f zwi|sSm3Hv#nReukYtEcX+kYxB)~#x-Jbc(Cyv0*rP8nEG2<`J@&Weq3;D!2tjGE1L zVv%PrNNH4ZaVBzWv;$XgLvzXXFt2@;&fS2_Ah^q=)a5}xM3oZi$c*cA zkY4t(`g8!@z~pf%5jPesjX}a)db#+@z{!^S^|{1a*HG_e#>qOq=NbCc16F~Mv8r}q z;_U_gR8_n@yoWWdWf~{iDsNB%RVx|MQD338(x$7Pd0w{fAK}!;Ch1WsCZi{t+y5;4 znh3A~pfDtNo(C0-6b8CLPBzbGMQc1I<5D9qV~_;0grnxMk7FPYcRpbeG%7S>VuApZ zI{+mvx#q|h$Y@q5ctGLBW)9qr3C^*k&^|}JW#eaM4+TuoR&WsMbr2y#M|Ccwbqk-9 zk|(V2>Y6Di1=KuS;U(_mBXlPnwimKEGyg5n8a^D?Xg1)jhTUgt=%>{lx)9yK_7;jO{hH5BEsdtAkBFW4O$U+y5mG7h)x%rcpC!_4?cPodh61vvGA+TkKr$0@6;P#BtN^@1Jb*Ac5uU4vo%q$Q(}k-4 zA%GJXomJ&HY|24CyJiT5nOx#j&G8DSR-3#XjO$Retf6;`82?0-EEO8@!&Q@M@|)@$ zvq&i);}cQb91oJ)R&HsQ4?`6L=W)F~uLWcB(iR=_ukw*V7*LgGPH5^dZo}(GN)U3= zN2)d#G9}rtK10{3%AHgh`WclkXm;%w8wSv^f+4LIZG_^*g0e;f{&UFJ1ciFFMJ08~ z3O6$3QH2I}vRF5x93~iQU#fftK}|6AZbc5o;FC$G6c^_BZM(m13M zu7}ptrh&jq33!ZXVxia_<`TL*a*8aY)7+|-IvYS!qTiqBf2RMWi{SV@+G(8K%`Nqv z57;r;*zufB%{VD7ht}oTB$L1weTC|+={MeC_)x9Ll~X=GdipUBDF-!n5>xHvLfTF; zW%ki=T1~B~ziJ{LCfpVv=ufF1e$st}xRS(G=TKwu!QjZ*^zmz=D0pa~W3~{2*CfJZ zc<2mX`AWi>G`*&uQ0A zd6|*F=*`04uPmbZooA;mct=TVA&mpt(J#9btuzB`u`)8;(?TwHdf_KtWTH0Bb#8)N;yxyKeZE+We`S z`BVSwZ0dEPq5jd*n1-9aV6i0HptXG|lEv-j~k;Nrp zS8H_L6#c?SWW<Q$DTIq5dhsU(sKDta11DBpa?eY6qW3>VrclJ(7-QtW9Arp>v?Vwe|ZdN0|t6u~PKC3SbIW$3g zHW^+7CdQ^Rej>AJ_1NGMrp ziLFpRYVq(~DL*okXIYy*k^eonwE&?)zt0Vwiv+f1;IVnh zO4u0X#%vJ@VNG>K0X~&PSz8XJzz%$LlBVYRU^)spW&xFt>(*D;Dx+FV$5PWR2x*EZ< zz$f!Abq}YJ6K5u#S;PoS?zLlz^Nd`si6JMf_v=&1Q9VD0(nO}nPgE=$ojolPC|0W% zmBe4#;bTY>MZ6}9Oeme8^C3vy^M{ zUk99nnym%G=Y3yC_jlXbR6WUJ;2+C^CMt#Z92>*W;0A`!LU$BqH zWV-J%zWO}B4Nw7T+T!0ZWZ%ZDDJU*UYK~35H>Mz)7 zgEm@DY=T{#&1`o}A4(`!Wgwilz$&8|G>V5^2L)o-y=TfUy<@sK8_>~cumh8x?mb37 zc(O2-pfRogulQkS=UG6qnP1{4Nzm9X!QH4*G`tbjk-q^OkE2>WJ#GAB#*(v`fr)ak zApCaXti=GtmDT`tDt8d!yeNL%istrJ=Bg2JV9rgljP*&1DVPs{Bg-cHq}Te8r%KFD zFplWTe0&xfLnS#@?E*FogoqPB_#oQ!o6=a0Psiy~ITP@-J`Fi|5sdl4?5W@H0+xsv z6?0BbS=QJqvxJq{JoxF!vqa|_F=!SgDil1j>*GAmou6Ke0QOJC30!GOWH3{9mU)m7 zLgAWCatDfWh23KkDzJT5&0GLj+=Ph@0vAP#w-u(viUB?Pq+Z#m18j&e&8pu?HSxarQ@O%8FY^;|L%P0(MGFchHe327wXmqCOvjCue@* ze^+d`zQIh%!GSGPzw?1Aq5Z$&X1z*IEeGG@Jn-iM#C5+M34CBQ8(EY2ZFZc0Og84( zAu#TuU+yYnhiJo`3ds;x2*VbZ<<;UO)Dqb(m#yS#KPguiFN>Rd_2=+pX`pnORs9DX zq=Mhth`hrXIy;qNp6DrfZf2`7QS)UKl2;hiu!VC)cp_88#y_zv;?8<7*m*#4adk*b z1t^kfDbPjf^Cm3hqXwA}{rZw}%nS>tV!_^2h4gK7k!aMccj<5Ma?Bdx33IPv9QSwB zb$1o|r8{ry!eTw!vVe#c5-X9Nu^N9iB!|uwKp08E-izW2lIwbOLkNM2Y?Vd>0MzM_lmW|f~)B)hKS;X<#?u9FX*(de|Z((_>G1h2=R6I z0}ZDSNMI9dhV1C^hU&9j;UtE6e}u`LePF7tl+9KEZJ}h%wCzYI#|9r-dw2dp$dB1Z zxbTx{RqDP_Rb+}N5Fbo!sS({tBSs5Y|8%z5F*=|3f&~Su=2f4fH&YFty#;NA%rlq1 z>yLWi$xhHWydgUYziQ=-C+h$92MW z)K(8fh`M_Mt>{;6#{(%gE%hYw{mJ{s0Fl-9daRG>N?2bT1rZpjARFpTI3yOxj8q8n z1zmDJ?q*pVTUR?gpwz;t+kDySQ_4?D@6uE;&;|+kJ0*K^sCYGRz+FV1PdaqK#ILMZtI zZ(Z9B=8UAkc%R9hgRRvV)f!my8A_NF-`ga-Za0QED3N}5Wn)XOtmXFe7+m3OciTZa zu7SLoFp)1F4;ZWyO%}Wj<9A7KAox#NG@K#=efA%X|3_G9rJ@Rf|KfGC=-0pBEOcs& zUy~@gjRaw#n%%pC-VPSAbDcIJl!XnF z%#=A@UX8y=QaTU9gMRe<4gLM%>%^f3PQEROoAi|Z?-~22t1q;Xn_zCf6fQCx6@nAr z%Sw@aFi5f3NK}5gc=39OJcGRg6V)?~5l1j8?dpP+Ig<04a zbe~t13gwXZh-QCU*ilk*2rgj?H46smdri2g@CT)kKG-;@UQ)UA=;0q0T#qgEclRRv ze$TP^TOe|I$G8L=GsUZK02UtRrxS%+VHwUaq1!oP{EutU!@6!PEW6V2z*@3J8l zP0c%21I`PGgbx_)UtzP44yP<1ugAUvUSR{J3~mwBY|jNn*RlD!!lQrdHAZVG-)4#a zr|&z49v$Xoju+nl4*T2g{4pbNj2Nkb*9K__IevO4FDwaF)GlE)RVLkYkc}8I)rxyH zUvc8x)*^oDs`7F5Mumx@(L$x;3%T7@A0uu{Pd<%N-^}CdQ3IKSD+$jx5uxSyo}W=k zkNwR{oUi8AlOC}YDb`qNPT9&WH}{=(=A7kcfp%$PzpzOI`1WOTCyluSh7g&hGHp7z@d~H+>!Uq@eMuRZvpl>J2+j%9K zF0MA0_I;0e3GPkTZKnaJ#An!#SLR~%=DEE5B{K_ zw4K>kAc-!MeG-`%Y34v$kHK+D3`;vdsy7HfeReO7N?m@SkpF>o@?e3>S3Xhr$8Y*M z&-?0}V#$2CRsYy31w!!j>N`gPW_EPh@I$76E#*+wll~8xt*L%K63!yE?WqOfZKtOx zV)K#+Qgfy9=k|Ux@;@4b%U|d<*EXz6xTi}+!*)gHSsLHuR#Q(?ZMA)m37y_YRiCCz zef*5<3lf+gvJmcat^P4pI?4D1Mc>=2!c0Tctj z>Kj}{BIBHSMitohLm2fnH-U~1sSO%%O-rE_~`-^49H37__%*YYtp~twbYT znq?sGG|zTZ?fY0*e(#`j~>U?w zm!~ZEYs5<)qZzgF0)ReU#f2T+Du1bgYWP}>QzzKlRT>E=*v%5d>{Pb8jk0UnXwX0Y zXj{4tRr}q0359ndRGeQTL?(*UI85=xxC%GShFvsWZH0_?guJT#7znl>1gT`$tDjpn zoP-QXF6QK(ehEJ5M&mzGeE%pfEp_2^;wDP1wT04=ObZX12R$TM=SQMQ+l>Rrq+?$} z;O-}b8tY1Zo~SP(M<=3WX%yk?A3bXk_X@v$+8H9hRQkrQghj&z2LYDoZ|TzMm$m`FAMahvH0<9x6p9SrCW<_MGI2ezXBn*E>V-0Wd=8s9rou2p7q2b8 z8GChF!G`!=ynOyp3H+bUCmPnFx-ZhRrP`55Yem=%kVImHd~)>dVvjcYj6Y9Ql9D?f ztp(WSr8LOPRvQ-N*Oj8C0aVFYz)xd`PpV6_+GrqkDd3I)_Tzk%EIDuEb1eG=a4aLM zH{UkCP@XppumA7To#s`rXaw!!$R_fQApKp)q;^()=zfq>cn5UV_LZpI?=OB169?Be zV;Z4Lo2gI7C3}f!0z8A58$;Vt)gd%cWt1k6e1EcpfY3WDUnTddIB;aB_~%;3#8TS> zs3*?Kyws_C48&3M52ZCK)oDak?`>%Wu($M4E%8s6EVj_BF8_FFM;Q>@>+xmoGu+C5 z%td;DoYwWdJgcibmxB1>WQvv<0UzU+z^qT;2m`Yj+-E=_g$< zx6s{xB}BrU`XBmws1V4@@mB=4)V8ik9?mZ=PWSgnKfF!5WNyYpzdVd)T%&rMh4(@( zeez&%Fh=f+Q-M|tEAU=C0=!*Xli4)aJJL;-$WMLQ_ck;5-ho$5wa^Qq8VS@LH55=G zFQj?=Fr^FQ|6}rf>J|EQx^ZD>>}ZO?;Bo1swkngu`^te7S_-fl*CFjls&0RegJ9A+ zv{9fgyRgD(REQsiYc%E1lD_xYS;FXm&!z!4KjGUK*|G}CIq`T3rtng4Gt*600oF!8>oouiaTif4j@cz>N|1%f#6zai4k0_06sz$hFEx8Fl;q$o#w#< zf*pqxR~@{@bV5q%hfu7peq{E?c~FJNPRwIu!CUQA#cDbveB!yLq)z&yZsv1KFTJL@ ztUBPVg$J9d-5(1bN*1t{5{gS=fzSr@sUS4DgBHgp4)yqUmZ*ZLP>;>xTV~U zESb|Atys-A2Y3?TP<@GNL&#Wg-Nl@u7U08}=UfhzrDQ^W(KJ@xGZPhRwa;#+ zpK+m^mi$yI-;`&*TT+LgF%H)G06=rJQsC@XOMQT<#$5e(j)aP?YUCJIRqH$kU9% zQ^EZ_@%l^`9%|fu-C#K&l^ie&AyHNwHk($^JJe)XxnXux(`pc=QRos;T}SL13IJL~ zomDv8@lNwcQ*RDNBdLg&73RRPg9=kw=)2GlOeIlbxI30~0OmmEy5%uqAmJls-?(sU z@rP(#0dZz=Xx-H`3zt%Q>d{!cCG?@3?3a0SuQB2b#$_Ak9Q0}RbK|c!2iF=8R^Fnc zcmQtr_1qie)(IR`wG~(5EPF|xWGw3Hob-t6zCHuM9$A9oU#n~a%cYeP#!33nG)Bhq z;k+)jo1*cMv5FFprIg3scq3yz?uA{)0Z>pH^}~gqh8!PEiBv8w#x%E?v1Z&-uw|0E zka4>2YT6#2GG$B%?)1Disx_R)gd73t?CaPmQAE={1eMIrZ6M_wiBd!e3`u8&I3El0 z{#7G+5cR6D2upQ zF9_v!IqFu7!eRzF?X#llJ@4Z0h{8^Zj`<|$EduFVjG}I_xSYLz`4TK;f(EPry+so} z1}K`@3ycDQ*RQa>jPDI$EEJ#ni9H8t*809+k8M$GZY$!;-~JwZhXo67GUU#A2PCt~ z5>^yP0G<2JwuigJI3Z(l8UF8)nKR7g+_U{|_~aFZfKrWSD*V9n6@;lsk6tG>VsW>n zcu_$YQ-q^)Abh_ijLs=?g7JN@(o&VO{Bu@eZ#=?`UfYsr2uk{z+7ykSvL^fWT3_LT zf2n^pOK#ur%Gri<)7P8&ued7e!ubl0h*ht49J+4a3b$q7AH#{pk=_jn#vB0rkT`QK zierT@s@Wzi0GH0D%heY>oat&n%xjX|)p9>D_QfIK^e{#F$~nwjxDnD^)D$u-yS{lz z#_nS(@a*2|YWnWWbi`=tBl+%!CGX0E6Uy;9RoHP*rkw2=%(@Z-TzBQ!b~R=o4wv|% zEBM5n@4!2(KQYA`l|ety=tKSpq3;RUyygChg%L(%j%zmz7CRk#5NH%ii&{M!I(!wZ_0Ru-!cS*;W5E$L9 zgi42q5yxXotpVJ-YT z_p6QRre%o!%?7a&lW&b>z`@E=f832L%U~2Eq*xI2o6YScS-&5uKRRsd6o$ImJ{Y}S@#iFzgZDo1T@SiO`rVe z`gw~JnFgWh4=8Jh z&6+1_P_HX5bqO8gd3(8VpjwDmViQ>8{$ohoy|$>E65|NXEbFF518Y(j=^2=6JFu_^ z#1-}9$qA7yD+Bzf^RGN(r8?~JnPdZ8i#skN`4J};?lwjBV9G{eE^d0WRrvZn$ z_w!$(7n_ncwSyMj?`PEtM``}^{k{u#(SPf017b|&d>GzM?<7KaRiboNQlW6{Iph}i zAW)Fi`MrlNu4lvgLR={-7Bv( z@H@IT2wkw~v#zzqVt+wJz@{VZu>6jZ7VGZlgsmS#5X)(tXc$KyjA@--gT8CL?i458 z1?>TT1Zl6%L)!L>Z6Dr#M3Y}C-EJ#=SMzoumD#U$O~3y{6PH`XDIVel6jtoB+SK#& zg5MAn#c+4@cjGR*zts~6;}})_y-G~QqJcxRv}+*0Vz%3pc+rlH7>W~6Ph`-P2z^ zK^A>4dQCj?3xdKQ^8j!eeDz3X7S6I`LF?3_R4%lDQOBo|tHZx+&77g+Dg ze?Ron!T!&e+c4(nD3&ZXW6N|kxy_$Si zr1m1sYSvxh$kJA3JKD6KT|v4_lN9tX5%mwO*%~xp4tLo61i5;B`cIIYsPUX&`}&{x zR7Ws=>;kQ`@E`kdh0^lWpZ4&zb`deIAdieSTUITw-j>WFl^Z-Eb1?39`scO&3-b3` zo7wrZJU*jH>x!0<`xTXZMA|&GB*Vfdh#)u$dwsMi+2G5qXWWmC_o+npFJHhT*9r9n>nr*mqQqH{O<^bM%_M5yY5jIrmT9+loijOn)ie@?bfQ{VD5!&!y-Kh1)k! zO(jt*QE!5#*TA6=yKBtahwsoAH!sHh6D>K2@?`=cc9~wd;ysnS=^qS!hH3i^j z04_-dd{(WXwaj@*{18BmOane$+P<%u8s4Y;q~U2h!!LONjG$66n)9Qzu_#V^CEg+y zR;f-D*~W3?Tse)+e;D6XK~WyQebngss8-(S(BPxgN4(Gi{D7E5Jz-cU5z+u2N+FY-$ib}c5iX7wIS7&k87l#prfSta zvV<9|@wv+wUK_~>nAd+>`0 zD_q~%o6P8E8wXADz&dnt^(Ijck3wPQMi?Rl4*@R<2W?iTFY1cTa75c}0YcoY2}+8j z^A;bCtOq<+B-0^p>|9-hL~|Y}M{jMX zP496#k5YfqS0+LeYX?r`NFx$XvkG(DECkR;W2ki{buttc9dKB*h%U)EYf9PpB4O-vw#GiR zj-_+ihGzNu_q-OC8j8nwe&8%#h?qUX0ztG)a|Vr@L;80h46h^yuVhMs-Wu%1W) zh!QHygoY;Lx>+yqM#T!s9hURWa`lc@xq&iaN=rC?(xtsrBgnZpzSJ}IaKVLD2x!(R z67jf0h16720e@0fG**{%UI19*5#?EaS=v~Ih1se&Y$HKg2=~yxvC_fgDe51JWUrAT z_#xDqa(%f1CUp*K8H7$_bTr54ijW<8mcf42SO+rogIg2A3ASy3lg2>6EKMO6Bo@Ve z@EC}gObRO z=a@*YHJr@#we%ckB;pY%a2Pn%@~4B$i@t1zxwV%2fbelG2IX1j`u!1lJmTOUBKCVq zu@91LvhnlVhU@>E-%kRGF8u0ae~bUxF~8)om(HXSA`x^~D_<);VGuj=`pYvu zv&O(N*;?t=U6Wc`w1@Gv$NJs~j@gQo{#R7=F)7(VrNm1nmS8Yj*P-=(z==0F(P!w=P3k4pG;+KfdHjLkas6?TI_G|`z$*nU44VdMJULGDW78lUl z9OIDX$K`@NfIv##vMTkrxtRGI!K^p5xdq_@dYt0Rrg0EJ z`8vp)i_dlqXm*0lv6oouh3KKUt*=f6I3Xew)`0+VfBdqy$X(0;_uK8~{!&2>VM+R0vm3U* zLYpj(kMmHBvQNf$>7x#KSc#+QDG-JG5}`;3R{AV`O0ILHd&EY()|$9$W3_H*V>K<^ z&`0xVc@*Po&Z8hzXw7Ng5* zXaav$QI_5NhF?T8>*}QQh7UR*NA(i;8dd- z&plZmakSiQkXL_QdEz$xVV5C6*f7FrH0ZZgz#o$*QDs677~ORDWn|_REVqby^Mdo6 zPRcpLIf5zL_m(t!q{xEqZIGS^OS-k?5!^}Bo=v*P&FuYvO}1&u>7?ni?b6~yysXrX zXq{*_{WEHwZ@)(b4kY1uaZuE)EWfI(821Bz2nB-7^>Uj?;?F$w@-h)CG&Z{KJ zXOV@Z(cpd`v|GjqX7Yg~*EWVg(aKct-5DX=+F(q?Qeae&%CLk_v%l7je+qF0o*RnM zUk}F*x;zY-&g%;?!yAnSoFI(VF_cM^6!2%|Dn9l$5mu7VHq3mJ*QiDXMj``})us(e zVxF9!D$mrb%n&ZBlw&>@h|uUhhi@2|ZuwX0Nk8cbVBDjU70}2gf>cqFPlhTpZZeCQ zLXmE7wS!asY*vAeMgJ|_&PL6q=3Q*|Q=C8=OiyGxRcl3U#iv#ad(Ps9y?!!HrnV95 z+JV9SeWKwwDV^i4owGX35 zHvOf;kK-+T{P4t7*FZfNKqXsNGQtt&5{wkEsDA{UrlOKfC1qAWe?;529K9bO80vt* zqGZ3Dm0r5NkYeEy%8x$AYv|M*2C7T!FtFY3&fk9*b;2$U4A!VyS|olIPxA&7pe7sY zmjR}(y=efckW#aS3N7)PD{B{Uh<|mMM~a|3#C?UtqxnY0h5g;gqm^!aBoarwDuTut z=xdA!_Ebn9`FJ1=e(mUB)`FAOX^wodbOWZxh$0Ic%SW;?%&Zqm6$uvo?%|J9pffH# zybn5!E(;I=mBOeK*Qs2Aru@8F*+R$!a4L&@-%@N2?bA0&RwVE)pRJ2WTz+j**Wpr3 z2O7&^Qf5|QM4^*197sT~X$XNU@Et2?ZhB0{Fyn(vn=IV)Xnd4VwJ`|-?Zys9gzWt` z_wfTrvb7YCbbzddR3bZVcqv3?9&KZxOp5*5-8JtE-DV;(oL%4IPYW_*1oZh#4|C6^ z<<7LHlF}0&&gxWL3B9XoDtY@j?qTa)%2k`J^>zNF_=rjfgJNl$-~c@l zii?!fW{8-BFGrnIVR9FpOy}@**IS3a!S+;A3FLX!P~E1#Hv6)Cr1UV^dgv939*(f) zwE-#gaEQlGJUS#Q?R)XaOvkvCxW>z6N6(TV-;h-?S)O(gz;`f*BpwP*Ra;oU)JI8) z-;Lm8Cd&8C+j=o-Cu0^mD6^R{YcxlP^_bme&$5&(PSp+frlKyNT_KX;&L(g;{y>5> z1&O`XWI;>@3-+N1?K&o<@80i#1TY1P zV}KN<2tf#JJCOa!ANJ5q)KQi}=fCC-s^2m`)jRe%agfKf^O34atBC^pl4i%`V7o5k z?q~Y@sPHdI+)l8a?kWOHy2tc{znyi?5JJYx^BK}^*=tb9B=qMdV&D7Q*`xj{B zVo^e#iIEP2dpNj;H$TbC|fUj$#0->^T&0U);bFQoc(CL=~m`$^d5JjX~a3*-ByQ-|n zCoC9g!0UHcZ!8y|S%jms1|(bbc7!->6bb6}N1qcQYl-3c`>^y~ukQYWT{lld+C@EE zSF05xey+b#+M25-x6G;$ZFaovzVqx$+;6jVwD) z`Nz3g6H>mG(dF6HYP^{s9YCc0n!OxvMwln4%^`pt0~K40FMT}R+Pw79iTOtf?D7>} zg;b5-L1U)#pEs|KMA9=Z$ZRFTjpPsP!8F4|TtuBthar=-J*s9jl3NU!Ct}dDa+lqs zoYbe2lu13_`XpbV**Bke%@HE$Rm&aT`RPQ_-&~XO{Vma)E%o{jqHM(s)EE`QIR@uv ze=w+ej{5JA*4djb3855&-oiSIO^sqhW^sh{lUn9Ij(q@%X7rwnbuy1-X6#1?7+hC| zSsW$w<*4F88!>%MW_24ZJYMqh; zCa?I7pv@}(;=WJ@;+lj*u0fuO7d=0Ge4v3zbxp@dD@nZ6&eX}{k zod>3wodWrtl0pwG-{Z|5n9_oT;6-|3MVk#7^)X=X5}u`j+RBq z;`8fCJhGS~ZOY(PaRq?NCc6dBsvjI#WgG2iyZm|z;al>ppi7R?irVx+zizuw<=q4* zZgQwO8X00D6a}?e#4W^i^VgVmr){1ZqhMl1lMS}jPHwjMDca8k-#O+Y16lIQ3wL}S zD@YX&Lew?Nj|Bx#+P-0D<#_L}I9kl=AIgj))*QbBLULL?N0Z9-Yo_Q46yV$qG%TO` z(~M?11wtuaOS6*n`3@cz5_`yuCAkN}IJ4ITo&sF9CfyM*WU;f+R(aIt7IrF<@KS+W zNzgXt!;|a3i)wr0v%(-|#A~K#BA|YT>s;5MC@hq(e~S3Wu0~| zuB;;G2(*RwYApF!C)Wr9 zlnZ7Emcuc}pg+t);yu`l$4mP*7cS<5K8-OZY$?6nqX(WO8McqWBTY`Duhi+*(BzKG z#b_t-K`9^;gx5)IAK3A}jf{Dzh=vICWm_NgQayIgVpjrzklEqRj?}j?`;*E7znlX! zi1Y03l()Ui@0=$v=nrw$Bz-@9d2mi;RwIn69i)xReCi>-Xbkzb*0=M`bEz#m@BWk* zUG3Mk%sBd!{?1tv^Sj{kk@e?iIu_c{&K4J6CjDxVHT3Lp^QiBQqmSAMvq6z*zT?uH z5V-<2@NjokxL-E(Jr(>HzCY-p!A+qtr4>SQdXOuwp zuRk-9w@nid&e5doNtQ8iudH)iRIlm7)1OEA&xt-)xID;G+WWEgEB{wZg(K%5)dR~! zp8ts7zQrkJNQ9{O>~RsTu$M*?-EQsQn7+|uB#5S+t4+%POnzyi&@O9UAINu-I=_HJ zHsAuBjB8L5F6hn;@)u*oCgYW&dT`|7z}r2?H_E)VXl@Ct$lqSVIo`szRZ0_2`Ux5N z$>mcJL>3B^d3Os+HyJ73A~L^CBZ7=1@L1q&{}vGTr%C2dy|XO`rioH3-0$j<|Ab~< zKjfBihi-80Gaa)e@Ao~+@y_0*o!SZ`h5s$cmZ8A01RO5MU2Q&q;2c$coqP)LPL6l| z>C&W8d2BlioU)_NiDcXQ!UsS&IB1BMIZ6+zDQZE=qL z)U=b<#mQ{@$=aVs96L!tkq3Q5hDJr5;5b=pCP>4Fzg-`?HW4h>J@|0nJY>8)$Mxj= zlYfD@-%8gQrSF`CKa94r*@0&dknyacqXmkFC-SfVl#orzzOV^9YL5^qaGE{Vy)Bg#hPy`Q)Cc@2~6N4;8SXww@w@U@)` zc@DjTO#Zxl^Yc^r)0jk67q?n4t(B96un{yAV@BBb6&gA$kw5nXZ+3BrdU)vYXZ2Ga zej!zT&PY^j=G8n#IdC-g?4MUF1GdJ=TJHWO=Z|djd;NVr2(TU~a-Qqo+o{w>Nae5` zvKdS}V3?X-EgKsA_nY~LZ#paY9sSfg6a-V`PpWu+!o-qat5Z{|(>$X8ZTmCyqjCNj z{di!}PiD5Ye^Tt<8%x_UH`*l|y#bQU^M1b$m(Pi<^TO9ev(MkyZti@sYggG6GhuLG zvR-GLJ`-rsxW|~OgxDgL+=rG28^)fUIqaZI#3qxvB`md#p-2ndT719cr)$L=V3ETb z-lU{la^@?L6fiHl|AX?MBvIXZrl{<0 z>g%=MNBn$R0plKy0;wo}#IaMr+-X~?gnvSuN@^W%i)j099!CLI_0elKV=DO*L|y&k#kGB*}nc;RQVn`*45Uu z#AUy-`3v(=mdV2;u3fgPBDS;Rc?O6zoX0B#tN23J*)&(alTERT1X^o|wwM~%sS0pq z(zVG``1|YGr@olgO%xKU_@a~CB?edT-0e@DH}-9ij?EJe)EXb9sdfz2(vB`Md6@5 z@!^2Z<3gGfvQ5LsQ%RN@Wfp7jsm}v&Vil1IiZU`_M)wgg=R25r<{9@TCFHRx8FxtK za_I5KYWUKnNk?E|R;(oQuk!$V<$ipG88HWF3w_W8Nr<+P7dnFgtxJFVJ9;6A1z}Ao zl-Ao6OS4GLCLiqz)FlCxlobiPwh+{a^b1qKW#?aazW#Jp!h=Efe@DwWxK@kjI9V&X z`jC}B!u}P^aQuQr&pz!*0V{6HuDZI2*c|hdw$CW>t&g|Fai!LXWh;a6h{v+5P zQV{(1i~2vcwG-9YBR9e8MFwJs*&F~_RXtCPa7n@mi9><&3*3f3ya6Z0B#nBnz`o;+XP97hsbS=-Rv^ux`lXCQ~zDJ`uI>zyLYcIuy0Rc3jDHqlj8t~;s zLDS+5-p^#Q)^g}MTxSEzJ+q(!JKTT}NQxA2w47;LeD1`@Rj9Rhm#ESMu-Pam3g>lw z+vgq(H?zpZFBM<~U0BVD=vDB9zbITW2e635H*Q7GRA%8f6o%HfJVVkwK7%_4tP#zz zq|a?vT#-0><)KqfhzdqR6Vz*>5;f@9KG{?-eY-gzw{HE$bZ?bk3ko+DGubgRbc~3q zFa&7mr34k#PzCX*rAKm8j7U&~)>Tz$k0TwbpMBIIa=14fvf}K~b}vq1htC}3IT`|0 zW3sI6^8r(2+Z=Mqn^WlNcH~q|iftNFW$4Z>2e5WBvP~Au)Jny==5Xz=LwWhrBs8>a z0&~PdnhXT@1j&kG1B~u7GUa`qRdn!oSMMNQV7#2ch{GQF!>}bjyeg@Nubp`;y0}8 zJ_wmGdqd`wrDZl2b0+LZ8BIz9;5P#^rC-jz!c!*emj&Hi?zHh=odAIc*+`v*Co2b% zkns8}%D1DqyZE@$Ex&7Ag=OMwvZO1pkmjvHZ1zS;JJvyeri~0_eW=?j?z_)8K+hM# z)m-OP8(i)$%3-XBT_yUn+mJ9R5<3ykF&zq=EfG%JbTFfD&N+Yp;*lgLn5I3>7jy$O-=&u!F?T5j-u<$of z^OCRBpw_YE$uaS#kZ^sH)mcqVPfy`29;ucRVKbKW^8`W`x;4-z;Rb=DdMcY*Selqx z19mcmh)%yb8S6%#=E%_p*>l|D&5KBd*Pd{%eXH+y?q88%89TR-@ouSJ+cG2i@Hh<2 z^=VE1(lU@yl+Q}M^hz@W+-(DsW^eR>O((xxd_CwokZVVO4;xeb=tS=HK@U?gA9X{5 zvt(ddeX6?g7>fd5(?lY!lR(B}7M4RH&=Q`mo&e=cp-vvu54a-* zU@3ME{XXJ`P3`x-Q^}7$N84`keF0L`elFS94!SeGdj zfQ>q=VYL8n8^sh&$XC<1Ld*~Il!zJ=YB~ZNE zF5x*o0RKysU@>eeU5t-p3YnOI2yLR`d;T6pMRGH^DBXyE%>Q@lKRM>^r%sJF8p|*< zEL^nV<4*_S7cB6f9VhvD?+PhcKSi8arAiZjXEVM&5oTCB4ap)b5(^L+Y~CJHb3bU{ z^r*)Tr{39$x5nR3lv1kJKm7H)>wbFCcN_9~7|P*e8h`zdplR87IMdPwwDa8YONfjS z;iXEH&|oj_SiOdn{+RQZaJn3X(~-I)l+LcEseUI2@~(cSD+FG+_8f>|}E}GawuC95`bP;St zYTo?&vQL^BEY6b4c7lEnvUJtz+it(H+$Y5S)IGOQO9Zm1|=jn{D zSdSg;r;#sLa@G0qX4evInYsnO^C_Mt9=-qY#bhlx^S38G199Skq;{BiH5M?S*cK?ace$XHyRt zYtHfQYJ|tWRP^G2>GD5DV-S&|OaDa5zRy>JNSxKWf3pkdH!K^535WWRQ?1IbnNALU(0??cRiRPyz6JXjY%#ub+1KQ@$bsa z)-cQMzZ{FLnd1B-#pz>5-XF>0tk_dIZ{{R7)LOS5KVTXCwo)a&F$)G?$m!JV%^gfqf>bE_)(+zY$rk9b0=qaF>rQ(pyt52WZ#z+qC;JgoYi3M-ilXL6{X z)MHx_G2pPWkHSaT(66tYBwv=KpW$xV!LwB{h<@x5*?Y=x165qh>-3*a`X#gRP*0V6 zO~6nE@1yA9Wy!;1Rk;mRhV1L2CtuG*zHV)eP*8Nf&}vYtsyl>qZ6yhHkAK4`tVC{{ zIXNr!G=MUb>vYQm$Yrt9 zdFdTIJZNz?a<~W(LhZmo~US zm(8D_L|6nkdcThaA%UAaI%*2YsQ>FNZu90jmQ_9~6YXN`-XLUseupEdk@uOMc7G~S zU&0`vrWYM|weAWNW-shcJXLHrm$VHv`yg~_#W+DNjJaorMd3HQ@{HHkFs8~te)uTvnR*1W&P;-ggzUys0L1fD~Y`y z_md>7THhvDrSQaB>yVPrA0m(|t9C!S^c0@=Jc>Opki)9Be*w~reMUN57m!p$b$slW1Gu7a%KqJjOX@I6NBqTfTzAC0J=F>_9?`FS=E2r#a zwrHI^P{zHcI(%|0@VNlvM@-9N5|7O@ivfMhy=rn(_K9z&CI-xrsscw`o(orFNFI%z9eaiFSGN(Mn8%7Y&EcblQhdUEO`02GsB9l8f-_!5JS*pOhM_05`(~eE^6sT zf?_P5mvErBeistJ32b~RrncbQBe!q3b8Ki@x_Ol5BI$%2vSB(U?cn1Fsp3+TirVEq zJ|6_yV%p-l{=3WmWVbu=t8nI?dJtF+FO0KEb}fG5U4!VXbE(FR<-Yr;U!~q;3e3+^ z29t(EoC;X9r#4pvj?+H7x|%Wg`gFQfR>Z$B=HoAM8d&hKO@{*Eo3NPV)juI*o@dS} zOnbyBc_cJE_|y44E}>+%FLFk`u`YUK_?>BgCOtj&OI7?8BDbQ9l5j85nSgkaYnc`5 z$@xloKFWuhJC-3?--3Vo`7xx=s77@XR&rBVPF)0SQ&{*9c{Xt7-lVbpp^;|vb}=&u zDPK)Ah<#2fRhJa(f)~p-s2CAoB*`S;NUE*{FPsY;Fq}vfRQ^w6JX_tqbK=ogL;qBK z`SLoe-x);7u}{DSKo(ACvLMUR%E7mT%YW+3VBKWwk$qq8x9dLfK*O6XOq~VHT_BA% zYpQ*PvDUv21=lY!Y~sl0#OE;k+8y1b8VlM1rZFdd<-Ap{y-VZ5D&_jjD3Bu-BBXE! zi)|`IRk0$B4A_|5soowxP#Xn<;**5KA*`dwT39`x1@Si&`$^p8%}f(7LW-3F z&`$D~gTjmi#arOg<9PI`AIS9|YXoLf5n^yK6G&K_3~sNLLhlAezp!0{iexXfCqPt6 z+B=RP{KaDu4xjv3nMoR5o>KHe5xxqNZ+JN2sV^gUk9ksTFt)b9GEdBem=u2&j`(fO z6qk-4Cb0Hx1s__9J=Zq_B5d(@oaXq@hTs_dG#M z*AD*i1tL|U_hN-y@%gaN-z2D(xF;Fe$LLe}oaX%{?d?GMm#iwLj2h08=3W4aZ zmOhuVMqk;fEG@+02uHLCHzsdC<*dAWejFiN4U08mKDKDf!%ZMqV8zCt}rKxv7{C?=6 znfFJs)udyxik6{Pt=iz1=^4>H=<(+8OzZI6km~|HJOfMih4ZW;+q#d`HE-#schKex zFD-Ny2$$ljhyhuIJ^91bXn_6m5LTCl@;8e?m864_v0dXH|6S$m8r!*dPv_~VY)1|` zhx0O-kpZn-bE@(M{tg<wn64(txsCDl@RjqM zr}*?D7E_2m8IoK{ESWw2WBOEhjIc1#&@x+9hYKBebUgNEeX$`;R~u(}KYt~b?Jln1 zTCXjT;;WK97X0#cY)28KQ9MOTZ{{!R6c0l|cRj($?F!+w%|gQd$}|fila|;?L!-;5 z13{&kCeR0=P8PKcnf@Y|PY7yCwsy1BleAR**u|T=JKv>aQB?j8IlX=67N9-aE`r zabe&RpV3!*=<7c6x0#keP{?xy4_7ZHx80jNU%>jRc_02C*DTa#(WPi?#HH|sPDsk< zIkn+fUCI10-)S;G&1~I1HaZPJh>wH@NLQll@v<;dU_+2?soY8CNJ!-#fCxjyQ^5Dm z_tu*%Dyzl;;j+p9XOp81i+}pF`@lRJ;1=C~AZ?SI?s=411Fp+p^O5TNigmPnM4*K~ z#q1*ag+%8M2qBX$bYzrMYvcV3?M9&cDK_E)HULIv^G9=CJ8}oX z+ilA7$f<9#E-$^n)P9N%Ce{I5)NHqMM|-HgWqP!_2h-(=LR+!Nl*L@}kB?ShhQ=xr*;W68;9-WEGubmE<-RDl1--wDbo+y`G%z=nk z2;|A^1^08=JCO*S%o@?y;Efwke;Nfg0l9e<_Acjxq{&v*3?qxS^s?jWZ`r z#wFH%(?zUsyx+f9cIGv<&_W7L;|=cw{mD{cla2x7FH<^Lt2br2wdHL)e?l2S8I#$- zHNqkvDr}*~b{w$W&LSfMhXj^zAByY+{s^X90ojijWdx(PwduaO*Hou2QuW2!pC<$%Lku zmU-}#>8{C`!7TI`)C92UMjYLf^k@T0)4nGO0_;lPhVMm%MD*!W_`p3zjP{bi*+q^` zMNd5hJ37cJU+yv@L2l z_WRDHfFz;VPIT{8ONN||0L80*Bn^P;=?$#|V18%3m5Z8*E+89F*{7KQ7keGd{Veme zFY*ials-AA*{LclD!l$psYa#&uFzA9s|#r<;KQIfmbxT@h*W{UWNdqXW5f?zzK@(E zM$e(yO=X)Y?$HP2ddp#;nzpjqHn$6K!E9}jGq<+@aBVRw*>n% z(5Zw^R=T?OF-yxMkufj9{dzVgV{J@UQ!zDqzm8uX1qCRL0l1I`9F5_;BEB#0t79R| zVu72bNM#9fiR@+}7xF+6*K?8t0E#sOg1C>YN)CiL4ml87y5+%8j)krx;)OS=O` zpSMK|Ah7VnNMo#?0A&Flt>E;AG?0m$n+)D#Nf}q38bz4&stN``ts_c_8F~s2=2)D- zcxEgr?UFcdsgjM6Dl1-2*%?eArKa^G9U=3s=R+mJ^Q_5n?X30Vura=T7N28l>&G&` z8?tgINwe9ST~Zu0we=%X%7pfKP_#^e@mst_MKB`XrT{H2$vW7@J&3q`TsgY7V=2h!-5>6er2I(6 zrVfrN{R!mlA|xm}n3tcBuli9rjjD~W{EHV-Cd_8~=hv4N&hU6xm zTpUc0%JwV{1}qnNfr0Q)_^DD0Q&VIV=RJD2{DCk3*&wn`kN35M_iYW(3#N+y>7EfM z!P6RCG0-GOz_oOrQhNq^-W{M?7;nN3p9^}x7n2{jv!IPFZB&*|t_*napL&U7ha1*~^)lc-L$V^1pKg?-aWO`M zZ(I4`zc{h)$}XQqO7e8Ey5scf=i6-ASK-9_eVe&CtYUIA42;Pjd$O`T(ixzFkOD{? z_6eWy#XUcg%h?^ey9b~>mIj24=bEuxrgJf5-AcG@qPZ~pVBxK1>-?)$A;FiIy1Fz6 zBOiD+ve~BSG2M@8KaKm<+YRA)-;WMi(2kT7h3H@IYXpF@XQ=}@&2*hSQflc}gbRSv z(YZVx7z%d8A=_Q}r0K?~!3CDt%B*I=j#GwtHEI66Z+t`5_XjVPk2K>OvABx)dPD=? zF`hMHL=T1eS92jUy-~8>r%m^;KtZ7X*cBDxFA?zt>Cb z!&NEN1@1$J=m&~4oXyzls+!n?>MIO_F6~a4$=-I_CBK@zc9@V&d1NyeLaEHUc|rp} z)7p@~BU9yfHOAGHKXimXBXTB@NEQ97$^|NaI?mY-x}dE!%*&|p((f~|{<*o7-@?M@ zI!Y2hnP|Bef{E`=ofJ4QPhLgzjlHdy{O6P$N6jC_yUsoA1JnU^(6no`7{W{@nzyg zXFpvvQz6lS%OtzzRy<+Cxf>2A2SuIP-GF(gp~%%(mZ=i5Yu)E$9zAFiBjaqwWFup` z$Rj4a)_-h#UP~s^^xFJ9uuF`8K6Yhjr60O~pSbUnV2)k$8?fP_Oi|T7_sSFZTxBVO z0&SFgXe;dmvNnLwSyC2T;G-~*lO9*4s@Y$RB>gpvgLw_rYP&FpTQ}#0Uyt5>IFXM_ zvSg3%4OesU8w(pMBmBHSZnR2O&(-xV5k5%-Fyl0XIJ&^0_HT)`o}{d4KIo&&22dq86~i`1dDd`IN;GdSQY3eW(%VhHU~yQlbkD0 zH?DOR!$diu;_^C0MqKaPNOmfkl&()O6AyFm_ibV*(vQ7tKaM2OdX~HFtQp#Vrx+@W zg~hfkGu$WsO|Getu_?38fJ)1((?sU*3Pftb*<>ic5%HS2EY4CsMGl1FCQasHJmPX= zXBo0QPHi||%8XR}!kqj{7vb3>9W-vW&PZh+?_cT26|w`s4s_$#ZNE%A5tUtZ@+W-+ zGdNFSu%YPnszs=HLvE%2K$c(w6|4K29z=e4bZz_WA0TXs=KP?41II*N3^m((pjf4y`8zco(X@I{CAtRe@ z?uFWbFZ2;q@Ym+$Fm>%`#gRNqZ=l&#eAQ0+ytG<gWrP3B?ZaF7hyZfhBnq#m zJk3a;br!}a?}}znw0Jb(_HT|x+nufybr09ptl~issGst!Q4&TXwAbgOy~)#MExO)h z1#Q^LZP*~rI;!r_)O>XN_vefIe@dR&(&y1}`(;H&nRjo2wasZL3n1N)hjw{My&|)Q zk8W%Z5v#2q?fys8S-3U%zI}L%M!KW~0m;#hl4f)_Od3Z@^A#AKqorejbVzqer*wlL zh;)h)Lqddi{*L4Q6Sf`C?&rR)&*wboeb{G|DZyw?I% z;&aCgnFRzeT;V1MH0-46Dn86B%hwG5)KGf6j4ciHOA#YitJ#@z+i%Z_|2(I{|LGP6D(`c;V3 zsv_6`-nDwzLz5{EoW^GQ>_M%9T^;r3KYfqqptzH$N6G(|sHPKUS;7l_zMTrU$W|BbOySq?XvpNss0u>GYB`$kS;*4?><_R_*S8*uCoBTK0KFAUp&j0Xo1ejva1;^TX) zu`|M0R`7OCQ7&b6(A{-S-|FCwSZgMF&O#{tOu-ZD>~`U^t`$4-7a7vj#tW^-ldA5M zD1bbH@14=1Ba0s50=pbK<1|=;G`im)kC&0L<%9z9pjG;0etW)t@sF`ko|SxW$mi9MB5mebg0>STsek z&{4lnDNu|7T__cNJ|Okdydq^+XbhO4+*&tEDh+t?i2KDW>k=+NF5CD!5!Xy&`OfL$ zn?1JkvgaCe(P8IEXERjSiK1s z26kha%ax81H2mb@BI3%KiXjDm8^zx#EU-g5XzVVv;J=Jn$iZfmu|kL^E^Yl8IC{xN z044B{?GwmieXsYNHx)~0>r9T>E6-l8T$+G9>dq>+ST}pPrJn&I@Mk-FJ6HAkxd5}6 z2cXr^2EKr>JEj0Yfsn@f7yS4oz0W+XhB+ytrW{xI1 zmZ7UNvKj3b0rb)68XJ^&QN7VO%I-K=1$WKK;()fO8MEx^Xfj0uv=#d_4$(ipRUbgv z#mrX4YOOxcH2bcIhJ`i{A>a$toI6t1YMN8d0U41Cno`IqcVR$$NbF&mI z)Tu=Z+9bIeOfJ5j>{m!m{t$w-Jydo}-XFi#qL+!7L^fgqZBQc3twDyqdd{M^$tBDV zVC-jwTaD-`uR!BrDs7vG4#Qno(KzXvIM=Q`rWFB>HXS=xoV;VKJ&nFAK;GSkrs^Y3 z8Q0si!#EWPza@0-78c}N@aq_RLaiWa5~I&dhl=~rp^t~ws1z8v`eo2z5<_7wQNxlN z6(N~~p&_i1Z!MSU&px^OL-DC=7A4(UrRH~XG52b#-I~r-sO>5=IWX5W`GS}F;I#SJ z^3VgQT?Z92LZ5wn6uO+kS|}7c2$d9tLKiDDBN;WqnKRuYn2)%ge1>HA`S~?)UiSW{ zSd4rv;z6{-sxbET6l;nypC$$WI0K>c-bsDaC{g1@xqTRbf{s~?`AjK)wqk&OEq4=h zj2?h4Cc!PMfOD*u3@&o|)M-^=Mx-9tM2>26lNop%*&s^oPL*}S%QvwH!mxCnj{gRJ zu);$uP}6*KkvcP3Upg@_X3P|tMkRD}inI&(C4h9O(^3R(ga|gFk`8GrvZP;GpRH-T zRSDKYPOJ?CbmV~;pTESNZ7=;|r51`vb=1hV!RyN@!Arw0qDk@63c+73OE4uq>UW~p zPK)HY-EkjI?aIDGZcK@OHzU;IIwg5A^b+Nx*8MhK(?_CdL-NqaqM=;Q{K2)zLctGB zv;7<^+Wlfi{2f_poT$0%y<3` z&k5eJR`N-v1KvEBXV|p+D4|biHpzW+tCUMDqUY2T4MX|(V$~@1203mF{}8h5!3h=i zm_Z*<|6&mr&`1#`HZ`&0>oU>qvDXSC?@p*))%HYNcYfp#^2eUwVj!{^{HftfRTg{Z z6cVxds4@iu++U%8zt;WDHdjb=(s1ql>kWXpm!VQ(Ej6>lt`GW8`ErxXL(jX4O8(Gz zPT)gEH8%eK^!d~B^zk<{33{9%<{LA0;t*XyBb8t&)bsOQU9rd6)roHC%J{H9jvIW! zlyLQ;z;F;Sa9WJ&HLL?R))LqJs^omThmPKR1{1dqd=0Y7sGbVi^A-1g99qxB8YYFZUW+&Y;<;K9$^1oc->P z*QFtN(t0;cFX!N&rtjCM6TUy6{mz?G^V<993Kb{Ub^HhEH#VO-+2!apJK20>f8ds| z9=J7}S^NIr4XGNTrj^7nVcu+AdrLyoTD*<=4$nQ6YPTBLo z1Ma)^MVr*v6&SFN%N#Q1kBT+QQy-yCE?cE$)}@w(X>SD9)9CuDKGvn$PT6v_2JhSLS=xVq$%~gA;6! z;+_42#%cL0d)JS~30Jgy5mG2jT#g=2p9Qb2Z7h#%qbt8vRNc$2(4VyYmlkM3)#oq0 z$g%@!FUCbUhhYe3|4n4(hu`-HGE9TZ!_}Nu%M<1c=Bqh+G(ca+zSS4l5E@22XM5Xj z(0-to+EcPpGzF0RMoZ{1E`8j@yUJFA3lv$k_TuQMjG2x?iV;8Y$Y(z0Pmx2d=`9!*zZ)~J+97V3 z?M=V0OT!EM(|MH(JeE4!;HJ}5yU*ZE{K3cNLEL$%@+#dNG5P4?z_pXiLsqAwxXQOA zoDODR!Bz8RsbY?op|zbqUrpYA7LjxFuPT44-faS(k-7$h-ja#`h+R8#UQx9E%(Bz# zV}|CYX*v24?+y2EIBQLQf(C3;eUX zJVCc&g8#GygVYG8L?R?357*ea3x52xCDsGB%{{5``wfVHwug)RAR100|NbfNhetx) zDb{M-x9V1|2@gMJ9wrT~LR-VP~tSrbRa-6ULg zO)Zda8qXvp&s~MogJk|5Xnq3K-cdFQ6zZO{pX_T-d+V zqv^MIHO+?r9xm5CD}e$O^mAnFN%OUAt`WL-7ClQok_SR%wj@ z#}=?pn~ncwicbT*Z`5(}E*xAOTY2ZHH1B+JU62Hkdj|iDn-8W6DDFk)bh=*WH|{lM zFVI{R&oRW5gF7YD6t_h}GJh)y?%H>qORnQRJQ>3zkp%273q5K(wVxw-LDGLY|FO1N z#I^-zW_(@S=Pm)iXpUu~sk(od0gcq&4CDx3s1mQhcZ*Xl->OPO8aQ8`cijJ@giZ0t z`5|~+M~UbjPolUB7O&J^SUx6C;m@XPSwame{uXSGI}F~;)g@w#x7WF%`c5O3oN9st zkF?$-tg8?Xd)B{h2{1ZlK|{aBw1ZOiE30ZRyyS-131n5Wmlq}S{;~7EVYXlOt}&cI z{0Cn`^rc1b=8{tv@Q(zg+|2e|fY>6HBAV8H<|>6@4kB?(309WvOOSR?~ovk{(rO{2&U7A z>6?{WpR}HcXu5Zy=L?Dkq9J{LHEN#@?%Z9Svf?G>)vNc#w~*S}TOydCfRRhFn5;+2 zi#IaA-(Z?d=amK6fHvo5;&xM&fg+r*lt*10=oaZm(tmgkYfQ{u=)A6->}&IL@UQ=yOZlY`zxymQ!Xl3oxm=U zLg`izeId350S_roF3?|S>CsnxDUyqt%y<2Tjdk)v82P_J#9%)c^b~+> zQxPEsrUuE@Xh{UJu|Nb9Ly8e#H^(72{#hmGc+3YDCRI0}W)Nwr&t|w$k#k&;^*Ii5 zEFx{Hnf^<)?c2PwD04QHpI9*_q@e6M{*f*lO_^iq8I0Jjyfm~wgOEBnOL8_#evBkm zV=|jy)h?QbC{$Hq9GeA;wujW;zgF_oIc8Zp*9#j#E^5stz-V*6W17m^36eat(Yx2m zCOXfqQekpFszeR~kD5fxwm#c^Z1?MMul?0CTWX$tAwS7OmtE*>_V{@i^nr6N#Vp_= zwf69k%y&?84lm6qS^%l)qv1?cIta<#TDwGf#|62)!4ubaj(o$ZFu{uj@GBEbq|$`Y z2*uC*xFS5qD4sEFLLt&J#7w(!poe|A6J*u)m~y2vE+A~ADdOxFR&m3qIMpSqlFpWP7l%Tsnn4);x~ENup>RH~ih1v?hBJt^Ft*zEbV3wc6HZW~(= zm$4ul_j%(4iAe+9dnQ*Zg&x3r?9S%J4A(KqxD6_9%>crq$Wdn5T267U1-zsw>cF~7 z!)jLI4#N?rNbO;mI=|Z8xJtMe*JoZumKI>il@7*m`Fa|1zv4jM9PX)9w7n0v_hfgW2=NKJvQ;6q*!R=D!f*h%&lj&< z!1$2u<29aux2xnjUnt2Y-D^q3`*fTFF3?P+w1pfsRdg+DGc?7W-LM^~WuUh$_O@}> z;kA|!Fg5wrEWnsZyfO#+s6PMY$#iUuY+nA^R&Q9FcaeT<90Xk1)WgdtWgKSB@g_vm zb?pZoW1PJ-(rSRi6*ivh-g!aINJFIW{Zu@Q(PcdMkfPmXry-K-I(=;~X|PrmG>nM^ z=p42~SvAFoDPHYg6ce+EJV|QXvda+a=3>r%?dhvAW(XPQF1{g{&z^6C`Kfj9X&G7M z=QEGm54?B{F&q{y#SE5iYhZf{Zuf=xbTt7HUU3Zkym zUb1md&lAO0#3KI{b2*tA14HG$pYvn^Z_)YgUT691i!N17GO$f}F~XK~XD@J5^&;7Iw#~<~k}wJVN7Zx^ z3FXoN9-L-sd&MQRB*q=jKd5nBe`GgE7)#Upc@c*D93ErmFoQk;sXX+5(Dm|tMX z`ymy-%ERBw(uH8t=sw$IQ!Ko>*3TMpHuAL9)&ZVC9)-KD!AsX246#bF8=Y}d=li%6 zZIxPQ86p6Pu0A@M*?5Xaz^mydLU1=K3Mfb>MJP-3Tn%Nu1DfGr^o*twil)j0jWf(s zXe?EY81MjKHCspiw*+(egI+$au17@pc2~RJco$#<77aoJKsS&iOmmkZJykgq&X~gV z79)uw&`Q1uA#lZ&&WV7}wh@sQxczEK0N5G{*a_=LT8Y(=NSk>)Fx4o>g<>ybp1esH zZJ=a^>EZ;aS$mnBNn~RAoD!aoh8+bK>14H5R{@pii*jY>7rkgw5KJvrS^`zb6hl)> z{E*ww27XilGIsYX_XdEs6b3u$Yd*ZJphmhBM| zr1syT;e;RJ1rumXhq3^}*=Q$2M)p~4QEgQ;Ml*?GHKn`)6;lM!m8&_xrE&{u=Gro@ zF1ymLxe8sq+&#T7%d29qMPbCUkaB<#%(N+&45h?nC@>w;8k>i#6$O5V1wcLH>^B0} zu8>kR)GzVx?T~ZsmF5ph^;H$BajUC%PE3s%*0hJM$%}+I zRl(OLc|e62Dd@qMgZV7Qz;Uw+3Q-O4osM1W5n1Fud3OFXXC~<$klNx6#OPT%SWPPA zYP3^hf=G9SQJQOS$oDKvIiqxVCGhmu?bPd2HObN<3+P}pjnU-x@)p80sKs@D(%Xm9 z)s=zImfHG-ucU4kX}F7dV)gVW!Ww0Zp~F%z8Yaew{oa!XW;dx2kzE+@+4fnQ{!Mc} zm0Y_PuLd`enb@|HIPzs&SIftX+>)_b$^@)G;!gaK(TvW5vsT(c=u@K!6d6TPQ3I9F zNBQh|FFe^zH70wl@j2I$wsGqWc;r@UA3m&>Cc_g>CFP|rf5?ke>MLBr+E=D0j-Y6M z9ImsxzK+BHYiY;#B82mGEL%z9!+e~%O4CD~$9AZpLl*|=(gG(F#h|Oe844UB>66Kg z6tZWt$+S^nFa0)*i8E*~W!!|rO6h=*#A1DsNL46urye`i3#XZKYRCE^%me|p($PH3 zC-*41DrY=@1o)|0slA(sv9`sUB_OKcEshT=U4Qo4XRk%pfs+S{2XeJ7{``{82FaqF zCmW7m+7ceG@sJ5alyF^mwyPtQc$S_&57Cq02pq5VA}o#~AzoLCw_t55ji+HIB*Ai= zzm%jG8t@2*ilo~YA#Df5K`$J|LMfvW&mD*_XE-z>=ffk@j2}XeI?HUbn2gkcUutMC5Z&7 zbhEqapTQlzUE%FwJ%5xmv1FgTqI(pugA;3)VW{ML<(wS~3(7lLrs0GiFq%BMtKeags!`W;L#(uoA4)xJKn)GJjli z1~j3zILrpS1RZCFkmN2zzA3|@`8HJ1gsLR8lLDm{3j5&Px2y}8nDLK=|Cw2P=^x1{ z7s8t@pTs+`YGN`-PIjt&h+(;j8S~N%fR@ zS9S_4x+;8y+3kAD*5e7EH|-I8>0}nud9oQ5DMD$AHKdi?-&2G@%MpBmbizMOFhnFm z7D)Bye-{nUw+rRQxzJ)u4UT?hVp4-OhpUl|(GYKi_o0p{h@{!x|-+#B;u+nCgLiJp|tz}gN{2j6R*wD8XFm*;B0hVW&F0MFqfYeX%v zbC|Lj*;Q6%g;S>cjk7FW6T9OMMkj`0p9y$1U5=t|o!U?8W$y^6EQCE3D|l*nR;%qC ztIJZ1EdKtK;(=`mZ3!4-?PJ_fs*_Y45GZL^xGgEPl~sf5jnoox0GfdS`hw2rE#K)% zlOx53LHO)bLE`;WO%=^unMHx zF1C>ycPAmpFEQ8r0GuR7;oFKi#PU|1c@5PWNJS`P)LCC@$3Xtxp%Ki{7KF7=p7}mc zuO~fo68@lRSgJNepd|Q`3D^f-I8;u}M}DPx?La?A?MCLJ%!oz65DNCIqM#MF4-)~6 z$Fvg)&e><+hS2i$kDZE&`#Ci*Ph_TS&5!4qxvB$jaRn~!7bzSXQ?UCzC ziy@AL1jI4;_&gAx%dIN_(Zmq8)nyV$XW%&PPV#`awcz=m?b2 zx3BJYy9E=Wrlc#xE(6*W63!c-&nzAzUIl!YHfQf@yfSa+NPv}(FFVEK%IxC%fLlLo z@dLP1u-Z3ZtvKd22-^S*3B{*F>FhhL89ZCsK4W1>RrNUwau#bGVjh}5w_B2o?@{;t z(otc)x0NjtPJxfx5E2;|WAVynFQYmn%3tDdHRm+ZK_x--+jbr;`oT|Gl|oKgDN2xn zl=(FSL}NiY%8@kHHaXLIAu-suspx5(m4nEN$m0HvQ&U-c=~1ek2a(6LxKzTkoROqi z=`?oqHOfo1Y!+@53?fg}dpQv?Ne^|ytM_OQeAyo4dOZmN0y3X&VV!Q*0R3tGEZcp; z)s%Dz*^r-8K5yoHzu*7o8~EiOacu(WYm;8#vLqhK~Ydq4gjlqdI~^*g_Pd1KSC%K@8? zV*kVi?T20M>08cN>gq~A+Nxdxn^Kc2PJlpl2C#40Zz0*feTc_)wNUrH+AhfEUgiWk zQ^}r$Eu``02Az}m@vk!JaLEpK!@mT+3azw|GCn^b@)B_LK020y8h;x- za7Z1g=Dt~Z*B=~2uq8G6{boeeq+<6Ax)O_)D&7Lh2=T9M*!*3L>+W$j75Gr4>_hJD zDe`;fitANo*3&StP`sw^UE)*zza7#>H{6*yGw(oY7OLHbGx(d*#&aWM7HYp$K?Avi zQA|NM>m6RpafVe^P|dk!rR#4#`NsLS@3sc8`7cp^{-Lo&buo$s*O|0EH^cn5S=quf5z(*7caFSWcovXXW8EvQ3F`$(U&$*k}9zrT1t<%-eOAuwe_ zUsjMb=e|PlIfvQJV)K?l=A7bzY0N#qn;Q)Yn9i5^73;oJT)sFCg65wRCh|EyOp7r$9_`v)ZYW*-a(d$8#PLBul*6g*)+aLHNo_@VG zEU}|C73;C;M;}&mJ0;so=Ult5mhEN!tiop(p7iUu)wyd8Cb7$qg_Mwmc^cwP;^K=$ zkjUG)ij-J)oEYaNit%6*+ZyYDog>kqBN^`%kE$^hF<`x#-#MJ0gI9mtK_j2(Mrb}~ z+nRiRf{1a%i2B?7S{h7SoUto6aC`b4Hv7jEmQ7iopO_M5wBEX>SBdGQuTj5kGSpOr z3o8^jR|Ov8C(wIKja8g*M_b*imA`07DI;MwozXus%Fl8Nt6Jw5bo4VDWyxu#tS{WS zA^1sKDI=q0mAx8z-opSuSlYP6_GO0en`QT$#oE4zoNFPFb4b5(f93CFeSbEfa-Grocy%s?IaUR zV4HS2;tn8o!QaEibnus0xV2YwtGel_`RgVJ6(9cxzFIkRMoVFcem<@~XtF@maTZ>C zy?wa?2tpl?3s1!O^uC%cGRE_RAb{etk+PWN!2r`QaZ;=z%!f$dg0{}Dyx{CF!4iM* zTo`X`2iLXZ#V(hzf0oYt^KoTYVKUu>2whNz7M2kIe4GFGhy9zN zZFgnCw7Q*b^wa8{>E`}f^Db8ZDByJDJEJ?0lML}Yapw5`ROQ!K5tu6z@a=%=Y&hP9 zp+A2x&F%44dS;;}ThT~PW7k!UTOc~9#>MiOWOtMtiqe=7H_apB&FA>F=GOade4d8- z3nfI&^oz?^)@*HzQyB3R1KS(H7v+@Eg*C&8%0OeJjNq1?@}PL7)hRB5v-{M=?Q*40 zLDT9+PH(-d-{F>jz2WW`9KGTh-SE5F{DyE4j6hPQRHj#f(*#NDKH(tIkGZZL~8Y!}EWmSWOC`h^q=e)0iaa zuNi1Xsgn?z?zJaRy@w!bBvxGI`hXOZ?5x@=6SzDj(0Xr`yd!)Sa`%tKhILd5j24tc zpujKXc^29f@VT+AG4E$2{}Lu&7<^b!Xv8 zBVoc$zV^mwgeFv29pd;5j&N=RtIH6DR=+;~jY%t8v9(}AN2kSLlhJ5RkE4M{)-j&L zoS(r@l6LQa1x>49rj1{#M(LI+h^=h0Iupce2nZv(m_umT2Ar)&b3XoIRMT*rp92*H z^qVvc!wDwrLnrmLY+8=rXpQkwSnUIo9Vd&v#rFNznC^xZ;^O~K#0)M1ds1K3 zTdnjT!&e%tAR8Mpd=E&=I{(QjIK)<%2MOu3C^cZf0?^}SaBeaylV<4@M z>-N3d0u;%YVE#`7Dp^m7vdsi^4b8Omh3pn&+3VS= z!novVm+ci9LrsH9*tmBH9;A9 z&fehgi0_+xr>!VNC~ig>C&j=ISNf<@o@ZI;m5?Yw>- zaWvWj`|knzFkinQ!u}hBL5DN5urm(;U`-t%M8ZoN1)jvsbw=9>LiAW2C`6?NY>v)h z4%A}OY1R>MRLbBAIiyvbM2b&+ByKt1)NR;;d4pp2MS=>XEjiC5jc#d6DUc zeA(WSLajg3GYk`Q;G1v8QFoWl?xL2bzyI*7b>pvWS z<$|Vl>PBk}@}E5q$+HN=ySphUCx+3@VK{&#pM_urjiRQsDz5dWWV%+VS!8Oo4rny0 z09HnLR@vto0?zL|4N)}Nnn-*$jIn`X?f@b8(Cv}jo-S1KT-(cYXRohY4dY9*4q4U@ ziEF`$kDI$}%D-!snet0$zb0#uO1@IwK9wPMu-bl_$hmJVD(N|0#`vaV+sr5}>Hg-q zv0JyMdAobzk|8BPEuX$gIFb-#SRX~GD2n0;>qlpGw$Nrb`wv#6FPGjko05MQEOLau z26&j)!V-8vbdu2XqlJ#&C-*;oK4#5=ayP0U`Q~;m(X71f3O}$kejn5P#vSAdas5{8LIw23t*v&cXz}6?3iC72+Zy$V}UosO+mNu ziIO*Id?;d;%4TMh^ajQCYY$$-Srz}cKxq7IG)_%CsiAXAi>Op~c!zS1oYi+)&dkks zDAslGP&^_i6%05mjEY?&3Sat%WNh;i3svR+c-S*Dx96x#6(>#C;P)Sj+!)ef`rNS+ zQP?ue?Mc$>7%SttO<|tETL#e3fi+zDx%5e?I$jJDA@7lC~1s zkaxe!k!md>*h*h8>t|Vpv7cHUb8@LWc^X>l@x+M>o)NhrYF?sRjB8hAKYE&SnVu%t zZ+WM-n^DMfqtaT16Rha&+IcRQ$E>|o86vyBJ)0ft5Nf7I#~!U2IDqCgD74e-?2R*v zZxL4fG$*cp4Q(2?1CRXKyTA2!7X64v?bipbQ4+eXM{gYn* zkB8So4OK8qZC z^v%Bnm1Sz1ctmScVDjjr^`B3xeO6dP&614#0Z08z!EF&DQZhXT{^-k!rxwCdH!ia4 zu^|YZ)rTY%lg4iMB7@P)pE-a4EL~q#n!AS& zz2ON|wppTRU;95$&5QY;V=rZ}mi+fXmPehe&qxJI|+I3(tSCUTYW?o9WaxPb|C zWZ%hWZ?ePOgphmVT!68dyx@+mAGZ9Le}z1f%jHyyWo#*ozmdiv@`5|h(4V(1=nOYl z04ShX;rjT1rib87v%;r>e{OeV;-^55ZnwS*a`;%Tgq8C=p>@plwFnqfx7arAL-nO$ zCDea;e52{;@4)RfNF>Tx+#Bzbuvq@;qkS0vC+=e5i(z2*$$$^{_p4f;)e*q4Gw%;J zA-Bm7fPXn4N@Nf2Gt+A{>n4gQG&01Q*DRfp-vU+i^s0TZi|cRY@Jm-#FWaVWG|;3M zopIA(o^+{xfxZ8BfAk)m?#fh0j4URIZX8M&R!>eN#+J`W9?wVu+2r|>zVWPaU+DrZ zh>a!dVhSmFA}pXV-tYembv?t|e_q`z7z`d4UxQeu17yVe{IRT0nvJp8_F$rW)UP-c zZ_&7ux8-1@A$#r%iTr;|*SoOnLPhJn8M8m*cHq}>P#&WmgX@3cE(e7r{7wG8KSWf% z9^!?{@fu#}%hq>4G8P%+^FC(Srujo+GY*F1`0&&8dq$E^$BQ0;REyb~` zY|8D2;T=!wGLac=cSk1K~3?UtO$8RXU z;`BJ8$tHoLPs2XojJBQNnS|bcP5Kk}OG2V$whSfJ+WGSnh=Z&aH)HYotM-1x*678} zc#b38HHXxya9Uc^rvuzQkiPD4KwSxH zTluVeMAhkg;E{G0C5fN&>PBtaOJCA539DX}@&{IwCzz#koA`F`i~7OF#k=2pTNsBG>4 zPJNLXyeTSAQ{0F3s$QdgooiAB@c=RLKni)KL$Ztg^58ZW5+~d2#uHqxuyWB}`n}hl z`ir}UE!QbAE&!;BzV@e}3H~$oHJ7S~l$L0b_(tFL^F@_j`JV|FE4tj#Hebcxi-Sr_ zT(s1x-sHDsO9wV9^%NYh&LtA{Xr0&tLi*ISLSCE|T*v83idmX1=KjepWWwBRL$63s z&8Q+%*Zhf3Kb-+S5+R1?rb2#UBdQ^=x5ruj(_VRRR*J_W`mk_ip1|90*AJu*zO*=4 zpz*yD7$zRT)pOy_sF?8s%R_~*`B0FYM z`fwu1=nP%(xSrtW1GOoQzo#2;{mD(Tzbs!5&o!61V3h$n;_-a4h!p>VHu`7JURa!2IQB|BXggcn`THwDYXC)`J1%KkWCg+~J$EDXPj5{8{Apr2?o(@j zT|8pp);mwqVK=3usk@b_Iw8;K5JvwdtZwcCg&xQnQ5;J zKs8>fBhIA9P!p4XA0nfm>~!o_h8syg%J^!JV{8*Rr;NZGgpsTc0N`!6G#$1e*q>cf zigiRrZk$+%1YT1x%VL9FmLCnsQ!AaF$DCPIvvPgszMp#^`29MN@xM*4(eR9zlJRfK zLyYANaPOy@M^UtA+~aSY9Kyv%*AEOJ9tL`MX?#pih1e4G{xpmo&weZ{Kl>f82rG)x zo_*PNC+*l|eCm~$^X;JBW|&IW72cZdY%USxY2q3K4NL2=ts15XapriA15rwBtlCJL zl!08oPV^XUf5pI0{Mq9@2hd8g*_7sA*sl87p0df)=L3?_V(bx1?&9xiMlarpr^PY+ z1^=SceRVYPUI~5EQG%TOv^H)L9*?cqteJLQUec%KtyQ0~|0KW|uv*zVBGYDt-GaeT z0yra9TEnr}W9~;KTTc(o(j~8c6$xUFo`2PU5Q~aT?GS_%mqeq9(@a6-?Rdk?!1tJTNNcU z&9O(2ccgckSCRnv^kj~8T)%iV6^N;QNKfz znGN)OTfy*x-67(pPYtKCq7#KZ(E^f5j$o}#-6GsKqt+A2_T8&2@@la-H>etN#gUAZ z>g?hO)515xp&D$at~3U3F;}R5ywchohO;B8pI<7mZ|eL+0iG3CD%VM8CN$Dt&sFLU z);_`Kj(hD0TLSIv)t`K`M^6Pn?S4jj>bXg9kZsQDnPg7b3?{&9ytZXIuFJ=~eJN{^ z1QCLBaPHg+QRR79F!9LA&6|xn>llcTtuV%7K*3BS?M1I><|VmvoYZx$3<~N{WDYOX zXDJJpoNKPYM*4vkwv-f&WjE<*R1`u9TtH*qj0xz}81cw9&MZB3Ew;_lZHB3V zIu^o8#cIA`IdrU^s~e}_nfya?UJ9j}ceY<;h`Wh6CVttd-GaKvU#RYOQt*a>EKh>c zT$=hU_)35pyZY6Mxlqk8G+DfsX^8Y}C-%weIQOO0b%E%Zj!kplukB%R`$rTGw~*Rate|hxZ5&=r7G{ z_s?pi#ha=U3=~~L7gz&uLkU{56BMM!psUwoH=u10dEZ*Iex>B6Z`D4WS{2vcbi=VP zZy=@XTSFYlhX+-!5>ZG2zlzNiUc?sJlrdD`iU$)Oo7Ys@ zhf=D=;89ijaYsWHs|G}B1}bmUvilUOroAZv?j&^tc})H*l*pP@qg-A&CK^On8}^$~ zaSjS%-=>wdz1k=;Jd5yZWEf0wb>#4?>a_h0-r!}pHoHHU`GT6Q$I{8(A!j|Oo)N~a zS{I6*n>Yb>bp`;RO^$*6Ph{Py&feIjr-5{7oX&W`Y$4W)%Wung@<#^2e)|XwPapyl z`FO5Rkc|6Y;1OdyEDur0EOpyCzl{j-!e?WXAMB{f%nZN)Ly*%8_edS8TEkIOY{#{Q ztbuV-A}K!ySW$f%!jDFcmt*PWfW7?1a}Jifeho1;`5DG0LDD8`RP|w7mk9k*QhW`g zsEY4oi!F9+I*zB;cQtI|BYP=w)TvdylY-UEN}AFNQIfXQCz%e zee_(Nk}e{@SaG&!pvrs;$R;@6CGURUB6sP?85Vdf%L2SD>hmE~42RIUU^N>0m^bJp zRUl=(-N@w^^i^Qj zDAr=_>d|E3W6D&)O=V?;s=Vn$y&w5kyFfMEVx7C=>Pi@ZNSYaG4+KOlQ5TH2foQT% z37xJ*{Ac9_M6rw6c4P-t_lKWd_J_oj&NU)Kqcx`!w#fmd}0DC{}e?yWr_C?r(1(ZK}@qdm zAZ4;BptFIdJ&bLHN9Tw};1Vi=03Wg5yC-2h__Y}qPfp5#k z!GVJ-^BiUYrp6NS*OlLYN0Jt-_?2R0v4KlHYM6Y)&3~6U+Y(=`&ZhyQU`TiQCJpD^ z#;}M&%Br4dSc7MZY5?cUl3hzCcoT+=&{IO&Y8q+~Xpn_n$5Xv_k03;Jzi9EWN*bdP zjCp3IvRV^aV=d|{RHcDByJI2OS%MtnjwsLzl`VEe^Rh`#<@Fp?c89u8M^4LB-YUnN z#prF)SQIIoQYOnaz_5ZR3k&r*;U^WQ`8I?t{USoq=yJDzs)%oNz#o@K z=4qhO;kE`QDoyFpxCtZmQqGv9eFixNNCIvAT<1*B9A*G*BPZPAw;j*a#MR&gVrVs_b+<_uV|Afsy>%05bIH_}|l$_Tqv!{ez8PeDT zir%C%aH*=6dGVL9s)>#chx%=Y5-cxrb2cNyjefVQn9$Hqh~(bk13798#a5J9!?_Ev zyHphydQ4^AqjHrm26udJ3MWf-iOrF~CfeP4Cry1BJ17Hc%n;Yqbr`)$|F~3Go#)r= zv2c+JQad$)X#w6N=2v;~NWp3@Rmzscz)3laxw)FF0$HRZ8eqt?SnFkET;}ZRXzAM~Pbe2M$K2+1VHuk2;aKO4V zn3(ab3}bn1y0(7YwSCG$cM1cr$eoJ>czz<5MI`bj1T~wirn0y52k|IHZE3n^{oqFv zY|_=7Ri+!e^Jqr5H9q^Bi9uhS_u`?rGytI)lolyu^Q9yCu0Gx>8-2>~RPf|g8d$)u zX zYS_MLmOVBqL+qmP)3vS$$GW(J_OP|kXlnT^CrhN~?X1_5!GpP_uPAy|g#s$_P`Vo32)@b_F|^N9o!RddnYV+01`* z@k7{wc!;_tnoPEpFtdUXr%0ClUalbDthns?dfPo6yo>onnBxuCtI0X~nOhE6y1-y* zRgxcXppby{-$W`pI%(^#p=7K%HY?0BS{WXhTNxK}0iJlOn>t6M16vR)MOHp(x&+2* z^oV>(--!;QwL)H&0z;D(xXL916GKeIhV)Im%+G4 zH*5?Jz1qFWCz^yjjL4DwR*xDT>&BulEDbqLsdM3JPwKRVE>cyo_abU!&5`%l*s3ai zQbGeqnc@o$)$aSDR00@E{Eu3&t0Io4Uz`K%MtSK__x0T0InmQm5`fsr&ZHz-aj1Vm zQjD}wUq$fgXj%FGX5(pg92K-@bI2R~j3J9Lrb5o5I+nqs=1DeoKdNYEHU833Fq61a zky5dmIah~A|Lnbh6tvilikOAfQj}RmSXX;jkxcy!OHwPPZ&|}jd=Ww$bs(Qers z)0Y|6ntrd3wY#>56?&yjSkP4xEJHoMK+wQ!{i`aONrnZK9Wtwh`X70))Ow=1tLSJg z1D^E_l(95PKk53Gz(x8hyI^z;2R4T!y_q`{WUimjVTn#JIEcUx_=0T=xIJP~+^BOd zDfwdJi0#r;3Xkv$^_C(@_*Svwv=}9-%aeg_)uycHQSHW9SUcizcbGx5sFMx%!DEOr z$`1LXOS9GRIfo=W$4|9m$NU5dTsOY2iL(cW`-6WTba8GWH<2UNc7a@`e+3#xYgX|9 z3~hT(VM3PUJG_tRLG|8t(I7QZD~-)?eM`ZSr@)h)0K4=pvG4RAb|mDVez}8mN;i$t z&6M%b89yR4vdm~EmN1@PbJE(0g_v!i^4ZVvLgD%Q;ON_93)sbe#wJTC_8 z&sV;XMHV+FJOvuhojDoVE`cgi81f~k-a3QeYmjg{!e!H=oBXDssG7yTHJy`U|8-8d z7me@H5wkkZ^J9&n1QWpL49ur}!h^g>bdCh!X-y;4Q2=~bsz}v?#YOx2*PJpSKMj%m zG&$V@b3e%{Hbga(j|jRx9}_@)9x=B)l0>oDb#$)E_oP9RreP`TIjuF#!z%Fb5_7PL zOCDSB-5q-or{|W45MFBLJJLvYHc2{N`FZjxz<#u&x-|04?$7ytOw>p+F8``tu_HF- z%L4_x2kUx0xW0N@;xo7t%N|#lXGQ#z2&I}IE`xshEqu6swgwt%OYA3^^cHYK>9_FjLLGYy6W5rtr@xrFt?@eFLmr4UK32=IjP92z#u zicAyRXh0sTu?@czM&5PRRFupPH`j%;3mG(EaFYmt9>!403FH${-Ajo>t~jh9q8Co( z>CGA@U;-0ic&)l+k{-TREIspNdYDI|sT=3*SgTJK5k0X8=n*iFZ8AMg=aTKz9v*I` zFoc0G{(pQ|$d|wTWdbb&mT(Hxuxt&#@r`c?V5m0?hTOzh3C!K+kp6$$7hE!AqGw`C z6J?hFi(mYr{T)kz_%BI5>6r*xA@pwK4?p}cTT{T?<_8!o=i<`yL-_jFzn(e#SHJpI zvOr+y=_@PS9$Yy0jxvU(fJ5rVg{k6tt_Fj_LM!h8lKW|2gOYy4?g%H zB?9HLL2NH1#RL#hn$<*hDS@>m89p^-=Mb2n`NIX?wH4AVw|Z71oAPmvCbWlXkM_*N zES*1U*vwu=4CeT-1l3b{3Bu7>1+v#* zwj7G@8Yxg4kDfVKtVGwWBLap?!b=%AZ@^il!NFT1^%34W+iV*c07G%z6m62MyrUVI z+@p;B20SS+;Rkcq@6@66BLh%dayz<=5A*uVT0t%`td_%FInU`Y8v;N*EmrHOCQg_( zfPBp=QLYFyddCZxMz3PkXBn=b`josm0aVygU=P?fOt{j*AOJ}w*nw6=Q~57^;S2od zt%J0PG;GOww+WXsJmojP`OTCY*Ov}M8k{?L!TOoLwPRv7|AgbVS(ja5CH|C$!_euKq9o@-L(MzylAsVcc}nO=h`5gC02MWCyhOjeh&v-!|(f z27i;`+0TBqH)xrni2z%y#6szVAN=6$x8KeWjbWUZD{?ZKAFPzlhT)cApQ()3KPn&s z)AEm=_nCT$yQe@G8;mCXgZN-87Z^JM#z#m0@gM(j$iUE-~R32n9zt~^LV$R(}o`aJBq%aB~WVp6jZR^PewV?Xv|$|4cfpx0Lcy@SO?>IJ^u4A~NcwIT%I9a%MG%vuO*XDWafrn@fOhLAL6X)x3Z9p`u^&(zVm9R-Px zy2ZSoU6FCu_Wnsj*|krkK!}5Nf6H6m^3jielwgzeFd$RmHVG`+MNRj6_Pp?JFZ!br z=2|4NndlYK>?c3@Nyu`}gq#4jHzoR9A!NP%l?gE0v`<0Eipz;6F?TqL3CD!S-ydkZ z(7T*MX6uiB^`|PpII&I`s*72}(rnx56~dE~CtL(_?!@HT#~yLxYH_ z49vN*Ku-Gn7(x;}>u??uc!Gx#MJIU1Cv#38b7k~YhasQi&0iGq4p8Bo;8n6I9vnlS zSc}DkE3(5&p+v_G-rgl(!UN}Q4cpw53Uc};3g_HveyUR(86n`!$ld{3*5V2je}ZGw z8kr)qWrF`9OybWcSn`vf{A6!^<#4Kzf3u#S{4FQtlDk=&>OavB33SD#kcrjVMx0FZ zZ@8Panz-WZJ@vHDy}Q=JQK~JC>UoH?mwZgv=QQ*Hrs#WBzlEdW+*B8@Ec`gcR?WL! zION>cwI^W!M6~DGYVL*pngl(c&F-xOSMld=mH>8Xh8DX)k zi-VLN3@ci38c9gZVRF4r(5N!R!IYNOkrg#W^`|1trsP zLSS{Q#zR&kFki|`Z;+;z8uBPj`42nqjHZ-}A@Wj)n`GUXTPE_#$q9zT#G2+%EdIID z3+gWs^=EOX)TSW8++19wJh-8#IXzh-8P<>g;TC3wrx%cHL&nf8MriH&>#w%~+7Cqu z25%=QBcexvg712Y1@Rmw)8u{+g*ER3yhlU2l$)WdMcijcdg ztol6b>G`+aIV3aDXn07_)AJ?}JOYk3!`UJ+1dEm2BFqXsO3t0CT?}~xV2(dq0wmAF z944P7NEVtaLzp=0>Gk$OQ}_!_6M1e;apj3hXhV7cv8149uGi?11fYS}8k%D38Osy` z_MAlcZ>aCGrx`pG<}?IUg^RFW8+^=R&ZHGx(BY4&rwwI6rZfo%#7!T0NfDS!v&!t{ z!!YN~!Yg&LOi}WqPnILWE^l%3tl+HnIzLD$BAy4$o<`s5oT2j^qQs}RD%Vgo6-M6{ zg;Xxfq=4!}sgWsFxLLhY(-W)_JtUboakYxi=OE!JO{HXF-_lqG7}r9#F?wrc1Ay%| z=KR1j9M52z47dNWizER|A8oUcWZ}f3r_qfoGKO=Tg&I1v7OL!rHfKr`wDqT z5g`-w8qWzw?BPomzAVz@IdAkDd4>~+J{b)r?XGH4{Q;^&TT!O+vPFT9$Z!{iMsZ_R zNre-LW&|N<3@KqEuXLiLG1n%2>m^WGm}MKDvRDmAPT1vk0*zRufGs9GN4p`1gu>O! zh*CX0^_rU5La%63ey;~8{}jftBE4*KC$`xb5ZKcj62@fXW&2?UVbumkAHMQ&VyZ?@ zDK~D(rfN(GwsMCZnT81rhWdtNhE6@bv`fKMdICLVyNXPce{1YktzJ3IKUiCYNno>r z7K==&M>z78n_LQdNG)3ew>c~*B+rt{kDjI}y~k3IcR4o&NhuswT;+$52_YG5_yAW) zhG1H-)Aw8rTi%pcKdUBgv5Jdo%qa;B>(et_5+iE?s%@(&YeH*1m;z0{2q>ndoXYca ztYmpU7XIkqGxuB(hJc~M&TuIx!t5~>Bdd@51iX4OR5#@XOQy6mJP;hg@GZpjU}oU~ z!Zbx@$Q93h=#s|J?T|*#7Wg2l+ya>8Jd9z)SaD8CLy$uH1SyD>xjvX)1JekQ6FN;< zvEf)`?1)vJdz%Ss4>Ym+yw$N-eNGSmW3PXMm$vvAR%7cMy%dEycf>&(m?hQP|y z7>YPnqs-?K`#6`l5F?B{eoPfE%`zMrRkIj6%wn@kWdgTy(Zgs2 zvzmf;lr$@iK)-pfsPTw%+gd7-j+hkpXOTZ z)ue(*)519sdV2?({`~o5t4V`7jBx(*<@Vcemk~`n0e@5aLlzOz5$S5; z!5_Tz2g$2RWkH^~UO{}H#*~JSUi!WU_oOF1$xk(Y0AjAZkZQ=GFBbg$-~T;&;VJ!} z5f5F$JkY?4!chO~zy9m}hY>!UaP9=~f6e0hNR^VIj|cQ*-*k2YI74|50{-f6trYS& z>#d!>^xyTacga^BWI?cG^~(4MfA9ysNL!{#!Pcqx%fI}~z#M|*p&}@bG6FMf)fC@X zPi%PM+6h7=nEJol=2H#creOtRxGE_t`-&f1B@c#}+;GDU(I_G&E$e4K^O=AA$A7dZ z`RCJ$qjBr4w*qhuT;q*7C7!lKpXSn_Ctvq<%J3^bSKAO_w=GMcM84no(wDvzb7z7C z<*s5~HU4MEVWJ?CQYdMe0!`?7`8Xes(|lb`aUwHa?uc+0KN!S87%=#NQ>?p zR5k!BGX3r%9#XW{^t$V=d*d75hzXxa^5buf>A4c|4R3e@=a}fFWAAZTKc4wv3#?I= z3v)i56}}70n=18j06fDbIc0$5A|c>=KkilTNtPC|$!=nCqP3i(yygVE20@r-Bq6Ux4bXXtM2N}kJmJQGGfZapolLp>vG)jTV|-~}%rm{Jxw z0@T#g?;^bNm9KQSD{;3X8(_W)$q!^cDkOZeA)M8GQH` zm!M(mcLE$Q*2r+L&rS~}$lzeZ^uWsbhd%Tn-!$ss#(o3foD=*BzVzZ5&Y>awL;UXz zdWK2`#M!CU#nl@5y0`t@g;p#HPKA&CxegZ<$kzt_ys^B1l~w)~y$eI7T&Nc=Tu852 z1?C~Z>67P2#J9fntsWPwip=%7on?j*(MI&8OVzR6{nkc5hDKxfw0$X2{gq$&6~E>~ zb9$0_h(->{?IHE?)YYab?wn$f5%UX8t|hr10J`k(*#pLa}y`MrjhyyPV-FaWoKZ4&PSaBFuBcRI6jcUO6Zs)ki8 znbolhIH3jdVCoE&j$Nd;tX+`#Y#Y4pb+7x#M?T_lgpx+As2&oOXP39KIHq0O!(1s$ zF(hzelYIEYANI2yjh{xGTr#b^(TEt6_^7t)!Y_D({mtL}&5!=*k1|AwNyNYy!t>Ae zpZ@8eTpIHAjaMl+BIDdF*_6gs`g*^P;1`L_2D{4p3Wcq#RgxeDPs#6o_q(UyrX*PP z2xa6k+S9p|4gCU94>~ZxD640NdN*1h@2R=^c)qOEd1FG*QnGadoI7`}-%+rE9by6W zMXzVr1%h^q?uR;SYSvV_ph4!j95b}&ju$)OAO7JVe*DLOoDz(4%N-1!hMw~7yYF^i zm#L)}F1Ptv@kx1A^`+zR|J>(3=R+E$C@+bqn50_ta`!WX>e>0u8NWOWESJG34wxZB zdaGjtxT5KOXVy1rt4sw4nANOGcieHuZMWT4^rM#u-tQhd0gw?|kKXd~%oW=HuYhc~ z=wsegGHw~>!BCTk4`Q7L7{)fR(?(J>h7mY_{ycAB0rGr>d)K3C3rbK5KN$OzZdK$c zA-T!uxcqDdX-i(EvYomR%YG(N4;J(_8nXe88}}f*(p%2QJ??Q>wf6iA2Twzu zpemx7qIX!(OQH3niE!ZQ31`*X(?d*LY~8v%pJL^diK=j6;Nws4Q)PH;P1TmH0JNp% zT9jJ|`$~d#k%CBHB2uHNdWK+Sm)<%C?)0w?;a$4biZUy^ksnBpIE7PE8Vz!aHu%vT zMc@h(*PggOwn3guBM^O zX6bD(yPA@$??8+OV$5lfF{>uwv5IOYdfp%jk!Rv_BR!>h_Mn50VJ$qcVd=wL%}euF z`@b@kozxI@c}|5Dmfmo-3PFR{)AzHJQqVZ`)PbOx#~l{$n>P=~IIkbQb&imL!Z zlztVaS3pHOZcSp&oFSivExJBs3*o}_(Tfed>mM>Oyv9S6c#~iUY0RYwskfs>0~T}J z0Djm1mC<0;v4dFutM2NUBBQ;e(eT*MI`+3Ptv7dyl_N?~I}W;j%u;FtKt*GsUs5@Z z%sDbvOHe|tT0B!4R)&Ihe#Nk-UEnZmTT8^q^mYIl`O|P)+H$xp#>J1mMG=}ZKDbp$ zDG(^lGLIFFYe^$4eLk9GDa%gH)kIz{q!yz6(=tuIlx-o~ouF0T0Q^iF6GA1m8M;an zmVhzeRbEzuzfI+aX9e4GP6Rxynj!N^M_iX>8({RMVCtYikp~lnxjWM!MG@UpKIN_& zDg#E)*aDyd38NL)6anGRL@)%x5Wup6DTO=(5X|DBH#8ePG8(~(!ZTNy58P!kuU z=Tt&Z72WfsKqI>)d6^o@ouF1yW(#kPO1ZM=D_ZzGR2JLAEJIUF^nvw(Gvtr}rYWf@ zLv2YWnKJT*glL4PF+spOQX9Idamj;6XJ^RiC0WNw{izoRQR| z=>jPo!w6H#RBvduC&9k*-}>a*ZWOd(&}Iv-T{YCDsu!yeWE=l)&Oy zLPO@_>XSPH5Qgc~CWfY9Xb6buD<3D6rFKLbhr_x;IOjHEHocY{Lo!Ne1j8YpXaG2d z>`sD~zFtP!QgbcJmP~evqfm+<<#w7}W`W41PXne`D^*g)Y^#hulknAt&sMn__PE0~ zhZ$D4@X5#OrGSrH5f20njh2`!x@8>=KEYB-M_bZZg*FRG$phPNPUJMEgedWP8vgBh zeHeNz8smqC+~uR-tf~^)ApwMg;Xi>xO=v$m>hlSZD+?El2F#|#oP4b`X`(H6S=cHP zRFf7_3TCT$$ySppM$&}~U;t4%&CpDR9SU~t~e}nTuog=LmQ^k zRw+OxhE^~>^az&%FeOqrDRqBVksuKrfgtCEr*#mWWrB;6p+kB z3bDM`NOCX_N_Tl(v}1_1qL&5qTi?Lrb` z@+50~(O72QltTrPpjWS@f3THiIeP+GEO9lN>tiAW0S6ATtpowK*o!}$#n~(vyq-qm zlicb#pl)1g84|iax4O-rM|D8kA$c3;&9WWM5d|h1R$A*wK#9Xb5b)$} zr~(+WO35nC-iWMn7=AWGx#!1}DEt)2&?%O4au>pw8}ez$6-*B2`faM-Y_&Bcckt=Q zTp2Bd&^gz)aKL1=HS#slWFeh6OOPN18v4>#Ua|z~(t4v#tl_dmIj12w${{;CTJf#*|G;Mp95FC%t9)4im&HF7ZeJRA0` zZ^)Zrb%S3zzru10CusQ+l)4oeVyJ2nqrABFk51GU?>|%Y;xWu4c}l}C<&$?1jZn1>!FF>|6Agx|WEcgkhCb9`=)YhpFIcULk%6_T2~F}v++xM2 zq@wUCT#3*Z%d)bf=YdZcLLtat3?n(=^1154?4Nc^CNZ28;KhnVAyh^n`e-vJz|a7o zHz5pz@kz*Y;5>|HvmydmAhZefT~ojq!jv_)sM@{sh~Z;Oi(ay+iM|p7torc!m3f@? zq6jKn8uB2sT}EFyH^f{9hbCvR0X0_ouyl1H}Gwn*wHDtn@ThtSDfe zLNJ5mupwbsC9}c=G8%av|IwC$5b$7n{(-AT0+N~QA<;H#vBa9{G1P?1Ib`%Gs}dyJ zl7gYvgJ}&Xo#iYTK*fok*^v5dX*9^d&63mCZ^CTbQb|skVQD7Uo0V)&1I@0Ak61Jy znHcJs=;`%B7%EFYON8gpmt-vIQ+L3n{G5K!LnLk1kU3Qaa)GTb4J1uvqQ@ZoFgT@Phgq;P zv}J{e6`q2|Dn8;ulC5hz6{||=L$b;hfCc7!=%sI%RT_Q5AomLK5mAHZA6x!?dhBA@ z`q@&}{;-&eE)vIWf(H2#+%4)Tn@Q=%*+QhoG1Fi|U6jGvhm)N!Hsdes9~x{_tJ3BNU$a+Tr7PB6t4 zEBdK<$f0M*533FV0uv?WpObML-V{F^>b3sE3kE%S0+Zn*=xACpCRkCLf@Bp~Uj_K5 z*GInJ!UN`J7fOg62=3h!} zsaeW_R~W-t$G~}5d0zUl_o}m=ZNG{&fDNZ@8x3K7BV5kym18M$`$H|{T+!(bRnQO3 zWPwyaz+OLRrL;m~d1An%M^Ql|*7n)voQ1k3QKNN=E0Da-jHW9tDGb^8?122^fgND z(&)SSFB%p?FA>eokvjolN(m~93g~HSGOZ5wA*7WB0M-o6CKHmSXWc!B&?_U15HyBi zg3VBrJTN5d4fTO%DnZYot;nGpjNNF6EieHw0$TuVc_YY?Oki7H5dhoULh_%hB1*w< z=urZwWWWEMzGRoqBc^(gdY&~mc)j`*y*mL-o3NwMln|K92zgV?^>Q>Nkca}6@1)PR ztYPv7rWxuvT(*?@gIb2Wv$@hEAPN9(dd_LIOp0}o6PQv)o; z7qQ|~!ZfQ7sb?i_8-u=LTw9uA-z*VqG$54x*S*$u+O2-oOkFa%T z0%@2Z2{wep-z*q&JwN$}%!*Is?U1J2Y{0St(8^+Do&ogQBnaP-F!(KG(}iDx$>DQ5 z{djH`tf`Hc?HQ||{a{M&s^WIo^-N~ery%+{CSfR}>%W-y%5bN(wn4U@gi)WyGt zwe)gX84rHF-pU&Vyf73V%Oa8IfDrP1?|a{;KJ}^jqL$go?Xpph2oTo-cpLq>bLa4} zB!;cGsR@US_s2f4{mb zs+esggtwGF;R#PL8y_0GO%{e4!J88AzyJRBv|e08j+Y-p@*LLyywvfga$uatn@JU;qU(L?|OTi zVcv#G9Cmwt>|5XmAAHaYdjC+eZmU?JhDGs{^mEo`ND+@^sZ}D&wS=HMJF&J2paiV-FfGo-Zv;^ zfY{~`a)t?M$VX`d9)W*t>2@>(J_mWY|Mr7T1y1x+%i~@oxis^rIi8l&PKh(?9*w0f6(&1T5x$ zfcAzxd(1gsm0-+PWF{k!Yop0qO7iFv>bU|K(qkYw8gH%WzAXlN#l&`L1SZyOi+<=86(StQ<<70o(%UciSeM zcS0{+TvnDX*DbRWc$=j6Nh<{J!n+RrP;2)9{O5oErqtCuCg3{nU({AFVu1rQU+ z>#x85uDk9cU}fdpa!gfLL9P9L_uYpcl7=XqKY#w0e(9GwJEk=7%$-F3_JtRr#ylF8 zmAuP*rU<*UnM+w*lmH0z{FA*M&OO_^|6yDzellnw1AD8omtc0l0TVD}uJK%GdV-cR zy&<8gIQ)O=?)BH!vnv01jQ;?yh`~S#g;2qSpb6nj4+azzH6q7QcH0CnO(29A!4d=t zibhmW2n0(hXTmugReA`N8V-tp3Pz3v+86pi`1{QH-Fx(5&b3zOTx;(Qzx&0#$20D0 zT;m$!zMp5#wf0(bSzTgsHV0mM&XHZB(K*@ErhE?<2TTWa!$)C0<}r`4#BSpl81(Ij zKm4K3LHDrmtq5mKnNIQtKlnkho;$w6#$(BMS!}WM;1k(D``OP{zQ($-QPuy$Dh-@e zwgEY<-i``MCq2)6<}+XPq8Gu~F9Jky`s&c7w>Zy#{`2p=^G*T`v_eQS(;3M&IV8<} ziRsgjz5Rt3%ipbTmIeRimtTJS?YARLu$#Y+tH%kf!~7Bn=ehC58-2bir-wz~H1gHU z-~R32>WpJ%6on1BOLt9w34s_q0{yK6e}JV++1+>X3xxDol#6ZfPxqAB;A}O(*!vpT zVqoBG=2;p6T0M+EJ#^}iZtE(f@_m}$M?UfqW!490tdqvW_7^04oV3H%HvpWyQ>RWj zpD;R!3IipEEdYDQGoIn)Fb_ge?|kPweITmt0nlLP2aw(Nz4fhcm543YHsMSdq%pO` zFSd=V^WLI)hWPtjoC5Li8+a7x3kJ7_cQBsYE;%3akcYha&2I*XwiJoM>?5;nYe~)X zm{s7&*fuI;2s1e2L0*<+;4)ci!PX%$TlIkI zEgkXN;!ngVhi0py%xs;hjW2xR3;*?B|JBn>lqDfB5-s`b;nH)Uy^Ja(spgiB=CP_3 zj&usyUP;}g>4{74mQGIsF$*x9JnrY_m7(2ZOK5K?^2up3ArR)G~ z`yx?-GiY}2`Thz_n+QhFZ1<8O@xx|mV_52NdMY^~F7ZoLE?`b~obIvbq$$=AA}N3n zN*HO7)G7wdoT5~rC-CkjO)CYMRhRZfU{X7WW*OT!lFEw{rfqV&Mb#D*#b(HBW)m#d z2$Qi<{>J5C7)Z>KWSeP-ZTL4B4!aasxJGG(&LHQ6SH3=2O*v0~Jpty|3Wc-P?lM)t zU>yvrE(OB$ys`wQ&lz#jY(T>EpmHzhB>qx|x3oQygkMP87lwdQ#lTz&hrRC*ew!l%SnR9P$<0r-5|A(c#c1w}^C%Zx{Wp`rt-lEcunJ3055>U&2zTIW+UAj?^xz%kFf)@r$Zb^E3oo9pb(R$9lC!rd!Z|(N!q;T{@Yzwpqe2dE zwMV=qsa@(e5<>`849?`4W6Q~A$TgQd%duPy)+{fB41i@tbmo*Ak5ywyxM2s=-cCsU zF@x1}gw1Hj?S z#V(KlHbVXi;b%C3X3WZ=p`u(CE=7&Xj{)8iqyoVTv0q>=@dG%MJOEaW@DXB|k%tV1 zaKlm|VT#2$t5SrY*f1^)wm=y8Jj+}>#d1zA;6iFbN0f3USTgw0@-Qe#j1W#FbNi@T z$x@gt^@bRrKsA!$4W$cRlAu>{11uNFe#^sEWH=d9Y>!pzu0u+hMwqoso6Ky_N=q=(bB+Q_#bCSn!ApBKlhFY8$ekG)zX4(010vJkwDUl^cC7AI${bsf-=EIZ+7!tWAqa}Wq zayg@T7-o4b1&fvw(wPGbkEa?#+V8}`Gn`AKx^Oa*5gP{hIQio_O#fN&d$PnM-3Fl@ zAx596vPx~@)k|21xZ`9A#sRa$)(Ts|@O@^0obcE%@Jr{c0N6dQmN#fp-bvvAJrW^J?@t?Doo zf?0F4D<=RCVP-a%q!_k`)G%_Ht!8iYmi_?KVdQm;h6JNxNF<68728TQLb(8d(UvYS@^m5$Lv1odNeWidIAIL7RdZ2nMqq}KNX){MX9nZJCIyi(@Q24{ zgJPzNGbQk%`l^O_oiqbD9{gYc<_v5yCd@oG|L!BINT&Rpa!p0$UDEQnTuC|eq6w^0 zo$**EyGyf^TovXX6uJs&$!Wx+I+qMx!Wr{2*I~B)P+p%?B9;dp6cUE*Cw{2290EuB8=n-pXdW;XmEvmdjyDwm%>r@<22>zQY+qI4ci znX2_)r#~k#$Z#I0z0ZB_;|D(c zxQ28TQma><5xx6yRQ%MMBfW1fwW|f^JKpgQKjBgB^z-PaGpRpc{_>aY5prPV!~0PH ze}kO>!cogw8{K*&wXOV0aH+a4ed$Yna1EGd_pAfp$M5WdOFS;!UtpSsit}YJd)a&6`(8Z2 zwh6!wLjA^X{DvkY!D$GzB!ve0!IbZQ_q$-i&7RU9_`nBz0ppP4v{RyNWs>}*dZ`5- zQQh_uJVH-MPXK%5*I(LnO6Y`7>Koqh2EWT9mnt}6X3n$C{L}TcgPPf$tHT-mem42_ zuYbL!DTmYS4pJstH@y0I)zDi#*95Q%*(ejCZQ?XyaLZ%pDcwyZmsJd8tbzacidVeC z>#SS=?RA|zE4|wL|FCQ@SrpiHza4P##TR?lkVPE7Rfduj@C(223my=jcZ!^L2sF-$ z^y87xrn7!j`MJ-1F10np%YEKQLQb7Pz$c5=@xBA;-zqRgXw)-pY7tyR<}uW2FC>*^unVKK8MGw%Y47 zr>7_7UW$M_+zblIRt2{E-uJ$KbkWb6As3EzFOf%p>FJ3hokkgcHOV_dw~Z_f8fK96 z_{TrquP1nfQQ{|E{P?Z+1dz%%C2GFNJ{plWF&Y}BLl%)Wr5Y?sMi}A}N zeyq|n5yqRvtFOL#INMelfCYL_qm4J+bdzT>&jTO$Ku<}H0#H{`^_(6~ud+76lU=dM zpZC1yU31Mf45RGkkfOXH_V~W#mRr10lh=~ju#|m7S9f0`k|g`9Eq>g%HaY{TITW`G;cz1G0gbKO#+H0TigeTNwY?Z7Q9>d!#0+2fiGJ2=$?WR81UozStA=t$7 z`&}n{^q>FvpLDuBP>&8U8BkJ9Ei7Ew(Vz}MBJGu1IOWjjE zinTD3>Jf|NS zy9d+l48i}>Fa46TYnggf#a6tf0Hpu!@BXeMt6x^z^5z4(v%7|qAnCv7J@4twDUk#x zJ3TxMvhB))9`qn@~IGNrcJh$Ka)F z2{|WBSsWd<=Zi0Q;OX%q=eOi~84E^UWeR~2;yYMg+WC!k^Vph~fwDW(zP^{vliIC9 zl~+eHc(w<>qEu(5%G*u9$bgW)5xE0KHmRH*qn<$?qwLEszuc3Efmszw4&#zx7ViBG z0AZTu)kv1%Wa}yzfN;b68&6VBc<*JsA%H<8BsK=H!LaXs#kgpoad^B7(>w61qTqrB{-s)DL&LB3JWS-;eoI7#&&eacgwLKrdhH@7wP0O+~b z2yf$Tx?+8K={aDtst%Hocr)=}zRL(B+_SIU7$D92nG7pwhjP! zmSQG}1~b{Be5-ZehIljT@#rb)JEN%RG4QLdd6av+*iC-vWZ{Rbx_y(L2Yf9}VeK+g z>s#IW$)G_Yn#O3S7^<_~zLw*GZ$411ozkwtZ73?VZa^(+YeZS)uSE>QrrV=bxs>Zb z^5Z%8ey>9r5mfd~EwUV9E#P;arg-(}1x*SbyYiNo#}j>$D9RmQ+6cqbrgBncZm7bN z-BKM-m8zPP2eR!!QY(~S?QS{7P{G#(#=b)!88j5=k`iwn@Jm{XF~am9VE4Hl&X)H9 zN#$f1jP^(@wUoQ$XLO@Rb@I7JIjm%BIDRA9YayEx7yzWyY(MAWTgTa;*pif50GA5v z|L5Y;W(e@oIh__vL{ zpRFoiZ}jR(+AhvzuOj?fV619goa4Gl0fT>A%K7nVqT{H)WYBN$u+vOD8JqweS=*nJ zW=N|%W|#cMn&FMJSOR`Bdd=?U=kc~T!PGLQlL-K>ite}cfVD5ed0Y|+Hp-DCyHkF) zN~pbh=-|?$SH=>@d!yqQ4z9f$dA86!YNh=qfJEX<_GJgE^RIWz(TT5KI(S4 zuyqEc@X#-Xu*+PIBsH7|P&j!lc?!V}(-!H$2Z&V`{m_l~+`_byNg>=IR1GSWk& zT526Id12C7e;_ML&&6Wvh%)37+o;${lmURm&>}EY=14eQN@TFH;n%kU_nmDk5;|!+ zEogmu~OyP2xw;b$b+MjFbrL%zs!g261=jP?vT2A=A2iCk2h#2Ml7WK*K6 z$gy=+i0OSX894I;I7}{cx}?Dp!$x3WTVfzIx`g3$Sy5JfrI>v@wTDGHxIL*o1$5>x z7{Bf*kR*8Tx?HB2;5x~$3Rx9qqhf*CgXeUq1Hf8WQW#=5&0ZRPZHgABRl?YofEhh= z^#_I+7)vhgdsJ*jDS#OUSdTf0;A7^&P@=LZt#Ax5G`keSj;9nSpK&TjD=LSiGIo}% zri3;PIeKdj6r(QUv5}2I7PeXpc+UD@hP0|cHa%t-9)|3Z^D`3ZC`NK%-kR1D2Fg)6 zIXtnM4U){9n6c55j0BhHFn)NkeO=+^k_Ensgjk);3TcT^4~cPUKvpR#t9g>tWrgVB zM)M&BhA*8GoY*v|g297o#uUOc_PH|%kVq9&QIeRPl$V!&9z(1t&vKAhf!p#5(n}t(S-^!90o_MrwZs8^2(YA285Dr_rLzN}6g(se& zKv@*arn9?S?9nNHDyu{iHr08JW7BM=r|Qgk6v(KMDfwvGJsi*q)ONe9Ic{xHQdq5} zR;RJHk8QIwT6GRYHu?K^E09;CE>->O5K-#1kPjo%bV~UmM0_G)AL{ zA~AxZM$woxqSPiz?X9u5su_Dzqc*XrQMSCmz7T2GsrN5CKyAhK z3N<0?%U6RFnJwv;ve;coEbPw2Ei10e`Y2*;{=Ep|w^878?0<*pW0x70>|!C)b1WbJ1j+KsT5F+4>sB^QH zi@AdztzW2j-pxtslU4Lr{*2op4z*PS+5dvSEjMduZ6LG`%JBA_Kzk-?&-@gkDy%=z z#u(eVEzWT)ZNDaU)>-AoF-k=>RwrK9rg3}!9;2S8nn7{fUQc@3~&{Ks15a(Z8@%J)jqni^KE(|lRurAW6Rvn@dV#TX=n z8a6@cgN>c9=KChlD^diMmKi#qN4U;hy!aR>c3;^B!L?DT50ok=Q#T^l;<**=S_t98 zTOh`W5JRZp`Yf@~aP9j6!H$<5L-6~?D)_lY_i|%efN5R=u9L>ppbBd>SWoDnmD3wx zzs=J7AFm$C1cVZaUbC6Mn;~RzjDqm3j#Yi+1;qQSM;Q#D&*&^)aazi)z2#zc0_}iA z(%Ek{tBa}!On1gP(#2;et9d=D)n@rr9(DsQvRLrY3q1dtmtl_~uqwRuiyvNzzf~Kk zAc7^Uo~4VRoRqgHj%R0EyVNO_K5}>l8<}E@c9`p^Sw>(D?5&8bK>yBU=@KHUx$i zv^nl)FVQC}>YBa!cputZlo6m%n;5;kAT z;wOaFK3x4H*cBnj!*=vO_uwj|&3RB$VS!Usw&mRDr5Fb}B=$zwK@C=KBJy2zu>8KV zYE8Dqu?2w$ePfKEb*{i{JkmhqBu|pfP4)xwe_}0^po-jZ! zH2tZYRdpyhECown_ZzH?<(nC#DVR_;Dbx*!OsEpO?lvQ}QT;8~5*y6cRLVjtEcwtu z7;5bwCl<(ZU1nZn|R!aHa0?%#S%(?8K-GO^_I>-#HN&(UJk2uuptHm4b`f6rlk9#GdqYjsR| zf_w0-w59R`JXT+%gF!$UQZs?UUuhAE-$$o3o(Q|-7NCBF0o3%x4`sVkz861F`RtxH z31I$eOrv0BxH&+Rn>XM>Vh0DoqQaDx)=h&QO_K0IvMq23?)j-hXnbB&Z;3K3Hjuhk zlrUmk3@NwT2;Y9KY%Jw2v#YM60JB_8j|ov6AD4{)&w<8aT{6Zh!xp7NG6q5X*esJ_ zr4nQl=gM;bg`uJ-2TX~zv1?T5T?l_nUF-M!$RuY?1s#;Md_2_6DW#NBs&nMz`|3fC z8j;TwmG9!ngqMP#4ky?c?)1oHCl7No{R|XsB+EWE>S2T0*TIr z@_iGzf5KcG{6c-DOEB)4Pp*TScyaPl>>ZXx_(tIrpQw<#jjt9esuTI+e!9UsfqnUa z5NN6WJzuzE&HzslUZ@ri2N-}!`&IkFS}iOof~d!OE4!F@OkrQf9QE7Pw<&(%lX%0u z$$Yz+C)N7M@`GzHtto@mQZQGv2Gh##iH8CpOX;NOXuNo{sz0Be5iTCZ?I$Xc^qw$x zYwcc+>OKVUr-{zT%KRw07@8stP^edV!W8=QJ5?K2x0+(ClDzgMD!ffqT}m|rth5mY zZ65&}P>=jfu=V!t_rLtv@ssaWwsX<#eO+BuMDd&ZX2vQYRO#1ZyfW9xK03q!$q-k9 znLhrnN^NTW08ITu8<51fz}m$sd0^?wQV@oC3@Ag#6MWHVQ{g#cqhqkvKsBT*?*oyE zWx6{bm`{-4CZQ=YZK>pgg$b+t{>4HNln3lIHX&`IPzng8`snIsNy7~Y)Li`iVk03t z!FmG}@R?sGf@c{<(uD%B+T`!MZy8t_j$};(x&)3e-LXt!J^91Cu~y6_$oap`>kriV zpi3NTh)(w>FC5tUhNNvHN}&2FNM9ioX0nk{%tXMCu8xA~%))K84axd?N5TyVr%#z6 zFpN=(rirIOdCzBq=Rf`jRtKqZ1>mGNc&|QPn1$4eY4H0cpg~udQUs!bZCGT zWBH2h;6sZFX_jnjQs^Dd{mTmv2i36H1gWB=LB-@rUx z%G}g{`TPdu#4KcS#d1@4Z>ySwtqav9QTiU!&ouKHx42JxgWeKLq=niDY3g-gK|e+1 z3TW7=5+XvQ=X$nHzdjk`Z+O^=>W5Ni-9?lUCbhE$Q*_hBs1S|XS@=ea2Mg6V-s6#I zpKFrk33|}x#Z%h>^z}>JuJdt;mj9Dvh*8Tg9Es?}87_T8R>SxshgK4aAD3q&3ia2U zQ2sFuXv;S zEUZUt%jB{ha~n5Otub?KA(jUXy=s6Uohajgv--S_m5}>>3a`_xZzD#GoLe@h%d|kN z4=!;rV%{Yq$BYEqSO@@?|J~Gg|G$p8;Un_7r#|OyG=wzagqK~K4%{1s5;eW zt2RwV=6CwP#YL+fA4AxbwU>pa^^tr-PrlK${nma*up&G+AT9wwYdS02!;AsWn)eJ- zk-V{lf?xnw7-N6C^W?-S;epwJX_}9-VG_PFwRg^Or09QHWYMRCSGW%RAA)&5V3}O4 za18j-*TRo3}<^>EEXd(|uqUS~pA?Z5AEv-%*| zv6=xtr~hG~i!f$6x<9OxeHcoLzS7rHe`0_nQ{|(omRX{7Z21B#m0(rcIM|iRdB@#g-wMLfhF^T#HKk|jz1be3Fu6-S-jYplUW6+D zp(v_>m%bRcdrBz1?3-iugoYWkNy@KiE==H-Zao@Xr=wAMb>Cj#k6J;e?qf0~;uDEPHoFjL}*1ULEPS4@Y#OQ*GK zgj%*`)93!I1Co9?`0+cie9IFM3esXe7;)n6 zriYe)`=E;S0_Qb;iOQ^~Z9iOOU3&F#+x2sTy1{8k(dPvm$u0cTqdh==W3&N_y5AaA z-=l53^`aNPk}K^W{@FT9?4~OM_z`u<56KO?M@>&p)26Vpp=7()py%^|ee0KlEh7xM z0I2^_tNPSOgJ9B~ANYg6o|yav&(-PU=S%T#vP+BGS7lOeMS{+7)8J{Q%vbjUR!A$Q zz9g=Xm4`owmTZXKLSQRS{XpMI0W=*vbU;A$|ZNs#j0-khga%VCBOP2s(6rMO$v z@sWGX`ZXIfrZ_THUt>|0ByD*4W?Ys)jS0-lP%@o;GWaK{Dss6l2O#Kf2EXtr0HYLtcIPPFW3~0Y7{iC2p=J$NP#T2$&j>D+=!(L-W**HENn2h?*Gc zkVE42abeH8o96FQ9dHp56p)g3nS}Jj!lXnN#(15x%GQ4j=J>IRFC*xdw41(~?B`nm z=QNbzjz^fisP za4>HI7%B6$pPU>!PGUhquRiuRBOthH7i_Z-wk`CGP@u>nHeE30$q@hv&jKRP`m#Vd zW?Fv~>S%{-uh@jSq2xWVA@lFkBl!&2SS?gb%?u&B+n5_ql_#KHA0*ICYlJSJLjmyd zuy9|C-l(unn@8i9{Uoy}f-HFwBDi`5|6fr#%K0{aM}^?hxis;L^Jjb`@}LO10s`Ua z4`FQ7rGyO$N`&1#ug0U8lmR8RIl{8hRUX$Mva*T;eD7U=A*+kBiQ%xC&E`fg&n&3_ zjr0Pga@4ouCTVe4I$a$3!iQ{>L5lX;&!DWG*{0$h>$2D5ic~Gg{Fjx@1Bh#BQooe@VLKGB-@s` zmHLO+ItP52r32vLpOGX4N@2f%V;+qL8>pi|+DeerY+l00ji7Xy?rh8csA%o6zj3AS zts1n+7j5ko*VomE39j=eOuG2cRw|e-tMVDL_~Xq zT2&;whHCu_KQH5jf`#;dw^BUqtt(iKuPtSYXsGqhrdB*ONG|t!cVlRx(143zMKPu; zp}QOz5IMt6KwfA@{e_4d`9J0ja8)oCM8yM0pc) zc-thr|Cl0egQ=pL-1IZ7eHBHvA3kUw33{_KkWac$qx_<3o*fpj!AFo@brd7qqu>2X zJ!K^+56?h84V!RawCQfw*lPadP1qKjvc_Zrf4VS-Qvy(*Yt-stDhi1x7o@K>sJn-s zXj?r!mYWMe@p<@WnCEQcthW32=>>8S=60xFi>53@tUG?uPxcb56^&JESeKZuVY7i! zJtwO$LgrO5X{dyWXcEB2N=;`**;2c4Z;>Ef%QSsiwdQvyJ83upXV++a6BsL0BC>G* zDhZvn`C?k+MK|$_5EZlvB$JtjL$-Nl-b-V$h38Fu6jjhLKx;x`r?$wGtK5sIi!>Z6p@RoS$;zJpg*wP2;88ngNVI&hyNI zMUWc&kBr9$F21O>Tg%1c3Ywb?g*W~`!oWoe&$(}yN~B~)L!$tk_0VM2?XxFzsJ<};`#GGwF_Y!~Vm@1Z z(ZL=e8_9$=B@Z6{UtMLu=_TDS+?9Pw>tm57Z*o2oF!l1;DXYizI9ZMCfdme)6c)wn ztBsJm3>}S&*{#|9SEaX+t?O<3xRG6|^zbk4;Px>R%MRG>3!HT~=&*f4Gzyz}M%os9 z^QMDIm1T&#h@bb!BI1_7R8xgJ`moYQ5}DSJQKGT;uk-GP+q`H%llOfMg<2>tCB)E^ zt9+3AaD)B%HcC$U0|DX|z;VboWt?Wqxb-FHK^jm&JIV8zigtcRQ0uT7jJz!YFs99p z1s9rFUpcG>pp|IGU8(u5aPE#>4hu-_zNn&1EQ=*yWLk2%r<48Xa*9CHFBBr`UQig@ zem0dqGhylW9O2O@o(qWq$*lsbK7EQvi)HQZ;`$dU($PZl%j<+lL@yOe z&Ejcv;+0rY)|)Ra65V@*(MRaA;#6x|@w2V}R%`H^p=(6f%I5*0VX*If$4O!NT!NNf z9k2KNvH706iQi!#R^U-BH$uk#h6mLVO^CI1fUJ)uZwXuW^`uV;ucUt-X)z*?SQud* z8v2)W`}(T!bw&2Ri&XFI@=&Qh{4R1+ieFin$BdOc_3<3*okuCjAj>M{u55hFp!^3- z3k7On*-Mi4tExM9&RArCh9l}(_+1b9*W^kyFG@G7YS_pga+}nj?0*8{YM%}t=?;Bg ztKzqnAf5J1h6o#Ieo0qHWbG4#!{FRieGNY97MTs%%;F8k6TB`R;{E)ipaYvGYIe=#utA*l z8(1^a@$>iKMiI9{``yfcaH(HO&okDn-0c>x#SYc|ceQQ{yZ-pJ_~WpgYw{g&37qgY zfotQWJIa9{XIDGpxOk+_fB4LW#)H&Mx%+M5Z_qvTw_9&+fz2~pLf4@2eMNd@)0b0( zB|q}gPFXXY+8N6d)S9$5@6xNA*GQ$VB3{%UJP7j&R$?Y2`~HX^^bqH88Xyl_Q*<7h zxV*tZT5GZMzAWZkZ=YX(4Pq+4!8u=S)G%siWY2!A(DYHUK#X`~@0JQ~2YH9za`Zsvv$Zt^l)zaUyUH0&%bh{ca5TL@e^}5#l zC%)xKzz{p;SCY(0m0G`6^T&%W8!6x)Fd%+FUaFssb~8JAjrR8>-hu9%o!Q7$siNfY z&e4xjdcl}fx(i%AEj|xL9Q+rMw_R<&_%yX(v-eG-@7ToDxdH#(`p}LiK(v)b;uH56 zhRKvbNwZCG>)Xbr0sYhV0~tx}m}0slvMmMl#mMV%uKOJPasr;JJ~U>R3b337nV^r~ zJ|2TG*<3uVn*kq(bI-Oki|KCvi}<5e(qfizOm#d%9}*rsZfr8jys5KS<()rn4H}rp z=)gUb{{o0rp(#tG}9_b-SGZf<4GImyM!bdmooI{gOys^LQG0jjMz=e=GbWHhOn+)F@QW$MPhs zYezDuI(+aep7Kot4ga4qL%hPdI)KaW2M%mQpZ5WQ5rAyP%IGYhI#ixc{`s#LU4FY-dG;jvaoCDzbBrjNRGruEG#BO{PR-Y@ zy!$>bq3ixzK~V}sZd7jUP1*o4YULRL4tMR51(W3!QJ87eU@^T~ni!hmM`j!1&D+&% z?8lYFBcDYSZea{gn6lU%`yLRiJH7E;6#i~pSPP@cZ3(uT7B6|J#G4eiy7Two0?crW zi#)D)ga7ssc`_asx8#?)f8CC!1u=Y=2a!^HftE7EZIpfMl7Id~$VwG|D)`=cd-_1S zj)QG}j1kZ|@hImi!4TdwLvT1U*VZW9b;Bd?6_>~E-U@b1lka&VSn~eh30pveCmdjj zYF;#M1Gu;0|JR>fe7Z3%=rpNpmX(0cKls)D*IXO`$QFt%G6CaN#r&`18KJ!JsCY3|rR(~dL~mFZa>Mog@Am_%2Ji}2&vXSl!jEPsMIVP5YqOP{d= zwvz`~*th>G>9Z^zyIkHZxnAGGGh8I%_ut}bcj}v!x$1>+cC_qArd~@PU;5Hfu!s-u ztt-s@-2UxWRMV`{dip)pU1hPc(Q%qt`36-F3iw7IUp(#^rez1>b1EjQi&}o_W3ew> zmjjM@zaX<)w&4W z7Mii_Jm0cf2;LUbbhf@9|CKh)c{G4=@$Z=T-zSAE7$~nxrU$U(jm6dJJvDWJ(+NOz z2t1n0`5Jb0mWxyebTZFQR`Rvgz7CaZBKl)bzom!z#jG&`ipW7ijh?@Gi)QZFIjqtU zu~{i5Zeq{c$IwDrgL>cS95^r_WXUw^yCCRxhVhFz)D^V7^2&N`C`g=7vut}bBY=UEE z2V#*H2GH;)>j36{`Bzt^(v^$rhZ0GXWXrY>pj?10dy~KL+wvX9ztCQqHQqjeT9cUWka=izN$5SafT=W9ml z0E_Z0q$50?(x=^Bc9mgca_*`_jVLY-VpXj07?Wc`{+48J!DdG9Ot*|uDViN4onvvW(nX9%=9CbDk8O9NrZDhNc^uQJXt0o@CZo;mKbVkSPO-#HB%W9og= zt5^!A)0sw#H8RXu#yqG;*^BJUhM2fUr3A!j%S^7~%X9mgs|Z z)gb{&wM82pCAvM=MY48&!lPbYv7A%2>7=xdS>NxTZz^G)JH24$djyZ!PlE$wO+z(` z>#+3MD!TaLeB|ptzvlrqz!_X!)wO)Tds2qflSZsa$2T{J_LI0@5hWlou=>?W({zUi z@lK~_8m8zbc3#qclAy2e#u%7Eohlxw!Jet88>fX!@~96H1J!y7`}g8U;I>_(rZ4$7 z_FN`228!qAAl=?R3bpFbr`6JBiy1EcneKDk)+Niq_={nQ*|6AUUk|{bL`%{-|1T;2 zrEJ~jMn;@!CW0|>w z>!SsUo+KpFdO{sGI5&D8DROiymTYKbi*j$@DRPO3fLK-xexo+0fX3!I>E=@)uM-@@ zw5;V{{*2RLmEnS;=O(p$#fn{`5}Y0feW>uB6Z-t23yy8jq6!IfWC2xZQdD!Y`Ze|% z@D#CoQT0@ODD5^8;2E<5^|j)KM}FJyIv5>n!HKTghehaHSu2tTRifA0_KR1yPog=K z@p%&aCO*9_cnInt*Q&uZToXiO3Ob?MN=%ls|6sI|CX4sJJnbQk0v~R0v@P&5OUt|f+z3(0Z(){vx=(hB319XsnSD=#^y6szD&iv8m zV#j8rJ8z5xrI>B1#ADN%=K5Nlrb0;dH<9#IlT-#kxiG zH!ut62DJ&XfuOKHk5)wMhjMWH@ytRV(n|Wb%~5!7u*$=er@Q#)XA2VWIjA5 zAYir;-CnnZTqmIR68KhiHeSt-3GeQyhIy~AxhKA8;qiyU_dN1_#Go?nb$`v85p6z? z|MErbrA#ZY-3akFBB<`eCAX(l*Sr_ZVMaXJirP-k=+o@c>R?3hYGMsJ1IqG5gihLV zKtYKSXgl4YsW|wjlwr^>AiIIGknmWjtE)6xm)d+t!$$PZP%q=5=Rk0=r}q_c+4g}# zkRkthhrQeyrOg@YvscuMzA65+@#0UJsTLyOvWo^1E>;k^Ur#Y!5I00hmgxYZn`)67 z=ooO#chSL9=aByp@qV(lgnpUY!#IXfy5Dsf=Y$oSkuKJppHa7M2eoKflLQfIk^w|; zR4z_?&^cg931y-iL6t8e2vOfAz^1K0%L&uF`fs893T*Qce`X#v)@*-i9Ef_R*9h!x zo5mYE^aH^?PV*-WXwazl-N618Gu6qtUEbRV&cL~YMNt-ZbKekJTbMF|sZ&pekx&Um zLi6Y+RkshDk&(j5+)*7<9-!ynIxatYXtN4*Bz_;~p~@RW8v2 z22scT$nEE8)8GT(&t;-U#kMo{3eqs^Qn{4$lKY9baNAetFy3GT^W%RZgj-3j$ZSx( zG$t)bqBgTy6IL-4Q6>bFrSbB&8GXs}mA$A~G}$aF_{ZocB(^cmtV|~uBv`^iD}sMn z@Te(yVmB!S#8sY&Tdr2igOM!oP9606a(2u}gM-!$1YC2d8>d+BWYbP@y>*g-1L}1m zxZPo~PBf+0vFMX9Jf5&BOHW+Fo(fCNY7$8C-p8$jAOkBDiqO1bX{kH}ysF zeCOWu=^YKz2*HBt*&oq&l~XEYSeeC)NwHwFdwv|h`icEP`@6wwrmdY&dUAyH5Z8op zWFeZs-3W1nz;9cZ)H%pY?p$Z2q_OU=(vtsCwG0>0h-jdx-yZnG2FTx7h_^GpH!7FOY~`Uh*-nu4Bj7{G4ms;xz1t$154vm z=MF|))>^qq>^U9}o{_*@HV9;0-dzI)T3AppGc3QbER~6NcV;#@qJQwLNaAaoka_Rc z&;Gx^KfEp!3IlO*Gl^|(m9JYGZCX}*W9}(`F&b?3r-H3rE~O^5l9wOj<;qXYy!fSu zAWt5TzHaKPpdpPclOqE`-Ja8HSVe<;fnE;jIG!MLcxi!HEYh*nKB9WUab+2#Biz|7 zk>Ch$g8)adkn3z<5Bq_=x4+L=GH&BtPOPQm`OOk?9Sl0lK8c<}9Vc6kPdturvaT5S zm5A_yaOk-rfaZfWltAF$*G_4MX^zxMC9Od@?@2%J&K<;EA$lr-kTQt|u^+duOy?T22)fy;|!j3@J6R*jUY5k$U7wy zP(k~Q)t%aQd_9JkjgE}4d0;Qgb2(gU-h2NQe8IUS=arlGzQ+4u)(BBr;w$mW%y4ZI z>5>d0;D?Sd%06do&-A2z-F9s=&2(6{lQi2610AX^%8^KOQ>U0skniet;V4_f4OzT7T*} zTNo*&8^iyz0H$pV?+JW$#>RO?8IwmT4t_+qHfe}$x*CuI;A#;s^iN^xScMcGV&x+@ zaw~T(%tPE1O{8;%&qlbBzG2`?ksK*G?RC0ILEXKRnh~8{k#E^-0M9zSIiEhbdz(HC z6cHK9YF1XNLSxQF4+|ob1xQ&U$DXA1S!B9RcC`sryXIYF5MY$ML!sy|owhq0g|0=( z@}3Gu=AVQ_0$~EP)IU5%j?vXFuIvLA%#XeIn~fT``$de{g&Ydab!aR9b}IB07F@%} zgP_}f@wz-gb2}t1lKj6*pwcOLj_{x!=Ippb3!2;{Q=d5*u*#Kgb(6KxXBrITchrRCpX#hSGs@6t4A9kn0XO0bY2N=;T|1ASeR_4EeZ_%$zI4^ zDX^EaYv@syt@}*?PM}|0G{;~0Nv?7rta(_(%boHOrNgKu##>i?@e+cuoDq}}L_y$? zv4UTod?ahNmP)S#6ru*I4Oa}AvwwnShnjZl?6<4(9-2*>pj*y=kF>vw$nb`Wj;}+D z#>Z#E?6biq8sdWLRT6NG&9AS2h93~yDTcrEAjzi(h1SbV|M9Qbi=}gz4XgmwuNGyp z4#d)YxOMks1B(*WjUzRP+uy*ITEBS?!p)%(WCesy9FXUMh^pHRpcS_ON<(-w!c5?(Dc~6 zAB9I9J7?*F-%IsT3A;%~dbGofvNGYGlBd(wLJE#fXew)(FO^pi$g4$V{B6=3*cQ^I& zo6n&$Yjxwz?)@uIpkLSk@2xyxI}bG9V#v+H31Vh42!Dj1E61oop0AN%dWltS=I0yn zV{IG|OSmgfs z_O$(RI!E=~I03nIu(g0~jT}Kc@(SNr0TpI_n_Age0~uATltvcQr|ZLYTn1Yn+aZVjIu$(z)tu>J`FGA ziq*fs<=zj!-w90(Q++r|nNgXb?HsjE4JLD9G(%!UgTG)L*l-VWg7}u46`llyhWYmd zH&JE1ebA;hommbK(Gq_(Wea2joKXmpc;(pT8+7r0-|xv2ZdXo(FI(x z=Mic0$)9d>7ztY#J%Dcny!l#5v%!K%*WL{N$(%S6L?WVqrdtu7We5uH%{QQSA`NQN zUo$&DG!g+qK<9S#wDah&EHg*;O%xD;6Ko#>umeBsY!;`!5I8Cm17#o9jEMFfp=6(r z5A*lLcR0D<5%nmDiMZL=4sptn6eN8c`%RRKXg_c(=LZiiH|RVl z305plT=|@`MSQ`(LbT4sflp z!h@co*AF@LHR?hcMY&ZfDefeI$qQUGvcZ})Q#};;>#^Q=ZrtK_mj+KPiBJ=)kEE1G z@ISM6Ei%H9TVW*g3R~Zv(aCx2Nd{0qNf0qFecu)LRh1yHy7`o_zE}qQc$l4|2AWFd zltyWW7$<-UKX}o)o{k~_H-BbY;RA6k59UY7c?kY^{ zK|eU2ipYkql9q9R0vv}^kUUw63S?eGWfZ7f?s7o{P$WC9RdUTnYYv>dAPhRf*WVhiejNsU?(k*u3@n$&|fSReWW6cEBZQr?zN_JvI)I zKL1~+m47)z%7EobA%mFzvY41f`HEWP)8kmrhy^?CHEftVBLG3EB|q(oV@e^=az3x%!61?CAch0uI@NnDF2l*_{b%Ky#bE+6a6OOIO{omN=dY+Jaxzq_C_^g$*`f#`dr7i zPCs&|?r?_qlDZ%g+U}?+mlg(Z+~!&uWW6Hw2jxx;sgwHsYx|i>w@f9y%@sw#QNzNpT`RSj8u)w{(0mL=YtRjz7HSElhNy@og?hcgK6o z!IX-eoPAshd@D|15%2fdlIs>Xe%QuELq+N}KhzG6sw?M3q&+|HBEQAyxB9<2Kx~lK zj(n?|_%5%pf})hSzZ9i>1|*idfa^awq56Ci&(uHk_CT-^#eAP_PVF6)if1eh!ibB0 zBq4V=*(|bFZOLKTIYJ0_0am%?a%B7uuP?_rpghF(nI5qbGYG2LW=UV(vo6?m6P=B` z!84+6`4aDw4zOr4%r|b`J#fqHVKtKmZV??5yV^OdP_`~1@LC;=4JY{o=>rcN@9r$r%`3C^44@uxSF>Vsd4 zB4nzLy-FES{s&5TUE~2lgre7z6j#U3cR8#)dR=)yW@1|X6DxK@Fs1c+E&mUHwb*$Q zXF67+`oEmc8vQ4?`)axyvK_Oy7-#CUQ>>fF3GSC3Ay}8Kd((cXkSVI%d9js+n^AP% z=WAvLo}Mgr1O9ye4;b*g_1l=PPdac z$8I>CWA*KB_3%tTNf(Zps5s?w|51$2WoJDs-fx5T+c5ECWE+<-eakZ&##cfAsSRu6 z{VO@}MKJqioWMiN_FwW(U1Orzv8)r{PRc6Nnuv~Y+^zYO1zVcb_t)4@;4+1l-X>Nb zdi~GOWO%w1IeS-yu7ZwJY()#p`I{sCmp^9!xuWHX^C<)a3ad6~HFskZ*j^m96;b4N zl}TyQ8j<>L$ZAM>4F)+r(XcD4@;=V`xu-lZL8T1zMWmB6fP+*0%PEA*=Sag=6PJjD zBv4C5*-loBp4Dc@Vtvo?bxg9wx8R5sJn{oVmrrna=0o=iaoE$qixGeLvl@L6_h}>@ zAVnnS^R8FZ>FR$86YaiRdiP_G*ALf>UfQNIb2WOX8hyE(slK$hXG$q1$>8_WW6|mJ z&1Lmnt3Cv+kyHmAe0>(OT)pzIsA_e4*iB3Sl-1{<-$R1TdXP% z@;T?fQ&uF<$@@KPW@ooup+rReOqbsY9XqvN%4;q#V9I_bC~}SX^Ug-{w}EN5(2Hiz z7A=Iw=A#~~dI~GofHkVT=B-Qz2q_0S!6FX>3lMV@U7lcFaqTF(E8Y|FERyJ5uyyq} zQOT2bRbR&2lWzF#-|hMR)ebz_caNhmEz-}d%K_Q zdOB^>hIAF*;IB|7!3AxsGI55XAhV`Oc9sO|6*0tWAD?U**DeXvif9qIEmy2BLeTlV zo=W%K%$Kh$1Rr=rskQ`)+)sP$A3@OyEfgX$Wbt7+g9{@tK)enM7R=tm-q}I*X_aq1 zvDg^Z+YFizbzx3HI%bX`5(=5L3{el-)B-qc&pd$#EbY%01G0rcFh^&MrQGJkgE%rFE2Q)gU^L>6~DKS+zAxF|w=Q#58zmv(bQXlWVm>C40g>3TUXDJ_UXW3R@W$_ zH5@GhM(q@#7DX54va#p9FLo7>r@GDT=08H6#eGns;Sd?0m)j}Y`A`^%p+W0kLf0dG z`P6p4dvv;Fj*z5a<7ReJUVik3(?}g&>mGr5^Vv!E`R!a>CgCBP^pWH8WqQ92yS?VbvJsY$Hg-VXSW1@N_K>cv?EtGJ|yAK73&yu z%Q;64*q0MN;-Zw|hzBQ7Jf<*ZE@6kM5U-c~h$1ahW$UJAr{2_j2#V)a-?rc0M!bLi zi>X$$&oK5!lWVzqZ-QLWh^T|G(W8~H46^w#fP&`|U;9$Z{h1ery3bd2`$E29rG-%y z7J?|DNFZbbsC?})3CdrM_*jNtj~8L^4nvO4MFUY$s8yir>ShvSoguMo2)CqC5j+eh z!jHh{Yslfxy?RsP$Hk<|!@Zg|iq&A`FD!mZ3bLIC)l=Xa5Fo<}+cCu&r>Ea2O4dsR zNwuGM5nqgXLmK7zlmuN&@U;ZksUMK@Xxx|q^xn)@J=V$JbA&-;dB|^-%GtwpWx0Re z^bNs(gQVgOCcG^_jE#dwn7*_NhnA&w37+Eji}c^+S1ov|swNZ@REV&>cbwU+I@*6UD4FllD7TwzalSgdabF zQFkI;4+qMCNZD>k4gE(olCV|GQieiE+LTfjXAd_hej2dtuEZN7Bc9kO?wU!(lt6%=(5UJXtnqbCaX&ZgP=N+?#HH^&u5$4bj~TTG$MJ@+e=4* zLKfclD$1Qs%Ri=9qgZ--oto+8!|b znPzii@>>PW=H^?dkwWgY$TolI;q?3LlWA48rB@+%ACHByy3lZm4)ZpHhhV{;q<=)+ z&;*3#w&J^NrlF0-U4cWjW}edoLGM+Y`e+Z(uzqpds2!(X6DMYeM^8fE?mEdQ?VF>{ zzI<1+kk*5IO@erXXMXf`Z!Sthy3cbS{ksp;mT3t8nJ)cisr5sVw6ki3w|%p;@MVuFo)k1Zm73rTtGE3TEx zUB{oEoCvEr$O&8u9UwqaE35?FFG?U91>a^P=nJKxP;9|y12v?+7>cNyiY4l2cZDfn z8s*g&o(E)io6yXuwKV*d*|k|g!)Xho9q}S;PI}&TgZicWWYZVr-tmA|ufZ>gjJt75 zY0D4V#x)hMK}zRv3vENqftE41^hlWIP`|7W+_7Il+#YWwxJpLkxUGJ$-oct98gzQr z9JHDu!_1~u)0&gqDcAECsQAmZ7oHHyhBWsboD)Fa#dk;qHl0)s-@TswCsm_zk8)C3 zx>tscZvD<)6>2f0cuY!_vRwJ%6>oZgCk+#82dC5RN_t z1*A!Ecm#XI{I7PF+FkIMCPGwvik*j&T9;W0#q)IG)o2ksS+lC8FYa=1D_|d!E($J2Fr8NBRiKiYH>I`{ukXWol9z)^ z`CCuUJz)!Q1SEVL{tvB-E4HiujJzh2Prk`<%X>g2F&})N|20nHf%D}lRCFh&yhOzF z13NYBh?kJJ1Xr_0Z^G^D#75R8O0EJO#CN`#1;?%75Kur}k681u=MkQ3y(CO{Zxve} z2H?0|SM4?nD)zdG`T(Y~vws;Q=|5GIwm<3vSU2IRJg-CnbF~%&euHt`k+1UmC%FhG zS+R=t&R;I9G2wvo_>H{tsJznc+P4A}K7j`~QRqQ15v-IU;LgEig!i-3w=NhMz<5?> z#MDMbzw9SY5XJK2=i+f9w)!HoT7wb%=>0elyX5tA-_7;tNM9#M-K(sx?31S&+cgNz zl(=&&&X2w=XKWeAQuR+H+&^b!x_C!WsHfXO{)($8-V)3#EQ4and4zcoG!gi##07fc zuYDml;T^}Tp!pCCtRY8S*7rZhTM-Dyv8{sRWeC~`1!-~o$vlQ5ji78z{mZ-0J+*(^ zmHF3jDD!~8&1E8`)!#;Jz7o|m72zWef&RotxYXw_{YUXD=H7nB>zNLpTK+Bj7WY(( zEhx3z^`(n&4+CD6Ed@?xErrcPRkP%(lFZy~F86%7TIt)RO-8>vMNuh;K3|8DIj1^t(I z_XX#(V<7_=t;>FSj+ictqLNB~cyx)%n3_`*xu_7w)?v$pdcZKjB1}D|^Ln#EEPJUu zdpyGAfQlOD2dj#&W_;0T4hvvWsqY&<{vk?u{6ytZrX&+Cv+lXa;ejuH^9}>5Dvgo4 z^M5p*hdZ19+lFJW*qa(DY7>;AF=~^RnpHFQs8xHkw%Ejs+C{2XZK|l)n_9JMuP-%X z&(<$}kK_Flp5#fM=X2fHeV)c$_%~FG=d+dtQ_nZCheWfyGSwvfF35ON|H3gesZ1V9lb04 zZdgXOiplX;*n+yfZ@D`emW!>KD<56B2r$sJFQK^&`Ymt3vmF1Qpyjnzh;F+YSqdI! zODy6_aDxqte6t}( z-g2?9@E0JFw+qq*ARpw7OEwSa(C{y;0^wZRpMuavwoSBP_rUl_WUdf-55}>#=ILsC z7u51*61BfuY8<`Do42GPpQ{m@GYSo-EKrTFnzCaL?w>in$Y^LU{;*;{8k=V=apl^u z->1rGzA4v!sdrFK#}*N&C$bs5sY1Ed-g`N;pou^|;!Y6$2`$@6p`5bv#~#Jz7NdHI zkL0yNYJ(}r)C!>|FmXhG!4w2g4#9?hgmB(!r3XWDDMByPmmeFS*FMX@ZNTbp zz&USe_H8oc@jlaAsJzdf3-~+;Nw;CJD3m+7=xTkwX)W5aNn?G+5ug`UNRM()XO+VV zWs@p!em4lUc*xaeGMSK;STM!P6|Eu>_Fn0y?xk~B9KgVd&})zcZ$Fm%C+O_a{=e|Z zfxlC!#3;#3-eS+Ez2IS`Z$&F!eZ2sw!;aQf^4&o4*HYh3h|gfF;>`7A62wy7DL%&= z!v!zh4w4b;VR_8!iGkuNhNxH(o@&n`WpzSsrew||R|WtG;_c1BGz+q&g}d(!p|7C1 zMyLn_x}2$y{;Q%hxubyOVjeTudPAm)0i^Yu>@NS>x$*r{i3KhsJC0PeO?UNQfAS!S z@9yF^7Qx7@9eUU}G+um~4>*+60n~14f@=1xX|qa{!skU@mR|*k>g}0W84Bg^olm^7 z&J|&TN>md=A4^1y)r;Cc02s)oZM)Q_tay#fvAdm-c{{FP;7`74(#}^>LFTR7udkL? z{+RRBw{LgxHjd$?mA-4iw5sPSicYyS=ZK2F$FbosWZMyE_}G8)ujPB*rF{(|!L9%& zjpDy_l-hgtW>;L)iye<%Ruk8;5K)5dJR_@W()M9@q|#~8DKt&HI3 z5i3$xAB5Q9+kH}1&$?`aXDTeBhBVx)>o}_BU61niY4b7oQenQ)oh zXmXqr{mPWt34_#VSK_79FJJs^rpU(0ejl7%SW8{$P8y>Q0V?V=ck$(TaHZSGk_0%-*B&}ed zRRK_bwNU-XAG8ps`l+LC`1$=ji3zi+A<5S0)Sb8S5V106@V-^aVXA`Q3a z$FH`E;_P+kk@+_LNo)2jt9Ppl){-C2fF8V*REYobFLVYq?c11yRhX3k* zs-q0@B;%vh3DBQ+UFL;HPs4Y_#Q(c|vD-sci6_U9;Z(Mb@v5uJX5D$+QSwbabeToc zY*RY!+$Rt%piwyccn}mK>i;IKYkr-)<(^9RkxM0 zoVDB|!n7E6h6b&8`N{2s7#Ec27q8#`I4?*nnJF3}fUEx(|LRfz2-Z~pFPRyze4x@2 z#=FflGT5}~5dI%%S!BSvY~NqygZZO5J@TE$J$hXvnYs^|*t1dKok$%#DfUR*%OqJ# zIuqUFLYg;*ba)?C_Dy!)wC-og^oGB+25k2a+9bbh*+ND-b^`r1*4UHKGEv|XL8KxW z)IDAubf;&$7tr-VUC34!SiA>#pt>TXk*}!i8=9^GlB{8s(OF2BvfgBAVlVE($rVIi z6))xC)X0FCIER%|Rf338jL7q1Ie2YP-H5q)?86SZc1z|m#PXRUe-vB5S$a>@d2*+y zx{3?V7%faWtxN=}4abW79X+}xJWaCM5^N<+&nE@3*W+vbS)8g*pVJemdQ5CJ)y+&& z3e{FybFr2V=*pT8ilz##{yjDLS=MHZMDBthXQ?5w927^$Wir-&=Pz{au2nJU_GLU) zhzrM7y19oeWfll{tuvBo|EZLG4@~rlXcVBKyp6{rBVI z`Xk_SBY4ATpbjPB&CX{*koH5+;6^M}&b~I8BDkXdF&6cL*Cz^fFL_zJVT<=Qva;K{ zBEB=W9_s7tw}WV3_!vc$DaL&Qn%<-E7y(!hvc5am5+Y@XgMsNKEvN}m6%k<8?@$kA zQ%+_aFn*33r=Pw3B{gZZ*sNX?AGa7cy1fOp;=L%4qXYo6FYcrkfOIQ>M4^`0Cx9cI zj;yaH2LTKS^HXo{6;i2WHoXwZD%}T4KAbIpSYNJRXTXf&m!^H;tWD9A%LoB_)!02V z(%5MI0skVy!iB+_Y;sFtMgG1}U2%Jj^`(~t;i}(Ldz>6&o;d%3Q3FoGUfZQTX0Lj* z_&!Bo6%0845Zy_lspg{hvTIBNq@07p#TZ;0XFWTpA?u!Ux@tyRK{6VBwc!nq)v*Fj zT1ljVpeQF;DO(>+DFzG^apHq$9<+K?+&PQQ%~&`iPn7x)V@g^kFs}*?z-DSMGp%tV zM5ePORIHxOoE-o6p|%XSb~g%R1;}LdgyD_{BLo=b?3;)hB{WR~E>GN-i$6aDoc}OQ z$$4tTR65V6Czee!c^!(xV~^Od(=@1xUZdv2FElz~g20BxV&+ku+mChnj7HVzz**{J zoH-C3=8R7-!zNoQ&A;KK?L&4f!B#iVl@II?v+z_ck1lk==DZ&q1;cgat1-55vQ>^V zbh*SUPB8L|h8xr4+Rw!H57x(z*kmx_iqp)O0X$^Dgxr*W+&&smi~g_!hx(5Mj2N*k zSlC>hmwx84XQmJ$BUjo>Fw=@L`wC+CB>y9gmV{CP(#lpFN%bAk;s@E+!lF%9%t&n+ z$#fzNkum|KGS->X&SBR2Oq%?a;@ox59EEH34b^HPawP&Bl_t__^aFS(6sy|%a!LGl=+F#jXWRoL{)1FBr3DYswf45U2vVom8%m-ykS-vfdSbijFVLtH~wzYiRW5{$I`rUYTmhge{c(7vYifq*Q?gI`%$J@G19!GpN9#EmrkX+R^jL8 zM&R2tKP{IkL32dR0M>Hyy2d6UW-7wRs=^LoN_uYjUNlyzkErqHkQwPVgi`4>Gk;(I ziP#Az!i0XxWs}E&ZnIbF828*{f-=}uRR9=nH0i7M=@bcSpk69xVGq4Xzk$(KmdbLN3i(t{;5{C%(OmN`?VxY>yT`8qF{82cnWqvf6AS_$e0y!1pqF z5o%;(4^1bO5PZ%x$*X(W4Awvg&wM#n3i z-m%{ZDt7<+uzn9GZB23Sk&3)0*(M;#D64QuBn3$g3t6N?b3KlcS8sMFU3e~cR#omO zj+C^1#u%|(*rq6Q&H#sTW{D2oFR*}`&PWw;P1FaaQ1O$lZLT4srbgVCht)!Bk!>dU zxCT(jXbItGUv0vy9O__0dwTvj_Iex_hAm)yS=7fLBTn#!NUEHp-`H-fK&yu~AWS6s zS80MEL$Vl_3OlS9L)x zSmYB(BBAp35p3{*VW_-~icSVxM_?O7^hi>tJQBmL97iYaxxO2wihaJvh{maN_!GnZ zoDRKFqp4=OYWYjA)w0|s5x$9~#v^4unh;YpKy;^_3)bS4)j8m3d2=D1Ty5b9ci;`a z5yhux=bq48KcM}-TLZEWV1E}6 zcb=(&A|wQwJea58E#7ync$C4ZF|~V!mLamdnZ86Ok%-Jx>T4R3nzkN>{EinYbrsx{ zJech2O%@cXzm2q=ZSo4rfx!M=@E|1HWIdgNWI=b}3vZSo+vG0`j#O@Z!eyM$3BI+k zYx39mDsumbyi6y39Hsj0@0;6E`>yts&D+>-xDBAETyOx)#N>Ts? zp3JM!9a{}`N~QW{2CXU5F|I!QoHCq)5Q_+-3>iGaLg=>)t3y3pcoM)sSEu)>&8 z&h7?@OA=13BAeC7BvVjc{f!1|WJY*!6su3{f(Ra;%3z7SfU~9+Wtjo>)Ul_`9|yrs z``26eHmy8?1}*#l37x+xlQF3`v|B%Zq=xMp>;YZCgbAiRHWz^~W;^aaknL}WaZa@| zymY7r(kO*>LJL%eQXAo-Bvb(CY|g_X29r8OoRVg=#clM*FLI#*znB^vF8L;jv(LiN>M?e~5S&CpC}Lfd8Di3CmLxe|CSarzT#jbJ(!_QEFNzR~*cG z;VnR$%PNVSd#l@tpJq*KqhB$AC=_R$zl6?+nV&ATp!Boje3?=bh&)r8d==jok5Sc2 zGeG<*Y)a8o*KS4Jb@_!jn#SeJ0TXwk?~xEAuni+6lHU$vzNQurwPAR=YAAv{Uc^?e|GnmoZZg3J5SW{e4y zdx-}k#zP&t=Ng@$cY3FMKL9a^`l=rL#<>jmmR+k5DPE0`(GKB`ttsjyLzB?3hZLpa z*peYokIwWyV3k~URMPD0bQBTir(x>~>b`O-%T$2}n%e%lb>OSFC+bQOe&FR6foB#> zx&TcY`tORY2ELlKa9(>bcIX@y$?@?$kv(g>4k;n?ry1P#4sQARY0&$ zw@VDm^unw2NwDP5Kryd<eZHUM z-sc|SH@rqfd314(!(NdA78(E3YtT2`OAV1JPvXGK5pbbfcLz!b920?yt8&}dWooGr zz^29V1PM7n9VVehBuX(Hhsr)fAquN#E&W?URhZb_w;C<_>JAsXSVKo2VQ9!zi;q^c zBb4a&cY6GaUoLkad^}6bUh!VN|4l-Z;={9S-4ELcx54s(&mX#KRo@(f_^PSAfOq== z>}yZwK2C5l)Hl!>S80WJ$NRNKQ)hv_ zwm(;g%ouUnCA~N))w2%u+6vH;4sk+rPHQs+BYG`B33mn3(f9OtWsSI$owFw z>02Lm7tEHOG5xyUbwc)_Wg^CZ{lTbt9~w^gMrR&u`#5|Q*q!`ie1Br-VBa+UI6!G)I0yq~E`gUD3vDQoI7 z)6US23mC%gOcL??%r2X#S)=ezG4&`lx7K-QN|I0qso{u}Qp5Qavf3eg7}`bB3IV{% z$oxmZV%>`0T>5QlN)qT+Whl5zU+z`TO!q*m&VzYQr_<}4L- zMcauKHHQAR(zQNi3}K@ubyW*5*TXvR25Q^T^fcp-<}V;$AHOSJyaWe4VAU057HP)l zH}hz3+K-DWZ1KKhEQl7U`18W0A<~CwPli&HpQv7dEcttj!KVdg2w_*bpn&F0v1~=I zO9QwepdwO0n|*`?uBpbY9EAfcEkaNs{!W|lq%K)-icxu5OW_rZE5~J+-srNsXsAug;+ysOh zwjL)cy#TTV75||CCW{iR`$PxYAId1*7_%-m^>Z;Ew|yZt(ab3qE5PnGN>PO7N>>dn zHb0nisyF%ShV`+?k#evK_-p*>f$_^3k$_uW6y+V={`%BR;1bh~*_bYI&CTI&PA}^< z)L-RM0px&+ABVvSaA*eZS3d!w1btbrjc5CxqTwn=txvX!v_v$*_Hg-zXj}K$c*$J~7`8uoL8+#m<0{rq!<(dD zWkA!qQtd`Y;kH?!?5y>(Xvf`2zue(l!-uJlesC*w{KF{3m%Mxv?FR^c)v!Xx4VLxS zK+b$>2`)o*tbJk2{MVE1c)WX@9P%z4K}RsM-Ls7x+?$?%vi?9EvE!$5Lv9j0D4Vib zHZl0a8mR^Ndi4GFPEWGKzYk?{Vc+aM;J$cf1oHx#n^@yZ_kbHWJz)G2>wr*EYMzYRRX*`*W~Nc;C$io;IWB$(^rn$JdKE!7msgg!vxYkv93~N*%_5gJ$)W z%pzCEJ$dCcQ1^g((f!%`iFk`mv3`CAwI};rR#sQV;dm+h`8+h& za+klnW(;2iki+8tVGGXXR!-%k;1MPB!962&GUfqp!|}<8~hmYb;kAC^Z?wic9qLmMR4%tMiiq3F~ZY& zj$SZo-H6m$If zoHNbV$#r|Xav!{DL10z$mWC^G;?a#xtVJ9XmW~@RU5{A zw{PULdv&}K%v9b6P}<{VUBp_ylCWmqW!>3tI>9GtbN@hu&|O}6cJ^*;mOX3kAoz5~ zBI=K?{*dq>pO0M;t_R8Lc!5Ur=HJ_+=Dp6}!V9a+k&SPbGjVPsSUnpwRH38P4Rx;D#+|Bk($G6!C6Pp@sSURdfV8yH z`6#kcL(d(Y=)N{nZT(KjM@x(Nn`teKhz`*x*aYNfx*QMZfHGf{QUv*}QwGjuV{$VW zaEUJe`LNpn(md=UsTNoqL%y9>_tY0L>$Drm>VU)zC<$wQH=-Ps>H^@W3GJT{FYc`& z8`~I{dVjaU5&ag{FtOi!7)Xw=E-&s$?cPX?il)YCg6|6`9n$nU*$xoTzgk8wKMvJZ zq6Y>y8WV8QyE>=k1xw+D!Ux1w&rERLNs=X2lbHiLKF7&&Mdhsb#L_}YiY*_85cLN= zvRo=6ja4CS^2^1};h$}-?&vDDu5SIRkS8?Qj_Wl80fBUS594rbYCz<-!3T}A3r6SP z&iSfyudw1a%AgKD21*41**`b$Hm#$w^o>I-ZWm_1Y0gJwwF84v<9M*U>WSyS1V1Am zV#&m|2H!0c$p`<-i3v2ORSA&x*yDvH%(Lga55~l~K54geDZb8lekyv`CHhrU zjJV(r!OzJYw{L@99Yuh%pR8Qfl|ap>!UObV-KP~DHx+?%7D5#jefHSumxk{^>cg>JW(uoubtS!u+7}gxFaM5HTe;S;xf{6btJJkBNl+*M4!ogTXcT7WEB6OW2 zONO+0ao{{iN3pMGK6#hxkOkcC!O)Z~iGu6rA?e-aNj~Twb)B|WS-NkXm3B(;Y9{`K z{7v@r`5sr;S^lBF+&C8E&K9cj)5`kp8IUp&4MSfjS){h@co7mSD^Zp%&R0)a@rbUfvrjpQ2bhTp}!aX zGbt*=tD%+HLknn`A_Eepff_sVcg@YM5vFplBDfp04&T^Ry_6nhfrf%~C5pG*gP9em z^CG;7i!(}Fi<%#4ty9~OMERcL+kB-HtX)h1q11YuM3_t2Q|9`H!$(v1{lOZ%d%t|~ z?`4<`c7={hvVB6;y_|e^!5?_aWm=in1bzKtd8Ha>Y1uZ!xG2Z}`!w_Ire%1_evwR8 zozT#A^nsbUbVZcz&afa^huCX-hsAU)aJRbPCW4*x7_Wlt<1ixnf{`heeRJE%-@p8< zq#TDS(Ctv@H7*+6-LyHuCCRQGkn;60?_x!KJDE@oPZ*pkqEuUUlKr8Z&A7EeW(^3b zv}Z8UjfQuy2(~DxSkWHRbnuz(q6YLIB^$fa;SO%FA6sw8uW#@m;xfbl`u?Lc;P%40hSz1 zrJLxIj)UaCj1+U+m>@Tg!dc}O$1#$jUnehOR-oj`S{vk=w9~326_QlegA#W;#zQ57 zYkfQSQ(-Ma^kimw;4&HJ&N;nGxZ!*ht2imqPlg3=Mc=>nVZ>WnOj61R>56frp*Vds z-MLxmHE;Z(f;`{=+i8xGt_ih#DX0coh}oIff-`}w*4ZPj&P4W4WO7eAPjC?L5RF`J z-eg9;h&~FMjaJSW%QDyEMkLm>uNq_{oaC7yCis0Tg&*>*-{SseqKUL;2W2SodISiE z)e6f%Kc{jNl+G&(BKg>rr$}{e0RQ&ZnP@{S~&^}_Isge(EoG3yh!(ana z6oksHcw9frb!mySrkORE926FiLn*T|21(wMVU}^*gN@hXW2)z-%FkqaSVq~n z8Yv$T?|L%LFVTPFvc#?rej6!^xK1&sr=r=avke;AVtb$b?OEwT_2Wi9Gf-|~IpJz< zKUG4CCm0?88IHRWbNu?r&UA*#yw3I8G#6nDU3RxIT+|ghC}@*6J3>(*jQw|gUdR(M z@pc5Se&+iJDqMhjSSkDWn^ogQ{_3)8zu$2nW-U%rUrK30oV%d@bhUs+M9-oB>T{{` z6JMRZfPQ*F6>}la8ssybMiVN|&R?tRYeTt$k7A*>k-3TLFlWvvf7u77OK9mVPjLQ4 z(hHDMa&mf&RqCs_=^?>aiIS|4?Y+t6Z|Us8wQ$1|foJzi8OJ5UDM~TN56;}m7Ou<4 zBk=2S9{u!f$cd$v&Z9K?U5;xZ7+7dP(1EZ%rlWXdw6~A}LlWlbS8h)!19`y;QT9OT zMktXGe{OvoBnm%#t(vq#BmAR}Q`yJO3Bu4O74RJ9xmp-LXOB=6_Ot=9+o@KAYOs%B zWbW4+Hj4O&Y^GgfBRLZtJLx*{`;`C+`P19E8ctw0pOlE*$(yAkEW`wZa_taM9Yidy)Z1WdS^w* z>^CA_yt`spqEmSO(3C*gn%;O46ip}jewB9y$nf#AfL-VW>}SOVokEQp2HM$VAfz~E z0(vm#L9)_Bn$oNH;thSFme>FRfU?Y3R4!9XSRk0~Vzb$VV{)FB9f0@8RX-9UXL|#AA#|HP=(c>HOIpv#^IMz~ZU0Ck&UIx&+;918h_LFqKe zne!(hr%yjae#Snj z9G&%VH|Az!!{{8`%P|vankxHzvpPPzy*S2PXG58^=x?;q9C>E z#(?xkgyNKz3Uvuv!JE0h*B<5UWEGF&#{;BYB+(n6ZIt@Ao0VbugnQzPH4w^>QAZZ^kB)uK zXPR5&57(0Y+k-x3&2@e@Q-Kt~BFnh!orpClv*fwXbfseVRd>>a37A~}DwqpFo*rkI zO{%Qda>+iAcr7HSKhgYtu^IHdv_%M*^E>3X{CSxUHr2F!yA1I+|C#AbH-=eOT@>?6 zKxK~g=l?o#2}DnJADMQ@cdyzQe&(wkDw9~#t$k}<90@vK!P*R3D}6*Zs`-%qeXo^2 zsS!%)lw@nRn2B3699qqZW!eQinm6JKt2D2B4gxakhT+^os0d9%U9q7bEdWp8>T~|z zv(np@X3`x-VkZ(bZ(@U7%AJuRX@E9inKrIx%K}yuOsx>~fna<3-m1}cyNN2v?1zCG z9a$@TY$J<{^4Y2|IiO72VEdy$JF4upLs)l?m{6~rnoIckys(%*rMP&rSOjulEMkh=pD89SsUMP4}ZgDz6oQP_P|d)TrQM^U2FgW4;-<()7TF1 zE`f3-bd9-QUH<9%xLI*s39lF6(d+Np`f{ z4U?2p`nGA+KcYPOVAvNYv{Z|1t$@pKf3CLq`+1doEhrvKNsz@9y%SmMV|AzdFJQAH z_sjPi=X>46j|ox6sAq+8->^GZzvf)DwkP`BlvQij$g@cM?K2J(+T6dc!NFoVPL4An zpKp^xL^T{!{1rR4gx!ys2&f)H4A*d$FPfUK&GJxV2b%nZVH%y6uD12XyAfQOlHWB9}8-YQ4#;L*)3R%lWDZ`V`GCzN=v&|vbVA9Qt^JWOg z=~xn@Dtse{m+%s=5w6o`V3rAQE0h zMG#|aGjUlLrCbrBLX3SRklX0J+Zp!6AU{w*|wZ&T> z0j7zN%b)b20rP^L&m}08i?Z;3pdEkQdnsIc{e$(vRx5Dl?AYCXE$`MD-F9zTpX#6`)|D$Y!eGNy+wPvh?F5;SR4`LcVAFdr(bR`K!%+%K7zv&YZA(TTH5w9O zsdnW4uHhJ)ioA?E{ZX4jAmmwactS%p*K`w5tZ>(@;O(-Y-||USsZD)0^SfIExASBk ziaO(4g80Kt?-pz9E9g!QVMA)2b*%M&CTteSsPS9}q!$U_`AGN0oY+1huW2-|%W!5l zm!*GhCONu3tv^&{>)ln?`jg;|Crlz$Cf0NoYAeB*-niuC3D|=aYsX9LzSx2Tu_u&; zC*RKALO6($c*#|xh)yNw&aO@bQ`dlPxihS;OA(h@L7ik4KVn6}sYO>Qw+V}Ee!tc- z^;^{w0;7(wPfnQep4mcGw%7WZv? zzbgt_rWE$60cL6DBJh<#<$d=_l+B-J{0E#T_z6qpr+|Abyi>DwOLYJt7OXpTeE6e( z&K(<-RlLoP7Pv)(WaIsaiOhrJB&^UZ?$xFFXkNbadE?g*^#{9{K3OWc=7fa=E3(&u zlQIi%u}#HW2|y$qMaW$ouxl0J8p94ztr{EY*kpzjBepd>hb%rMSg^l?j#H+;#f`Rv z$77E;elR-tK!5vU>hK@gg1aGb zRBDn{ths7uW<6?5X4FL#gSA4~Exv-eY}BgDV|VUCjv0vwC?xQLn=RNgF4`G5pqHmA z#VYJz__tE)EdB@h9Z_Igd3SXff_r&%SUW>^oRF{%`&)cZ5bWfHYEXDz{ zTHVb-;_uK$|C#ac>bX3GKJit{S_&}2Cf*YHquPF*#tRWw;csQ%zZ(#dF+R#(h~irN64YEz1_F|8@T8;2!^jSSv~UqBI>;aRI^!`vn@i zp$@F<>aWyWa|05NFVkslcVP3WHlecggWK9A_Ml_RSIhh%caxc80yQeGeihU|$su6-WG9JIslXp#vkRB?kL6F~ ztb;0Fv322ND6jRpCyk1Sv8}@oYj5}9D%bNVV3@6CU%pQ3*yjr51EhBLiRkyMbvoad zt#6%KJWAVX2eyeQLNeF6n9Ic|CiY4b-w@?7X#H3Axg7++SZHOCgcg*(m>=!68Dz(a zKPj*tXs0tgN)54IB3ouwU$&yvdzckQA0MDeGxAH!RCud@^|?`_jZG!4vX)9rJY zsph0B_EJ+gK#&t`#U#B<#@s_spqp{ z7oT5THsS4ohwF-{#!~D)_>0+2Xqb3;kzQCHWd4)6 z1yNHq7H>(qSZwFqpg%UL(Db(^N@4`#q)}>yL;TX|^`nb-EC$T@Oa|AYB=fFt%dv5crA0 zXzYK2)()q%p>{@GAnN#&$^lw2-0WHT0djTtX%LAVqRIWqSB}on?r(5m8g0zWBks@X zFaV>4t5bZYAcChk!=i1o0WpV-MB7cSA{-C*(RG+4q}O(mHR}@P5{ylhoh!7>E!7Q@ zT!Yu+u$~#to(y8ye7H-TNqS_@Kr#NZGApfElHvr)j}rlSfdNaoM{D1t&B<&{??Nfmd!K^np&Mj@FE;VJ!QlLB?w z?A&8juSKq4rCgLJzml-ukxcU+NZjb?D$~3>>p0k9&R@+hovY5r{SZHk;zRxVV(3^L zs+b0nE6H0IaX36AuGQ}q6QL22|9y878btKoomeUuB(Nv=s(!)m=%7m%CrDB&g0Fdn z`YtGq{eZi$yt5&_%ERlm?emDlfmKi+u!BGNwo9RDc+&Ni)YAd*E1R%hLeo(M7=CH- zSi`wzRj;tc%)Y7V4RH<6iT_CUh(;`?W%DtqyHCdZ%T)GikDiT>L#VKxDRN5b5INf)!+PojlX zLj$S@2PHJ;B|M8zar(uXF0E3wcewLPOaX!Pl<01%Lm*zdNL%8n=r0R!9SN0P35a#h z*`NDuW)uEE4^`A|AU?!h?cxm!#LF)FRdkf^p=1G^7n>unMB)mGxc zpLUq`C0?jFs8#0|`;tq+r|{pizntp5L|uC|{hF&mDJ<#Q*dxD>2#<-YtPHnssqMwYg(P zGv$i8;vm8cxPu$Vc}i!fTOP_NAPrE7;1h53KH+Y_kW|bjPQBk=c_JW8>}xXGm*B|f zuxqSOIl)6~zymF7u5!45owx(DCKX?NHf#d-!N{>Y8ry#Smv*obA4tb!z~X)>_^# z;?R-#QA0W%?KNqu1)+*sG21YwSgBxZX<^XiN|_-wkOq$&V+0!2=Asmf-U~Di=I?UI zf_md?E}#bhUKE2By5&-PgbE2pOtn5bqpXK_;M7b;$`T4NqA*p^VU$QJ>Bj}Ju&Y~} z;rh%LSNc^5wX10sZ1imlUB~>|__@%l{=@%>{fJi=gOEUWUU>bOgS4J?gjk^c)`PKn zF{(?b*q|wFDK%V*xrdDVlsyp<@F^Nm zVVQ~^;`Pja?OjYHth)#@?>HNXOEGUe7Xd_ULGg3*QnDk<5C7!G z`BzHKIm#sNZ9N-vLSr0qgsb`x7|{dz{RB~+_b)>Fj_<1p79yGwYfW!09Kb;emmd%O z+PLY0V(QcP4@ z){<8lzr1TMP=vqY@|&ioI9x zC^IBBzO^<=Z}ssJtP&T2(puAZw1oY#i^!Wo3E`ec3lH=6?68AEQB9!Nvah6+mbBq! zS$PGzG)%%B%vS12pvZn>>MtU?s1JW%E9r5vkEp@tsn$m#9h5!cPfpPeD(tRZb79?n zxzhS%)yAy&BQ{O84B@nMJX|zm0FiNf4cRqlP;Lu5y7Z$>8wE<2jZieuoSqNf^_?F&hChrOAKE~E z_?9CzGP2M6+skH@H7ApT zLWH`{`Oofbq6{&9v{Lr<8uK;)(|f`M+CWmMX&4$iZ=m6j2wpiYkFlt<$Ci4O@Y=yz z@>xMbC6IHatu~TISh;Rc@eLTr!EB{G#0tuScH1#s^b|$;5$_N;P7C2D%Oh}{828y| zfOpQIN(ZG)SCu=*+ktwj3Z|=g$t||@ZEpt|dZMdk7 z5giGs!0b|82=Vddbq;!l2Or388q1k-I%gy|OPa6c*k@0RBfwx9@-BAAio6V}LSmnr zZ!q{nb`w9Xr(=K5jkHEUyk6`vT}}tp?n3-frSq+$AstTuOvSFb=Sxtg%gcwtQDuv? z6$~!f+nAS0LsS%sp@zzp%&grbl&eBp&G=?QU zLEG*71w`6Q)A)8jjdV2CwR9FV-cLR5roNP2VsM^zTe}%_QV8vB672eymdat!c#yj~ zx;Kpet1?!1pQ??HXEAo9iw^ttkOgIc*pgJjVOq!CaL2wTcb-QJxU_2N$*r)Swrwfy zoWS#$*IQ4Dea2rN>NyN#t|vV`q>5pHRZH#O*_Q{t`PBKDmp1hjxW^Mq*q~i^eCXHF zI{E1|L(z*q<>p^7Dq`25{)d7x?98L6oT>t7a53Pl4G2$WC|g5&f-Wz|*T}=}2IkYk zv0V>8zXpS~Csi4A#%k<@hI6FTn19*zT`nAo@^NyrbgcK2n>7%q*~cL_5wgYJ75%>LJqOVeM;c@7N9=QO}b}*#1ER-+K=~ebjZ)Sx

N~k^6RKUe@#<5A>lv{2ce32ks3^*!SThHySs3`C?0M;_mKbnNcV?lr{N@>~hHM zQYcM|Z3Zx?2Z2i;!I50N$m7|unp7|?8xj4Yz69$P+={+E>flKJ#w3Cx#4JVdT-ww> zcKQ5^A4zi+CfLqSGird$zKmra=#T-!_QUa(0dC$cPd7N2vz6I#h!0{;bj5&K+Pfmk z>=j_^H+8Qqpcc3zjnp|>^;6?E=Q@8kb-um0J7DQ>ONj;)8yQfTQrcbrTWS8cge=f& zLqZv$ZzY`@wrx$V9ix1oI%i(^%ItWL*aeMXN|BH^ zA}fEf`$l&cT?})*`=;*gGe`1%xRHz06)-q-4LVkryKQD}52O*BzACuCE-1iOb?#zA z2LxaD)gjL6knJO`X6X?hEuKg<@(|!@8>)?r@toXav1TXH6n7@eXVvz(3g`L?K3Ka5 zX$rA~PPG4PU9HsSGlddiHhVmp4nI8Y#uZoW=(ok$HH(aP{thwxi>M zt(B|972;5<^z^aGc#E@#q5?y^V+5a_ff(XZYsqICP~RUo5tq%lkgV?;ProaZz|^+r zDmE*=eA^qpzZ(aU0lj1;2$QpYo7$k8)hCgmReE26 zqEyLE-A`SDsjvO8=Al-)0lZ8?sYYybseWqfk|p}+#h{26`^dK;hGD|sqo9@F&Ahi; z!`KH)+B0zShBp?n5u&!-%q)wSUK*026k|oNT>1qnVJj?5RS1O#4ra!z`Aqzern6vb zt8KJsa0|s9Lb2iy9Eua1;!>R8UMMcb3WOrXitCHJySr;~X^~>Z-R(>7%>4t*kjXhY z&)Ivg#YuZB{9owV8P8RZhF5_9cH`4y=Cl5!TQP< zIBWAi#i*utr*f z3IP|40kw5O)4rPVojV4d3_C$P?8M{QqmnjQbDa#r?)n?1QOwSk-v-2G8t_uER~!WR zGr%HuA2(ew3XSDK)#yUUKRa&M4wI>rv>*;Yw1<~6h)?C>F8{9ju3drx-u;0iLd+6B zcni}~u3h$X*4?PowE84UGbJ%H>T(-$GgWQT*6fdY1dfso@f*S;gop%mfnc&wTps41?k%}5?JZiF&(nr=71usT z&*kF&bL!LYjbJqbK768jiB(O#LNt8Kmg%Jhlf_l&nSqm-K!7!PqA4zS+#|?!;EPo< zx?F{@MayHBPf|jj#5PMVah5-4h*glFaAQ-YB%r$vaHoe5I9&s;fet zJF|0a+hC5^DfeCwW1gp#Mzc~!(_gJJd1Iar>BX3Uw9qTTEl*9a1`jfAc>||lN>~

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+


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 22/38] 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 00000000000..1791baa82cd --- /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 c05261de31d..c33a0ae7701 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 c65ef99aded..4da4bd3f4a6 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 a2280b29165..4f9cbd73bb0 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 cc5ae37dad3..c8e07929b61 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 22c6cd46abc..029227d8745 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 00000000000..cbcab494933 --- /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 00000000000..989ac7b4179 --- /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 00000000000..c8d037098ca --- /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 00000000000..2e3a3a08b4a --- /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 00000000000..c505cb7e5ee --- /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 00000000000..454b3cf92f6 --- /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 00000000000..a79edc380e6 --- /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 8a6fb183f62..d8a8e2850ac 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 c176fb3c6e5..576438a8c49 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 1945193e6c7..54f0171e6c6 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 2fb3df21613..1819afe81f2 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 61348ad7cec..cd4977679d2 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 70d0d311d6b..d42da123437 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 77931d68715..487748de97a 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 0a3b6c7c1a8..f69bf2939c7 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 803554316c1..efc6660ab1a 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 d4404341fc1..7ba80556051 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 5ba2a95d503..ddb9ba7a861 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 a654dd8217e..c7ac1423e10 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 fe07aca6963..dd780ef9ac4 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 23/38] 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 014e3950e42..9fecbe8fd84 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 db06aaf0e6c..e46fa1b66d7 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 ec4ea11e643..da578c1ff42 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 eff46edfa23..e88f476a8f6 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 00000000000..713e4388e53 --- /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 f0acc22e620..23cc4da80e0 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 24/38] 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 d6127bbe29b..417924fc997 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 25/38] 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 ca641723c64..1797c755bb6 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 26/38] fix(remote): isolate shared control request timeouts (#9016) Co-authored-by: Orca --- ...-runtime-shared-control-connection.test.ts | 57 +++++++++++-------- ...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, 42 insertions(+), 53 deletions(-) diff --git a/src/shared/remote-runtime-shared-control-connection.test.ts b/src/shared/remote-runtime-shared-control-connection.test.ts index b355bda7dfa..0a2dd4a2e88 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'] }) - 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 + it('keeps unrelated pending requests alive when one request times out', async () => { + const server = await createServer({ + silentMethods: ['worktree.hang'], + delayedMethods: ['worktree.ps'] }) - await vi.waitFor(() => expect(onResponse).toHaveBeenCalled()) + const connection = new RemoteRuntimeSharedControlConnection(server.pairing) - // 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 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 } + const timedOut = connection.request('worktree.hang', undefined, 250) + void timedOut.catch(() => undefined) + await vi.waitFor(() => + expect(server.requests.map(({ method }) => method)).toContain('worktree.hang') ) - expect(onClose).not.toHaveBeenCalled() + 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') + ) + + 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 }) + + 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 585d3478cba..88be10796b8 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 6c1ec5c7db5..2f49bc082d1 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 91f555af787..759ac4f8757 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 27/38] 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 b4675047d68..e9c891baabc 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 16d1a0a5b15..53d2f6b6f11 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 00000000000..2d4a85ec3b7 --- /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 28/38] 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 | 411 +++++++++++++----- 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, 1133 insertions(+), 232 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 21c4a9ab51e..ababeb6e24e 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 4eb8938f2b4..9d6c825498d 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 57153e31fef..acde5dd02c3 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 0a2d7821f91..a237e941150 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 9f5ad72caad..71c94f84e74 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') - const { createSystemTray } = await loadModule() - const onOpen = vi.fn() - const onQuit = vi.fn() - - createSystemTray({ appIcon: 'classic', onOpen, onQuit }) - - const items = builtMenuItems() - items.find((i) => i.label === 'Open Orca')?.click?.() - expect(onOpen).toHaveBeenCalledTimes(1) - - const clickHandler = trayInstances[0].on.mock.calls.find((c) => c[0] === 'click')?.[1] as - | (() => void) - | undefined - clickHandler?.() - expect(onOpen).toHaveBeenCalledTimes(2) - - items.find((i) => i.label === 'Quit')?.click?.() - expect(onQuit).toHaveBeenCalledTimes(1) - }) - - it('is idempotent: a second call does not create a duplicate tray', async () => { - setPlatform('win32') - const { createSystemTray } = await loadModule() - const opts = { appIcon: 'classic', onOpen: vi.fn(), onQuit: vi.fn() } - - const first = createSystemTray(opts) - const second = createSystemTray(opts) - - expect(trayInstances).toHaveLength(1) - expect(second).toBe(first) - }) - - it('is a no-op on non-win32 platforms', async () => { + it('creates a macOS template status item with the full native menu', async () => { setPlatform('darwin') const { createSystemTray } = await loadModule() + const options = createOptions() - const tray = createSystemTray({ appIcon: 'classic', onOpen: vi.fn(), onQuit: vi.fn() }) + createSystemTray(options) - expect(tray).toBeNull() + 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('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() + + expect(createSystemTray(createOptions())).toBeNull() expect(trayInstances).toHaveLength(0) + expect(warn).toHaveBeenCalledWith('[system-tray] macOS menu bar icon could not be loaded') + }) + + 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() + }) +}) + +describe('macOS visibility', () => { + it('creates and destroys the item as the setting toggles', async () => { + setPlatform('darwin') + const { setMacMenuBarIconVisible } = await loadModule() + const options = createOptions() + + 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') - const { createSystemTray, setTrayAttention } = await loadModule() - createSystemTray({ appIcon: 'classic', onOpen: vi.fn(), onQuit: vi.fn() }) - const tray = trayInstances[0] - tray.setImage.mockClear() - - setTrayAttention(true) - setTrayAttention(true) - - expect(tray.setImage).toHaveBeenCalledTimes(1) - }) - - it('reflects attention that was requested before the tray was created', async () => { - setPlatform('win32') - const { createSystemTray, setTrayAttention } = await loadModule() - - // Fire the event before the (deferred) tray exists. - setTrayAttention(true) - createSystemTray({ appIcon: 'classic', onOpen: vi.fn(), onQuit: vi.fn() }) - const tray = trayInstances[0] - - expect(tray.setImage).toHaveBeenCalledWith({ dotted: resizedImage }) - }) - - it('is a safe no-op on non-win32 platforms', async () => { + it('uses a literal theme-aware glyph for macOS attention, then restores template mode', async () => { setPlatform('darwin') - const { setTrayAttention } = await loadModule() + const { createSystemTray, setTrayAttention } = await loadModule() + createSystemTray(createOptions()) + const created = trayInstances[0] + created.setImage.mockClear() + created.setToolTip.mockClear() - expect(() => setTrayAttention(true)).not.toThrow() - expect(composeAttentionMock).not.toHaveBeenCalled() + 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(trayInstances[0].setImage).toHaveBeenCalledWith(attentionImage) + }) + + it('ignores repeated same-state attention calls', async () => { + setPlatform('win32') + const { createSystemTray, setTrayAttention } = await loadModule() + createSystemTray(createOptions()) + const created = trayInstances[0] + created.setImage.mockClear() + + setTrayAttention(true) + setTrayAttention(true) + + expect(created.setImage).toHaveBeenCalledTimes(1) + }) + + it('keeps a pending attention dot across a macOS hide/show toggle', async () => { + setPlatform('darwin') + const { setMacMenuBarIconVisible, setTrayAttention } = await loadModule() + const options = createOptions() + setMacMenuBarIconVisible(true, options) + setTrayAttention(true) + + 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() - // Second call with no live tray must not throw. - expect(() => destroySystemTray()).not.toThrow() + 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' + ) + }) + + 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 ada6f58c4e9..27d2487a3c5 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 42d554417e0..090847e284e 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 d9b042db37f..ffdf4528d47 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 2ce823028c5..7e228f8495f 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 912e1852cfa..f4f81659671 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 31afc7d6843..9d5bb1cbbab 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 2b467df982d..0116866f048 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 21/38] 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 ca68ff7e47c..0550e9704a6 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 631059d6d3a..58954d32565 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 3ceaf412d13..3e15d266221 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 00000000000..f392da6c271 --- /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 5811f5dcf02..bf642e83735 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 86791576c1c..16f5deaddd8 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 e84df727e4d..838cf50720e 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 e39c75db2bf..60ab54ac4c4 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 5fa8b146a61..c2f095e2aad 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 45e1afec7a4..d4dc5016751 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 c454ec9ce47..313c6bfb238 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 b1f7cca3488..e7d7fc7bac1 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 1e59503c12c..2f0f5bdbf47 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 61c0cb56066..925b91ab284 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 343e0ffe173..8a167b08d41 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 22064cbe6ca..0573c89ec93 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 ab975939e25..91b7785f026 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 29/38] 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 00000000000..a863b503fe6 --- /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 0b552ccc4ca..8fd5d3443ca 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 00000000000..5f3bfca177b --- /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 30/38] 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 1797c755bb6..6510dbf0de6 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 31/38] 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 | 284 +++--------- .../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 | 267 ++--------- .../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, 14290 insertions(+), 1541 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 ec1cd450561..054f1748ea6 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 d4d1b35896f..0251da70490 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 00000000000..f75da01efdc --- /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 00000000000..a33c0dceae4 --- /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 00000000000..414a8daf57b --- /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 00000000000..cb766b0be51 --- /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 00000000000..3baa6da8c3a --- /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 00000000000..b485818970c --- /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 00000000000..ef2c1012603 --- /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 00000000000..ba02fe3d44b --- /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 00000000000..eadfd2043f8 --- /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 00000000000..6acf2d305f6 --- /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 00000000000..8e0223f19ec --- /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 00000000000..ad26d578d93 --- /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 00000000000..f470214dbed --- /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 00000000000..1cfe37e5d4d --- /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 1bf3df59e74..9d725d5fe60 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 b7d14d9a04d..567be99a9a1 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 00000000000..05e05b1f188 --- /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 00000000000..441f41291d7 --- /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 00000000000..6654a0e8689 --- /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 00000000000..50766b25ee4 --- /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 00000000000..25c904c7b13 --- /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 00000000000..de0ddc91f67 --- /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 00000000000..57351e4a20c --- /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 00000000000..1cf293a79fd --- /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 00000000000..3171b818050 --- /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 00000000000..4ace06d37d0 --- /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 00000000000..387736f065e --- /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 00000000000..84e0e00d012 --- /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 00000000000..61312670512 --- /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 00000000000..6b6ffe1781e --- /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 00000000000..2a034fd33f3 --- /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 00000000000..c67ffb738cc --- /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 00000000000..225904cc5d8 --- /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 00000000000..26aeea7680c --- /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 00000000000..6977b764e72 --- /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 00000000000..1b15a7e414a --- /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 00000000000..2f8ceb936a7 --- /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 00000000000..44d6d23c066 --- /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 00000000000..2592038d7c9 --- /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 00000000000..679be2d94ed --- /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 00000000000..4744c394756 --- /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 00000000000..fe3f9a0d89b --- /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 00000000000..94fbcf055a9 --- /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 00000000000..8a1f06dfbf7 --- /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 00000000000..e933971220d --- /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 00000000000..74ca01939ba --- /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 00000000000..01f4d5289ba --- /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 00000000000..4cf1776843e --- /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 00000000000..8d7d1e7e07a --- /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 00000000000..690994f02f3 --- /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 00000000000..cc92c6b3aca --- /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 00000000000..387fe9c3580 --- /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 00000000000..32ae700671a --- /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 00000000000..d08deec4b31 --- /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 00000000000..bc3f7a2f89a --- /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 00000000000..160477cfad8 --- /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 00000000000..3b29a6dfaf7 --- /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 00000000000..8d3785efdcf --- /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 00000000000..189b07a74f0 --- /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 00000000000..a69d66accdd --- /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 00000000000..a0ae29b6c51 --- /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 00000000000..79a11b6d723 --- /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 00000000000..255f60de3a7 --- /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 00000000000..8c81f0255c2 --- /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 00000000000..8f7289f0753 --- /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 00000000000..cca9ae3f923 --- /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 00000000000..1505c9d1dc4 --- /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 00000000000..ddd730e227c --- /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 00000000000..e0500019844 --- /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 00000000000..53df98e3ee0 --- /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 00000000000..bb90327c5b9 --- /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 00000000000..02dfeff84a8 --- /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 00000000000..3b3cdb2e8a7 --- /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 00000000000..11a17696bfc --- /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 00000000000..46feaf79217 --- /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 00000000000..e952335fe23 --- /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 00000000000..584f34624f2 --- /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 00000000000..2d381fdd602 --- /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 00000000000..27385f8d33e --- /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 00000000000..c6d36ea3436 --- /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 00000000000..92320b9ff86 --- /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 00000000000..c45980ae6e3 --- /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 00000000000..bdea29c7459 --- /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 00000000000..652b1e79119 --- /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 f8e07fb4d98..881391db0bd 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 5173ac5bc8a..a5af9bf350f 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 76b14415a32..dbf09b745b9 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 9afc4a1fd0c..471bc3c4a4e 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 014bea955cd..5420ba679a6 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 95315f6b00d..0cf672fa30f 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 616a9a10c93..ff390d13caf 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 ba535a25868..946f8653dd6 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 9dcd2c45038..58b1c0c0cad 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 53588814848..516f839fca9 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 d16a39b1f13..4d0e1765a44 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 3ed4e9b74a6..227918a68f3 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 5dd3cbc8dab..85cd653185b 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 adfa3856f0e..33cf8c23823 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 00000000000..ffb3ad83cae --- /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 cd87f2bdd59..ff48548804d 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 e2ea727c809..182296bda1d 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 00000000000..9ac029d5913 --- /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 00000000000..d610f5a1536 --- /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 00000000000..2d3b0828664 --- /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 00000000000..be4e60dede3 --- /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 4ff621d05bc..512da6b014e 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 1f55c33124a..f3228d970fc 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' + installTranscriptWatcher, + type NativeChatTranscriptSubscription, + type SubscribeNativeChatTranscriptArgs +} from './transcript-watch-engine' +import { nativeChatLineDecoderForAgent } from './transcript-tail-reader' -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-- - } - } -} +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 13ab2846baa..55659b18c20 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 507bfd98fdb..b17b3c4f783 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 fa694433616..6dc7b6ec1a3 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 6213765bb20..b10a268ac79 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 f261e98dbe3..5af3d0d22a3 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 50f196f2669..15a59321409 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 da1bbfbe603..e64143647ed 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 00000000000..cd47aa8fd7c --- /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 a46d24eb1b1..b9af390152b 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 720ce127d2c..15bb6b3a24a 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 05558c8b76c..513a2134011 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 ec85e873b9f..6d5879d9cf8 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 20b608c94c2..99a77fb9afd 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 d10456c8fa6..c1ee755ee83 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 58b37bcd7fc..e9fb481aae0 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 00000000000..fab3d5a09cd --- /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 00000000000..495a7933842 --- /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 00000000000..13213d6dfcf --- /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 6dd4856b738..d7623065634 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 7e228f8495f..a8793c5f8b1 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 f4f81659671..d1a6dfb5c92 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 ae418f9e881..8de2ad3394c 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 d76fe40b517..1ca5c2f5327 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 bbe7912ee61..c63a352786e 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 00000000000..d3e5591ab32 --- /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 d7c9172183b..25006788f1b 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 baea79a88c8..9c146af1450 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 8ddb4a46c9b..f7c20ef51f4 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 0b0eb791490..fd9bba9a5c6 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 8a995a7580d..f8da5702190 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 type { AskOption, AskPrompt, AskQuestion, InteractiveQuestionParser } +export { + buildAskAnswerKeys, + formatAskAnswer, + hasAskAnswer, + parseAskFromStatus, + registerQuestionTool, + type AskAnswerKeyGroup, + type AskAnswerSelection, + type AskOption, + type AskPrompt, + type AskQuestion, + type 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 bd31627f8ce..7936fd7e231 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 70e7a1d8cd3..6ef06b130a6 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 fa07de93486..59ff443fd24 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 { NATIVE_CHAT_ADVANCE_BUFFER_MS, NATIVE_CHAT_QUESTION_STEP_MS, NATIVE_CHAT_SUBMIT_DELAY_MS } + export const NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS = 300 -// 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 - -/** 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 324cdd48248..849897c386b 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 2851a4d84e4..a57d255b6ca 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 f49370f415b..e18bf8f4764 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 d79ab1f43c9..2122c1fb7da 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 e7c70e120cf..cb49cd9c57d 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 f60ce34d3c8..89ae267c8e5 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 384ae724769..ec1ebbc8226 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 c2f095e2aad..a0a4265cf52 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 d4dc5016751..5fec0c3ef2f 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 313c6bfb238..a760df56408 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 e7d7fc7bac1..ce8f97cf3ec 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 2f0f5bdbf47..dc56b80b941 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 c553ceb02f3..1728c8ab5ca 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 141da5c2a3a..aaf3fc4795c 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 a376fe0d5ec..84135bba8f4 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 925b91ab284..a64f4abe263 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 00000000000..b638334f633 --- /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 00000000000..8745d47b394 --- /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 00000000000..0c7e8e4a14d --- /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 00000000000..0fd148e13e8 --- /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 640f2ed74d7..9c5942ab7de 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 00000000000..e02f2bb9bba --- /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 00000000000..567ed97e0d2 --- /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 00000000000..1e49d1e8969 --- /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 00000000000..2797648afd0 --- /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 d662fec3891..a2e12ad8302 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 00000000000..ef75c81f751 --- /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 01c4c3e0db8..60f3cc26099 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 76475ba32cb..ae7c52587fe 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 00000000000..1b363a280fa --- /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 00000000000..ef6b385806d --- /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 00000000000..efe606f55a9 --- /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 00000000000..14fd599cf64 --- /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 2331bc3b1c3..e6894bd815c 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 ada9442cd23..fbbb24963cc 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 395353cbe48..9d3b4914147 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 32/38] 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 e387a13e3a1..b077b880576 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 eaa140eb2be..1e6d20f5cce 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 a6274097f5f..58f11dfdb03 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 f957e171d88..778b90b796c 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 00000000000..cd418597719 --- /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 474c4c1ee66..8df194dbd2d 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 87e12d66048..ed80c7b9ab8 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 0b0c3bc2eb6..39032ab5e2c 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 a0a4265cf52..6596b371b7d 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 5fec0c3ef2f..2b568c764bd 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 a760df56408..70aaea12442 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 ce8f97cf3ec..98311b01de7 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 dc56b80b941..7a439d1ef37 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 33/38] 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 1dff2ecccf0..c77b094b97a 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 bc002cf5741..38c4354ee13 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 34/38] 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 054f1748ea6..448b4beac63 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 512191599d0..00000000000 --- 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 e528ea324bb..00000000000 --- 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 dae8e57bc39..00000000000 --- 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 ababeb6e24e..efdd7ee9a6e 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 31e4ad1d36a..9bda55ca899 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 e64143647ed..40bfc0e5691 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 35/38] 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 47bf6b83a40..1e3fb4e6bdb 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 00000000000..6c9418e1f64 --- /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 8f67e1068b7..98beb0c1c1f 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 7ada984eb29..d5671498468 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 00000000000..c3542d6f0e2 --- /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 00000000000..cf4a8a100e5 --- /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 00000000000..bfb10a50494 --- /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 5da3b242817..8d12f8915a7 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 6510dbf0de6..c7e41d8ce0b 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 00000000000..fca1162fa5e --- /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 00000000000..12baa4b6e7f --- /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 00000000000..58d6c7f6c0e --- /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 680163f8b5d..bc390b2beb7 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 b81fd695790..d210123a744 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 68ff5e9d017..d65f30dc391 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 d84f82af426..ed74ef3ffa6 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 00000000000..f86d5c382d7 --- /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 00000000000..e7d403442b5 --- /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 00000000000..c81f1942ce9 --- /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 bec34117c4c..ba3726f43e0 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 00000000000..e3754197559 --- /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 00000000000..f4eb27a9f5a --- /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 00000000000..c43d137fcd0 --- /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 00000000000..990fbdba1a3 --- /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 00000000000..e5bbae31da9 --- /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 00000000000..2f8d32ca663 --- /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 00000000000..673fcb3d1e1 --- /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 00000000000..648b628e82c --- /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 00000000000..8189ee0b28b --- /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 00000000000..46216ce6c04 --- /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 00000000000..7162cedbc47 --- /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 a8793c5f8b1..a3db4bbbc55 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 d1a6dfb5c92..4aa685d7b56 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 9fecbe8fd84..ab19d2e244a 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 d7f4da5b619..4e986a2686f 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 20380332009..eae2ce2a76b 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 270e1785aef..0ffc0a2b526 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 85b27f12ed7..f1a34a0396f 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 b757261bba6..d597895d580 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 2e0d2baa003..0d4c2d5d537 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 7bbcea65371..8029eabda1a 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 00000000000..ca0090a6c68 --- /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 00000000000..135d860656b --- /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 00000000000..785b67aaf07 --- /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 00000000000..3c858686591 --- /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 00000000000..a810ae06cc6 --- /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 00000000000..0a0e342c467 --- /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 00000000000..c6dac96479b --- /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 00000000000..03368ec07bf --- /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 00000000000..db76800fdfb --- /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 00000000000..14a84fa4ac8 --- /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 00000000000..2e11a17ff03 --- /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 e06a8a2a2ee..e19bac655b1 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 00000000000..812ad72ffe8 --- /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 00000000000..91ea0c4bdc2 --- /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 6596b371b7d..f275b3923b6 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 2b568c764bd..9261f9971d0 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 70aaea12442..e1cf3313cdc 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 98311b01de7..d22ad9a5db8 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 7a439d1ef37..b9fe5b678d6 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 765940ffe9e..3541c0e3f2a 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 a64f4abe263..d6f6d6e5830 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 00000000000..08383d5e5f5 --- /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 91b7785f026..3de8e943d16 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 36/38] 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 21716851899..29d9f27d287 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 5892dead9d7..c330efb37e4 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 00000000000..aca9b7034d5 --- /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 00000000000..2c93f354ac0 --- /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 00000000000..b0bcd1bc4b3 --- /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 37/38] 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 6c3dcad2831..81671c20616 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 70a40d6d250..7b5a72def55 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 43c513905a6..2ab63663d71 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 dee5572e02f..c0382a5f162 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 00b7169b97e..be012eb72ce 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 449290d81f4..23325aa73e9 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 38/38] 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 c7e41d8ce0b..292873687f1 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",