From ee8ad028604e6e3f9763278bb86e48365e20952e Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 13 Nov 2026 02:20:31 -0800 Subject: [PATCH 01/55] test: remove hook and generation replays and cache assertions (#41951) Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- config/flaky-test-pattern-baseline.txt | 4 +- .../scripts/ci/sharding/ci-shard-timings.json | 4 - .../branch-change-list-cache-real-git.test.ts | 4 - .../status-branch-compare-real-ref.test.ts | 34 +---- .../antigravity-generation-stream.test.ts | 136 ------------------ ...message-text-generation-regression.test.ts | 47 ------ .../hook-archive-timeout-observation.test.ts | 33 ----- .../hook-termination-real-process.test.ts | 8 -- .../hooks-archive-exit-observation.test.ts | 25 ---- .../hooks-orca-yaml-unc-drive.test.ts | 44 ------ src/main/workspace-hooks/hooks-runner.test.ts | 53 ------- 11 files changed, 2 insertions(+), 390 deletions(-) delete mode 100644 src/main/text-generation/failures/antigravity-generation-stream.test.ts delete mode 100644 src/main/workspace-hooks/hook-archive-timeout-observation.test.ts delete mode 100644 src/main/workspace-hooks/hooks-orca-yaml-unc-drive.test.ts delete mode 100644 src/main/workspace-hooks/hooks-runner.test.ts diff --git a/config/flaky-test-pattern-baseline.txt b/config/flaky-test-pattern-baseline.txt index 7ccccd20106..9bbf1ed4d8d 100644 --- a/config/flaky-test-pattern-baseline.txt +++ b/config/flaky-test-pattern-baseline.txt @@ -20,7 +20,6 @@ 5 test-no-in-test-import-actual:src/main/ssh/config/ssh-config-loader-regression.test.ts 2 test-no-in-test-import-actual:src/main/ssh/connection/ssh-connection-auth-fallback.test.ts 1 test-no-in-test-import-actual:src/main/window/file-drop/dropped-path-preparation.test.ts -2 test-no-in-test-import-actual:src/main/workspace-hooks/hooks-runner.test.ts 1 test-no-in-test-import-actual:src/main/workspaces/removal/local-worktree-removal-recovery.test.ts 1 test-no-in-test-import-actual:src/renderer/src/components/terminal-pane/pty-connection/notifications/pty-connection-hook-idle-arbitration.test.ts 3 test-no-in-test-import-actual:src/renderer/src/components/terminal-pane/snapshots/remote-snapshot-alt-screen-host-chain.test.ts @@ -624,8 +623,7 @@ 1 test-timeout-needs-reason:src/main/windows/windows-msys-job.win32.test.ts 2 test-timeout-needs-reason:src/main/windows/windows-pty-job.win32.test.ts 5 test-timeout-needs-reason:src/main/worker-thread-child-process/worker-thread-child-process.test.ts -1 test-timeout-needs-reason:src/main/workspace-hooks/hook-archive-timeout-observation.test.ts -2 test-timeout-needs-reason:src/main/workspace-hooks/hook-termination-real-process.test.ts +1 test-timeout-needs-reason:src/main/workspace-hooks/hook-termination-real-process.test.ts 2 test-timeout-needs-reason:src/main/workspaces/removal/local-worktree-filesystem-wsl-banner.wsl.test.ts 4 test-timeout-needs-reason:src/main/wsl/execution/wsl-runner.wsl.test.ts 4 test-timeout-needs-reason:src/packages/process-host/src/run-process.test.ts diff --git a/config/scripts/ci/sharding/ci-shard-timings.json b/config/scripts/ci/sharding/ci-shard-timings.json index b19b9bd1a84..9aa6ee2513c 100644 --- a/config/scripts/ci/sharding/ci-shard-timings.json +++ b/config/scripts/ci/sharding/ci-shard-timings.json @@ -1891,11 +1891,8 @@ "src/main/hermes/hermes-config-source-edits.test.ts": 280, "src/main/hermes/hermes-windows-home.test.ts": 121, "src/main/hermes/hook-service.test.ts": 589, - "src/main/workspace-hooks/hook-archive-timeout-observation.test.ts": 1103, "src/main/workspace-hooks/hook-termination-real-process.test.ts": 3284, "src/main/workspace-hooks/hooks-archive-exit-observation.test.ts": 339, - "src/main/workspace-hooks/hooks-orca-yaml-unc-drive.test.ts": 402, - "src/main/workspace-hooks/hooks-runner.test.ts": 314, "src/main/filesystem/host-checkout-removal.test.ts": 52, "src/main/filesystem/host-tree-removal-asar.electron.test.ts": 308, "src/main/host/deferred-secret-protection-report.test.ts": 81, @@ -3886,7 +3883,6 @@ "src/main/pty/history/terminal-history-gc.test.ts": 824, "src/main/pty/history/terminal-history-tombstone-retry.test.ts": 715, "src/main/text-generation/failures/agent-failure-output.test.ts": 49, - "src/main/text-generation/failures/antigravity-generation-stream.test.ts": 479, "src/main/text-generation/execution/command-environment-process.test.ts": 1044, "src/main/text-generation/execution/commit-message-agent-environment.test.ts": 95, "src/main/text-generation/requests/commit-message-text-generation-branch-name.test.ts": 496, diff --git a/src/main/git/source-control/comparisons/branch-change-list-cache-real-git.test.ts b/src/main/git/source-control/comparisons/branch-change-list-cache-real-git.test.ts index 06a8bdda317..29c40d1ce5f 100644 --- a/src/main/git/source-control/comparisons/branch-change-list-cache-real-git.test.ts +++ b/src/main/git/source-control/comparisons/branch-change-list-cache-real-git.test.ts @@ -4,7 +4,6 @@ import { tmpdir } from 'node:os' import * as path from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { getBranchCompare } from './branch-compare' -import { settledBranchChangeListCache } from './branch-change-list-cache' // Old mtimes put every input past the racy-write margin, so only stamp contents decide reuse. const aged = new Date('2020-01-01T00:00:00Z') @@ -67,9 +66,6 @@ describe('branch compare changed-files reuse', () => { const counted = [{ path: 'docs/notes.txt', status: 'modified', added: 1, removed: 0 }] const binary = [{ path: 'docs/notes.txt', status: 'modified' }] expect((await getBranchCompare(repo, 'base')).entries).toEqual(counted) - const hitsBefore = settledBranchChangeListCache.stats().hits - expect((await getBranchCompare(repo, 'base')).entries).toEqual(counted) - expect(settledBranchChangeListCache.stats().hits).toBe(hitsBefore + 1) await writeFile(nestedAttributes, '*.txt -diff\n') await age(nestedAttributes) diff --git a/src/main/git/source-control/comparisons/status-branch-compare-real-ref.test.ts b/src/main/git/source-control/comparisons/status-branch-compare-real-ref.test.ts index a5f40187594..60351448a1b 100644 --- a/src/main/git/source-control/comparisons/status-branch-compare-real-ref.test.ts +++ b/src/main/git/source-control/comparisons/status-branch-compare-real-ref.test.ts @@ -1,5 +1,5 @@ import { execFileSync } from 'node:child_process' -import { mkdtemp, readFile, readdir, realpath, rm, utimes, writeFile } from 'node:fs/promises' +import { mkdtemp, readdir, realpath, rm, utimes, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import * as path from 'node:path' import { afterEach, describe, expect, it } from 'vitest' @@ -136,21 +136,6 @@ describe('getBranchCompare real refs', () => { expect(await commitsAhead(repo)).toBe(1) }) - it('recounts after an existing loose replace ref is edited in place', async () => { - const { repo, oids } = await createChainRepo() - git(repo, ['replace', '--graft', oids.tip, oids.a]) - const replaceRef = path.join(repo, '.git', 'refs', 'replace', oids.tip) - const tipOntoA = await readFile(replaceRef, 'utf8') - git(repo, ['replace', '--force', '--graft', oids.tip, oids.root]) - await ageReplaceRefs(repo) - expect(await commitsAhead(repo)).toBe(1) - - await writeFile(replaceRef, tipOntoA) - await utimes(path.dirname(replaceRef), aged, aged) - - expect(await commitsAhead(repo)).toBe(2) - }) - it('recounts after a replace ref is packed away from refs/replace', async () => { const { repo, oids } = await createChainRepo() expect(await commitsAhead(repo)).toBe(3) @@ -163,20 +148,6 @@ describe('getBranchCompare real refs', () => { expect(await commitsAhead(repo)).toBe(1) }) - it('keeps per-worktree core.useReplaceRefs answers apart', async () => { - const { repo, oids } = await createChainRepo() - git(repo, ['replace', '--graft', oids.tip, oids.root]) - const linked = path.join(path.dirname(repo), `${path.basename(repo)}-linked`) - tempRoots.push(linked) - git(repo, ['worktree', 'add', '-q', '--detach', linked, oids.tip]) - git(repo, ['config', 'extensions.worktreeConfig', 'true']) - git(linked, ['config', '--worktree', 'core.useReplaceRefs', 'false']) - await ageReplaceRefs(repo) - - expect(await commitsAhead(repo)).toBe(1) - expect(await commitsAhead(linked)).toBe(3) - }) - it('honors a custom GIT_REPLACE_REF_BASE namespace', async () => { const { repo, oids } = await createChainRepo() const previous = process.env.GIT_REPLACE_REF_BASE @@ -211,9 +182,6 @@ describe('getBranchCompare real refs', () => { execFileSync('git', ['clone', '-q', source, client]) git(client, ['fetch', source, 'refs/tags/annotated:refs/remotes/origin/tagbase']) - expect(git(client, ['branch', '-r', '--format=%(refname:short)']).split(/\r?\n/)).toContain( - 'origin/tagbase' - ) const rawOid = git(client, ['rev-parse', '--verify', 'refs/remotes/origin/tagbase']) const peeledOid = git(client, [ 'rev-parse', diff --git a/src/main/text-generation/failures/antigravity-generation-stream.test.ts b/src/main/text-generation/failures/antigravity-generation-stream.test.ts deleted file mode 100644 index 6c1f6ed1ea8..00000000000 --- a/src/main/text-generation/failures/antigravity-generation-stream.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { parseAntigravityGenerationOutput } from '../../../shared/antigravity-generation-stream' -import { - generateCommitMessageFromContext, - generatePullRequestFieldsFromContext -} from '../commit-message-text-generation' -import { generatePullRequestFields } from '../requests/source-control-text-generation-requests' -import { - createMockDiscoveryChild, - withPlatform -} from '../execution/commit-message-text-generation-test-harness' - -const resultEvent = (response: string, status = 'SUCCESS', error?: string): string => - JSON.stringify({ event: 'result', result: { status, response, error } }) -const context = { - branch: 'feature/large-diff', - base: 'main', - branchChangedByPreparation: false, - currentTitle: '', - currentBody: '', - currentDraft: false, - commitSummary: 'Add fixture', - changeSummary: 'A\tfixture.txt', - patch: `diff --git a/fixture.txt b/fixture.txt\n+${'x'.repeat(200_000)}` -} -const params = { agentId: 'antigravity', model: 'default' } as const -const fields = { base: 'main', title: 'Add fixture', body: 'Describe the fixture', draft: false } - -describe('Antigravity generation stream', () => { - it('generates pull request fields on Windows without putting a large patch on argv', async () => { - await withPlatform('win32', async () => { - const child = createMockDiscoveryChild() - const pending = generatePullRequestFields({ - context, - params, - target: { kind: 'local', cwd: 'C:\\folder-workspace' }, - spawnAgent: (input) => { - if (input.args.includes('--help')) { - const probe = createMockDiscoveryChild() - probe.stdin.end.mockImplementation(() => - queueMicrotask(() => { - probe.stdout.emit('data', Buffer.from(' --settings \n')) - probe.emit('close', 0) - }) - ) - return probe - } - expect(input.args.join(' ').length).toBeLessThan(30_000) - expect(input.args).toContain('--input-format=stream-json') - return child - } - }) - await vi.waitFor(() => expect(child.stdin.end).toHaveBeenCalled()) - const input = JSON.parse(child.stdin.end.mock.calls[0]?.[0] ?? '') - expect(input.message.content[0].text).toContain('(diff truncated,') - expect(input.message.content[0].text.length).toBeGreaterThan(200_000) - child.stdout.emit('data', Buffer.from(resultEvent(JSON.stringify(fields)))) - child.emit('close', 0) - await expect(pending).resolves.toMatchObject({ success: true, fields }) - }) - }) - - it('sends structured stdin and parses only the final answer across remote execution', async () => { - const result = await generatePullRequestFieldsFromContext(context, params, { - kind: 'remote', - cwd: '/folder-workspace', - missingBinaryLocation: 'remote PATH', - execute: async (plan) => { - expect(JSON.parse(plan.stdinPayload ?? '').event).toBe('user') - return { - stdout: [ - JSON.stringify({ event: 'init', init: { tools: ['read_file'] } }), - JSON.stringify({ event: 'step_update', step_update: { text_delta: 'Partial' } }), - resultEvent(JSON.stringify(fields)) - ].join('\r\n'), - stderr: '', - exitCode: 0, - timedOut: false - } - } - }) - expect(result).toMatchObject({ success: true, fields, agentLabel: 'Antigravity' }) - }) - - it('does not treat a failed stream turn as a successful commit message when exit code is zero', async () => { - const result = await generateCommitMessageFromContext( - { branch: 'main', stagedSummary: 'A\tfixture.txt', stagedPatch: '+fixture' }, - params, - { - kind: 'remote', - cwd: '/folder-workspace', - missingBinaryLocation: 'remote PATH', - execute: async () => ({ - stdout: resultEvent('Partial answer', 'ERROR', 'Model unavailable'), - stderr: '', - exitCode: 0, - timedOut: false - }) - } - ) - expect(result).toMatchObject({ success: false, error: 'Model unavailable' }) - }) - - it('preserves actionable stderr when a nonzero exit has a malformed result event', async () => { - const result = await generateCommitMessageFromContext( - { branch: 'main', stagedSummary: 'A\tfixture.txt', stagedPatch: '+fixture' }, - params, - { - kind: 'remote', - cwd: '/folder-workspace', - missingBinaryLocation: 'remote PATH', - execute: async () => ({ - stdout: '{"event":"result"}', - stderr: 'Not logged into Antigravity. Run agy login.', - exitCode: 1, - timedOut: false - }) - } - ) - expect(result).toMatchObject({ success: false }) - if (result.success) { - throw new Error('Expected generation to fail') - } - expect(result.error).toContain('Not logged into Antigravity. Run agy login.') - expect(result.error).not.toContain('invalid result') - }) - - it.each([ - 'plain output', - '{"event":"result"}', - '{"event":"step_update","step_update":{"text_delta":"Partial"}}', - '{"event":"result","result":{"status":"SUCCESS","response":42}}' - ])('rejects malformed or incomplete output: %s', (output) => { - expect(parseAntigravityGenerationOutput(output)).toMatchObject({ ok: false }) - }) -}) diff --git a/src/main/text-generation/requests/commit-message-text-generation-regression.test.ts b/src/main/text-generation/requests/commit-message-text-generation-regression.test.ts index b7a66d2a1e6..0faff1733a0 100644 --- a/src/main/text-generation/requests/commit-message-text-generation-regression.test.ts +++ b/src/main/text-generation/requests/commit-message-text-generation-regression.test.ts @@ -2,53 +2,6 @@ import { describe, expect, it } from 'vitest' import { generateCommitMessageFromContext } from '../commit-message-text-generation' describe('commit-message generation regressions', () => { - it('renders the complete commit policy before handing execution to the owning host', async () => { - let prompt = '' - let operation = '' - const result = await generateCommitMessageFromContext( - { - branch: 'feature/policy', - stagedSummary: 'M src/policy.ts', - stagedPatch: '+preserve host scope', - linkedIssue: 73 - }, - { - agentId: 'custom', - model: '', - customAgentCommand: 'agent', - commandInputTemplate: - '{basePrompt}\n\nBranch: {branch}\nFiles: {stagedFiles}\nPatch: {stagedPatch}\nIssue: {linkedIssue}' - }, - { - kind: 'remote', - cwd: '/remote/repo', - missingBinaryLocation: 'remote PATH', - execute: async (plan, _cwd, _timeout, generationOperation) => { - prompt = plan.stdinPayload ?? '' - operation = generationOperation - return { - stdout: 'Preserve source-control policy\n', - stderr: '', - exitCode: 0, - timedOut: false - } - } - } - ) - - expect(operation).toBe('commit-message') - expect(prompt).toContain('Branch: feature/policy') - expect(prompt).toContain('Files: M src/policy.ts') - expect(prompt).toContain('Patch: +preserve host scope') - expect(prompt).toContain('Issue: 73') - expect(prompt).not.toMatch(/\{(?:branch|stagedFiles|stagedPatch|linkedIssue)\}/) - expect(result).toEqual({ - success: true, - message: 'Preserve source-control policy', - agentLabel: 'agent' - }) - }) - it('keeps bounded diagnostic captures out of commit-message results', async () => { const result = await generateCommitMessageFromContext( { diff --git a/src/main/workspace-hooks/hook-archive-timeout-observation.test.ts b/src/main/workspace-hooks/hook-archive-timeout-observation.test.ts deleted file mode 100644 index 683decf17fd..00000000000 --- a/src/main/workspace-hooks/hook-archive-timeout-observation.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import type { Repo } from '../../shared/repo-types' - -vi.mock('./effective-hook-config', () => ({ - getEffectiveHooksFromConfig: (_repo: unknown, hooks: unknown) => hooks -})) - -const REPO: Repo = { id: 'r', path: '/repo', displayName: 'r', badgeColor: '#000', addedAt: 0 } - -async function runArchive(script: string, timeoutMs = 400) { - const { runHook } = await import('./hooks') - const dir = mkdtempSync(join(tmpdir(), 'orca-hook-deadline-')) - writeFileSync(join(dir, 'orca.yaml'), `scripts:\n archive: |\n ${script}\n`) - try { - return await runHook('archive', dir, REPO, dir, undefined, { timeoutMs }) - } finally { - rmSync(dir, { recursive: true, force: true }) - } -} - -// Real shells expose SIGTERM traps that exit 0 after an archive timeout (#19334). -describe.skipIf(process.platform === 'win32')('archive hook deadline', () => { - it('fails a hook that traps SIGTERM and exits zero, despite its zero exit', async () => { - const result = await runArchive("trap 'exit 0' TERM; sleep 30") - expect(result.success).toBe(false) - // Missing exitCode keeps the removal verdict unverifiable. - expect(result.exitCode).toBeUndefined() - expect(result.output).toContain('timed out') - }, 20_000) -}) diff --git a/src/main/workspace-hooks/hook-termination-real-process.test.ts b/src/main/workspace-hooks/hook-termination-real-process.test.ts index fc4123fd6e5..54fdb9f5bcc 100644 --- a/src/main/workspace-hooks/hook-termination-real-process.test.ts +++ b/src/main/workspace-hooks/hook-termination-real-process.test.ts @@ -49,14 +49,6 @@ async function survivorsAfterDeadline( // Mocked kill probes cannot prove a process group exists; exercise real signals here. describe.skipIf(process.platform === 'win32')('hook termination against real processes', () => { - it('kills the shell and its child when the deadline expires', async () => { - const { shell, child, output } = await survivorsAfterDeadline( - 'echo "archive step 3 of 7"\nsleep 120 &\necho "$$ $!" > "$PWD/pids"\nwait' - ) - expect({ shell, child }).toEqual({ shell: false, child: false }) - expect(output).toContain('archive step 3 of 7') - }, 30_000) - it('kills a descendant that ignores SIGTERM', async () => { // Only the group SIGKILL can end this one; a SIGTERM to the shell alone leaves it running. const { child } = await survivorsAfterDeadline( diff --git a/src/main/workspace-hooks/hooks-archive-exit-observation.test.ts b/src/main/workspace-hooks/hooks-archive-exit-observation.test.ts index f178a4e1a45..11158084d23 100644 --- a/src/main/workspace-hooks/hooks-archive-exit-observation.test.ts +++ b/src/main/workspace-hooks/hooks-archive-exit-observation.test.ts @@ -77,37 +77,12 @@ async function runArchiveWith( // Real-process timeout/group tests live in hook-termination-real-process.test.ts (#19334). describe('archive hook exit observation', () => { - it.each([ - ['a non-zero exit', 23], - ['a shell command-not-found', 127] - ])('reports %s as the observed exit it is', async (_label, code) => { - await expect(runArchiveWith({ code })).resolves.toMatchObject({ - success: false, - exitCode: code - }) - }) - - it('caps what it retains from a hook that floods stdout', async () => { - const megabyte = 'x'.repeat(1024 * 1024) - const result = await runArchiveWith( - { code: 0 }, - Array.from({ length: 12 }, () => megabyte) - ) - expect(result.output.length).toBeLessThan(11 * 1024 * 1024) - expect(result.output).toContain('output truncated at 10485760 bytes') - }) - it('survives an error on the output stream', async () => { await expect( runArchiveWith({ code: 0 }, ['partial'], new Error('EIO: read failed')) ).resolves.toMatchObject({ success: true }) }) - it('names a signalled exit as one rather than reporting "exit code null"', async () => { - const result = await runArchiveWith({ code: null, signal: 'SIGKILL' }) - expect(result.output).toContain('terminated without reporting an exit code') - }) - it.each([ ['was killed by a signal', { code: null, signal: 'SIGKILL' as const }], // Spawn errors carry string codes; a bare Error would not exercise the numeric-code guard. diff --git a/src/main/workspace-hooks/hooks-orca-yaml-unc-drive.test.ts b/src/main/workspace-hooks/hooks-orca-yaml-unc-drive.test.ts deleted file mode 100644 index 1aba51458e0..00000000000 --- a/src/main/workspace-hooks/hooks-orca-yaml-unc-drive.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type * as NodeFs from 'node:fs' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const fsMock = vi.hoisted(() => ({ existsSync: vi.fn(), readFileSync: vi.fn() })) -vi.mock('node:fs', async (importOriginal) => ({ - ...(await importOriginal()), - ...fsMock -})) - -import { hasHooksFile, hasUnrecognizedOrcaYamlKeys, loadHooks } from './hooks' - -const UNC = String.raw`\\wsl.localhost\Ubuntu\mnt\c\src\app` -const DRIVE_YAML = String.raw`C:\src\app\orca.yaml` -const originalPlatform = process.platform - -describe('orca.yaml of a UNC drive-mount repo', () => { - beforeEach(() => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - // WSL 2.7 refuses Windows access to a drive mount over UNC (existsSync swallows it as false). - fsMock.existsSync.mockImplementation((path: string) => path === DRIVE_YAML) - fsMock.readFileSync.mockImplementation((path: string) => { - if (path !== DRIVE_YAML) { - throw Object.assign(new Error(`EPERM: operation not permitted, open '${path}'`), { - code: 'EPERM' - }) - } - return 'scripts:\n setup: pnpm install\nfutureKey: 1\n' - }) - }) - - afterEach(() => { - Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) - }) - - it('reads the setup hooks through the drive path', () => { - expect(hasHooksFile(UNC)).toBe(true) - expect(loadHooks(UNC)?.scripts?.setup).toBe('pnpm install') - expect(hasUnrecognizedOrcaYamlKeys(UNC)).toBe(true) - expect(fsMock.readFileSync).not.toHaveBeenCalledWith( - expect.stringMatching(/^\\\\/), - expect.anything() - ) - }) -}) diff --git a/src/main/workspace-hooks/hooks-runner.test.ts b/src/main/workspace-hooks/hooks-runner.test.ts deleted file mode 100644 index 45205adbc4f..00000000000 --- a/src/main/workspace-hooks/hooks-runner.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type * as NodeChildProcess from 'node:child_process' -import type * as NodeFs from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' - -import { describe, expect, it, vi } from 'vitest' - -const { execFileSyncMock, statSyncMock } = vi.hoisted(() => ({ - execFileSyncMock: vi.fn(), - statSyncMock: vi.fn() -})) - -vi.mock('fs', () => ({ - readFileSync: vi.fn(), - statSync: statSyncMock, - existsSync: vi.fn(), - mkdirSync: vi.fn(), - writeFileSync: vi.fn(), - chmodSync: vi.fn() -})) - -vi.mock('child_process', () => ({ - exec: vi.fn(), - execFileSync: execFileSyncMock, - execFile: vi.fn(), - spawn: vi.fn() -})) - -describe('createSetupRunnerScript', () => { - it.skipIf(process.platform === 'win32')( - 'runs a script whose shebang carries invocation-only bash flags', - async () => { - // Reapplying login/interactive flags through set would abort before the first user line. - const { buildPosixRunnerScript } = await import('./setup-runner-script-text') - const { mkdtempSync, rmSync, writeFileSync } = await vi.importActual('node:fs') - const { execFileSync } = await vi.importActual('node:child_process') - - const dir = mkdtempSync(join(tmpdir(), 'orca-posix-runner-')) - try { - const runnerPath = join(dir, 'setup-runner.sh') - writeFileSync( - runnerPath, - buildPosixRunnerScript('#!/bin/bash -l\necho SETUP_RAN\n'), - 'utf-8' - ) - - expect(execFileSync('bash', [runnerPath], { encoding: 'utf-8' })).toContain('SETUP_RAN') - } finally { - rmSync(dir, { recursive: true, force: true }) - } - } - ) -}) From c228a0bbc74fccef8e7e4b4f8431608f1f25dc11 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Fri, 13 Nov 2026 10:21:09 +0000 Subject: [PATCH 02/55] fix(native-chat): the browser Show button says why when the page can't be opened (#41956) The button threw away activateBrowserPagePaletteResult's failure, so a missing page, tab or workspace made it do nothing. It now shows the open-tab switcher's messages, shared through one helper. A blocked activation stays quiet like every other caller: the activation reports it. --- .../runs/NativeChatBrowserPageButton.tsx | 13 ++++-- .../tab-bar/open-tab-selection-routing.ts | 46 +++++++++++-------- 2 files changed, 38 insertions(+), 21 deletions(-) diff --git a/src/renderer/src/components/native-chat/tools/runs/NativeChatBrowserPageButton.tsx b/src/renderer/src/components/native-chat/tools/runs/NativeChatBrowserPageButton.tsx index 1c53ea2a44b..8fb6f802a35 100644 --- a/src/renderer/src/components/native-chat/tools/runs/NativeChatBrowserPageButton.tsx +++ b/src/renderer/src/components/native-chat/tools/runs/NativeChatBrowserPageButton.tsx @@ -1,10 +1,12 @@ import { useEffect, useState, useSyncExternalStore } from 'react' +import { toast } from 'sonner' import { BrowserFavicon } from '@/components/browser-favicon' import { useTranslate } from '@/i18n/use-translate' import { useAppStore } from '@/store' import { findPage } from '@/store/slices/browser/pages/browser-page-records' import { findBrowserWorkspaceForRemotePage } from '@/runtime/web-session-tabs-sync/mirrored-browser-tabs' import { activateBrowserPagePaletteResult } from '@/lib/browser/palette/browser-page-palette-activation' +import { browserPageActivationFailureMessage } from '@/components/tab-bar/open-tab-selection-routing' import { useLastBrowserAgentPageId } from '../../../browser-pane/agent-cursor/browser-agent-activity-state' import { useNativeChatVisualOwner, @@ -160,13 +162,18 @@ function BrowserPageButton({ aria-label={t('components.native-chat.tool.showBrowserPageLabel', 'Show {{value0}}', { value0: title })} - onClick={() => - activateBrowserPagePaletteResult({ + onClick={() => { + const activation = activateBrowserPagePaletteResult({ pageId: page.id, workspaceId: page.workspaceId, worktreeId: page.worktreeId }) - } + // A blocked activation already told the user why. + if (activation.status !== 'failed') { + return + } + toast.error(browserPageActivationFailureMessage(activation.reason)) + }} > {t('components.native-chat.tool.showBrowserPage', 'Show')} diff --git a/src/renderer/src/components/tab-bar/open-tab-selection-routing.ts b/src/renderer/src/components/tab-bar/open-tab-selection-routing.ts index be253e1f4b3..2678380d42a 100644 --- a/src/renderer/src/components/tab-bar/open-tab-selection-routing.ts +++ b/src/renderer/src/components/tab-bar/open-tab-selection-routing.ts @@ -2,7 +2,10 @@ import { requestBrowserFocus } from '@/components/browser-pane/host-guest/browser-focus' import { translate } from '@/i18n/i18n' -import { activateBrowserPagePaletteResult } from '@/lib/browser/palette/browser-page-palette-activation' +import { + activateBrowserPagePaletteResult, + type BrowserPagePaletteActivationFailure +} from '@/lib/browser/palette/browser-page-palette-activation' import { focusTerminalTabSurface } from '@/components/terminal-pane/input/focus/focus-terminal-tab-surface' import { activateSimulatorTabPaletteResult } from '@/lib/simulator/tabs/simulator-tab-palette-activation' import { activateWorkspaceTabPaletteResult } from '@/lib/workspace/search/tabs/workspace-tab-palette-activation' @@ -15,17 +18,30 @@ export type OpenTabSelectionOutcome = | { status: 'failed'; message: string } // Distinguish dead workspaces from stale targets inside live ones across all sources. +function failureMessage(reason: string, staleMessage: string): string { + return reason === 'missing-worktree' + ? translate( + 'auto.components.tab.bar.TabBarCreateEntry.2c38630a01', + 'Workspace no longer exists' + ) + : staleMessage +} + function failed(reason: string, staleMessage: string): OpenTabSelectionOutcome { - return { - status: 'failed', - message: - reason === 'missing-worktree' - ? translate( - 'auto.components.tab.bar.TabBarCreateEntry.2c38630a01', - 'Workspace no longer exists' - ) - : staleMessage - } + return { status: 'failed', message: failureMessage(reason, staleMessage) } +} + +/** Why a browser page could not be shown: a gone workspace, or a gone page or tab. */ +export function browserPageActivationFailureMessage( + reason: BrowserPagePaletteActivationFailure +): string { + return failureMessage( + reason, + translate( + 'auto.components.tab.bar.TabBarCreateEntry.d7d496a451', + 'Browser page no longer exists' + ) + ) } export function activateOpenTabSearchResult(result: OpenTabSearchResult): OpenTabSelectionOutcome { @@ -40,13 +56,7 @@ export function activateOpenTabSearchResult(result: OpenTabSearchResult): OpenTa return activation } if (activation.status === 'failed') { - return failed( - activation.reason, - translate( - 'auto.components.tab.bar.TabBarCreateEntry.d7d496a451', - 'Browser page no longer exists' - ) - ) + return { status: 'failed', message: browserPageActivationFailureMessage(activation.reason) } } return { status: 'activated', From 51d0c97e39009b9ee8f1fb6b451c0f694302d6be Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Fri, 13 Nov 2026 10:23:30 +0000 Subject: [PATCH 03/55] fix(browser): publish title, loading and history state for server-run browser tabs (#41952) Streamed (offscreen) pages only republished their session-tab row on commit, in-page navigation, first favicon and load failure. A title parsed after commit, a live document.title change and the end of a load never reached the desktop tab strip or the phone, and the row hardcoded loading and back/forward as false. Publish title and load-state changes through a per-page coalescer and read real loading and history state into the row. --- .../session-routing/agent-browser-bridge-tabs.ts | 5 +++++ .../manager/browser-manager-queries.ts | 13 +++++++++++++ .../manager/browser-manager-state.ts | 6 ++++++ .../browser-manager-guest-navigation-policy.ts | 13 +++++++++++++ .../client-pages/renderer-browser-lifecycle.test.ts | 3 ++- .../orca-runtime-serve-browser-restore.test.ts | 1 + .../build-headless-mobile-session-browser-tabs.ts | 7 ++++--- .../mobile-session-legacy-navigation.unit.test.ts | 1 + 8 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src/main/browser/agent-browser/session-routing/agent-browser-bridge-tabs.ts b/src/main/browser/agent-browser/session-routing/agent-browser-bridge-tabs.ts index b972e0e2257..af0ec949cff 100644 --- a/src/main/browser/agent-browser/session-routing/agent-browser-bridge-tabs.ts +++ b/src/main/browser/agent-browser/session-routing/agent-browser-bridge-tabs.ts @@ -122,6 +122,11 @@ export abstract class AgentBrowserBridgeTabs extends AgentBrowserBridgeState { getRemoteTabFaviconUrl(browserPageId: string): string | null { return this.browserManager.getBrowserPageRemoteTabFaviconUrl(browserPageId) } + getPageNavigationState( + browserPageId: string + ): { loading: boolean; canGoBack: boolean; canGoForward: boolean } | null { + return this.browserManager.getBrowserPageNavigationState(browserPageId) + } onTabChanged(webContentsId: number, worktreeId?: string): void { this.selectForPerson(webContentsId, worktreeId) this.options.onTabsChanged?.(worktreeId) diff --git a/src/main/browser/guest-lifecycle/manager/browser-manager-queries.ts b/src/main/browser/guest-lifecycle/manager/browser-manager-queries.ts index 39148d307f6..08b454f12b2 100644 --- a/src/main/browser/guest-lifecycle/manager/browser-manager-queries.ts +++ b/src/main/browser/guest-lifecycle/manager/browser-manager-queries.ts @@ -13,6 +13,7 @@ import { listBrowserRouteGuestPopups } from '../../routing/guest-authority/browser-route-guest-popup-ownership' import { browserPopupPageId, browserPopupPageWebContentsId } from '../popups/browser-popup-page-id' +import { readGuestNavigationState } from '../navigation/browser-guest-navigation-state' export abstract class BrowserManagerQueries extends BrowserManagerRegistration { getGuestWebContentsId(browserTabId: string): number | null { @@ -120,6 +121,17 @@ export abstract class BrowserManagerQueries extends BrowserManagerRegistration { : (this.remoteTabFaviconUrlByGuestId.get(webContentsId) ?? null) } + getBrowserPageNavigationState( + browserPageId: string + ): { loading: boolean; canGoBack: boolean; canGoForward: boolean } | null { + const webContentsId = this.webContentsIdByTabId.get(browserPageId) + const guest = webContentsId === undefined ? undefined : webContents.fromId(webContentsId) + if (!guest || guest.isDestroyed()) { + return null + } + return { loading: guest.isLoadingMainFrame(), ...readGuestNavigationState(guest) } + } + getBrowserPageCertificateFailure(browserPageId: string): BrowserCertificateFailure | null { return this.certificateTrustController?.getFailure(browserPageId) ?? null } @@ -181,6 +193,7 @@ export abstract class BrowserManagerQueries extends BrowserManagerRegistration { } protected notifyBrowserGuestStateChanged(webContentsId: number): void { + this.offscreenGuestStateNotices.cancel(String(webContentsId)) if (!this.offscreenGuestIds.has(webContentsId)) { return } diff --git a/src/main/browser/guest-lifecycle/manager/browser-manager-state.ts b/src/main/browser/guest-lifecycle/manager/browser-manager-state.ts index 378f2bef58f..e0c93da3595 100644 --- a/src/main/browser/guest-lifecycle/manager/browser-manager-state.ts +++ b/src/main/browser/guest-lifecycle/manager/browser-manager-state.ts @@ -20,6 +20,7 @@ import type { import type { BrowserGrabCancelReason } from '../../../../shared/browser/element-grab/browser-grab-types' import { BrowserManagerViewportScrollState } from './viewport/browser-manager-viewport-scroll-state' import { BrowserGuestDevToolsDock } from '../devtools/browser-guest-devtools-dock' +import { createKeyedTrailingEdgeCoalescer } from '../../../runtime/client/events/keyed-trailing-edge-coalescer' export type BrowserManagerSettings = { keybindings?: KeybindingOverrides @@ -168,6 +169,11 @@ export abstract class BrowserManagerState extends BrowserManagerViewportScrollSt // Offscreen pages have no renderer to hold their icon for session-tab rows. protected readonly remoteTabFaviconUrlByGuestId = new Map() protected browserGuestStateChangedListener: ((worktreeId: string) => void) | null = null + // Title and spinner churn on offscreen pages publishes at most about once a second. + protected readonly offscreenGuestStateNotices = createKeyedTrailingEdgeCoalescer( + (webContentsId) => this.notifyBrowserGuestStateChanged(Number(webContentsId)), + { flushMs: 250, maxWaitMs: 1000 } + ) protected offscreenTabOpener: ((request: OffscreenBrowserTabOpenRequest) => void) | null = null protected certificateTrustController: BrowserCertificateTrustController | null = null protected siteDisplayController: BrowserSiteDisplayController | null = null diff --git a/src/main/browser/guest-lifecycle/manager/navigation/browser-manager-guest-navigation-policy.ts b/src/main/browser/guest-lifecycle/manager/navigation/browser-manager-guest-navigation-policy.ts index cf70c4ee0bc..49035073e57 100644 --- a/src/main/browser/guest-lifecycle/manager/navigation/browser-manager-guest-navigation-policy.ts +++ b/src/main/browser/guest-lifecycle/manager/navigation/browser-manager-guest-navigation-policy.ts @@ -195,12 +195,22 @@ export abstract class BrowserManagerGuestNavigationPolicy extends BrowserManager } } + // Offscreen pages have no renderer; titles parse after commit and loads finish later. + const offscreenStateChangedHandler = (): void => { + if (this.offscreenGuestIds.has(guest.id)) { + this.offscreenGuestStateNotices.schedule(String(guest.id)) + } + } + guest.on('will-navigate', willNavigateHandler) guest.on('will-redirect', willRedirectHandler) guest.on('did-start-navigation', didStartNavigationHandler) guest.on('did-navigate', didNavigateHandler) guest.on('did-navigate-in-page', didNavigateInPageHandler) guest.on('page-favicon-updated', faviconUpdatedHandler) + guest.on('page-title-updated', offscreenStateChangedHandler) + guest.on('did-start-loading', offscreenStateChangedHandler) + guest.on('did-stop-loading', offscreenStateChangedHandler) guest.on('did-fail-load', didFailLoadHandler) // Why emit: an extension-blocked guest main frame commits no error page, so Electron reports // nothing; every did-fail-load reader (this policy, the pane, webNavigation) needs the failure. @@ -234,6 +244,9 @@ export abstract class BrowserManagerGuestNavigationPolicy extends BrowserManager guest.off('did-navigate', didNavigateHandler) guest.off('did-navigate-in-page', didNavigateInPageHandler) guest.off('page-favicon-updated', faviconUpdatedHandler) + guest.off('page-title-updated', offscreenStateChangedHandler) + guest.off('did-start-loading', offscreenStateChangedHandler) + guest.off('did-stop-loading', offscreenStateChangedHandler) guest.off('did-fail-load', didFailLoadHandler) guest.off('will-prevent-unload', allowPageUnload) } diff --git a/src/main/runtime/browser/client-pages/renderer-browser-lifecycle.test.ts b/src/main/runtime/browser/client-pages/renderer-browser-lifecycle.test.ts index cfaa857e841..12660d845b2 100644 --- a/src/main/runtime/browser/client-pages/renderer-browser-lifecycle.test.ts +++ b/src/main/runtime/browser/client-pages/renderer-browser-lifecycle.test.ts @@ -53,7 +53,8 @@ function browserRuntime(closeTab = vi.fn(async (_pageId: string) => {}), runtime // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Only the bridge inventory is used by this lifecycle test. runtime.setAgentBrowserBridge({ tabList: () => ({ tabs: [...livePages.values()] }), - getRemoteTabFaviconUrl: () => null + getRemoteTabFaviconUrl: () => null, + getPageNavigationState: () => null } as never) electronMocks.BrowserWindow.fromId.mockImplementation((id: number) => id === 1 ? { isDestroyed: () => false } : null diff --git a/src/main/runtime/browser/headless/orca-runtime-serve-browser-restore.test.ts b/src/main/runtime/browser/headless/orca-runtime-serve-browser-restore.test.ts index 2c2399366d0..3aea2bfc463 100644 --- a/src/main/runtime/browser/headless/orca-runtime-serve-browser-restore.test.ts +++ b/src/main/runtime/browser/headless/orca-runtime-serve-browser-restore.test.ts @@ -43,6 +43,7 @@ function restartedServe(session: WorkspaceSessionState = savedServeTabs()) { })) })), getRemoteTabFaviconUrl: () => null, + getPageNavigationState: () => null, getRegisteredTabs: vi.fn(() => registered()), snapshot: vi.fn(async () => ({ snapshot: '' })), getActivePageId: vi.fn(() => livePages[0] ?? null), diff --git a/src/main/runtime/session-lifecycle/projection/build-headless-mobile-session-browser-tabs.ts b/src/main/runtime/session-lifecycle/projection/build-headless-mobile-session-browser-tabs.ts index 82df9196a57..5be62aad56f 100644 --- a/src/main/runtime/session-lifecycle/projection/build-headless-mobile-session-browser-tabs.ts +++ b/src/main/runtime/session-lifecycle/projection/build-headless-mobile-session-browser-tabs.ts @@ -51,6 +51,7 @@ export abstract class OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs exten ].filter((tab) => !rendererPageIds.has(tab.browserPageId)) : [] const publishedServerTabs = serverTabs.map((tab) => { + const navigation = this.agentBrowserBridge?.getPageNavigationState(tab.browserPageId) const persistedProps = this.getPersistedUnifiedSessionTabProps( worktreeId, this.serveBrowserTabs.unifiedTabId(worktreeId, tab.browserPageId) @@ -64,9 +65,9 @@ export abstract class OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs exten browserPageId: tab.browserPageId, browserProfileId: tab.profileId ?? undefined, url: tab.url || 'about:blank', - loading: false, - canGoBack: false, - canGoForward: false, + loading: navigation?.loading ?? false, + canGoBack: navigation?.canGoBack ?? false, + canGoForward: navigation?.canGoForward ?? false, faviconUrl: this.agentBrowserBridge?.getRemoteTabFaviconUrl(tab.browserPageId) ?? null, loadError: tab.loadError ?? undefined, certificateFailure: tab.certificateFailure ?? undefined, diff --git a/tests/e2e/session-tabs/mobile-session-legacy-navigation.unit.test.ts b/tests/e2e/session-tabs/mobile-session-legacy-navigation.unit.test.ts index 1eb38585496..d585b24dde5 100644 --- a/tests/e2e/session-tabs/mobile-session-legacy-navigation.unit.test.ts +++ b/tests/e2e/session-tabs/mobile-session-legacy-navigation.unit.test.ts @@ -54,6 +54,7 @@ describe('legacy host navigation through inventory reconstruction', () => { })) }), getRemoteTabFaviconUrl: () => null, + getPageNavigationState: () => null, tabSwitch: vi.fn(async () => ({ switched: 1, browserPageId: 'page-b' })), getRegisteredTabs: () => new Map([ From 86fea2e81cca344277ada8246bcc6004502d94e5 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Fri, 13 Nov 2026 10:29:58 +0000 Subject: [PATCH 04/55] fix(browser): show streamed pages' JavaScript dialogs on the desktop (#41960) The desktop's screencast subscription dropped the stream's dialog and dialogClosed events, so an alert, confirm or prompt on a Server (streamed) page froze it with nothing to answer. Ask with the local browser's dialog, answer through browser.dialogAccept/dialogDismiss, and close it when the host closes the dialog or the stream stops. --- .../remote-browser-javascript-dialog.ts | 81 +++++++++++++++++++ .../remote-browser-page-session.ts | 2 +- .../remote-browser-screencast-subscription.ts | 33 +++++++- .../remote-browser-stream-errors.ts | 5 ++ .../remote-browser-stream-lifecycle-deps.ts | 2 + ...e-browser-stream-lifecycle-test-harness.ts | 23 +++++- .../remote-browser-stream-lifecycle.test.ts | 42 ++++++++++ .../remote-browser-stream-lifecycle.ts | 4 +- .../use-remote-browser-page-input.ts | 11 ++- .../use-remote-browser-page-keyboard.ts | 8 +- .../use-remote-browser-page-lifecycle.ts | 7 +- 11 files changed, 208 insertions(+), 10 deletions(-) create mode 100644 src/renderer/src/components/browser-pane/stream-remote/remote-browser-javascript-dialog.ts diff --git a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-javascript-dialog.ts b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-javascript-dialog.ts new file mode 100644 index 00000000000..ac97374e354 --- /dev/null +++ b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-javascript-dialog.ts @@ -0,0 +1,81 @@ +import type { RuntimeClientTarget } from '../../../../../shared/runtime-client-target' +import type { + BrowserJavaScriptDialog, + BrowserJavaScriptDialogAnswer, + BrowserJavaScriptDialogPrompt +} from '../../../../../shared/browser/prompts/browser-javascript-dialog' +import type { BrowserScreencastDialogResult } from '../../../../../shared/browser/runtime/runtime-browser-contracts' +import type { RemoteBrowserRpcCall } from './remote-browser-page-session' + +export type RemoteBrowserJavaScriptDialogAsk = ( + dialog: BrowserJavaScriptDialogPrompt, + signal: AbortSignal +) => Promise + +export type RemoteBrowserDialogPage = { + target: RuntimeClientTarget + worktree: string + pageId: string +} + +function toJavaScriptDialog(event: BrowserScreencastDialogResult): BrowserJavaScriptDialog | null { + const type = event.dialogType + // The host holds only these; asking about any other kind would offer an answer it refuses. + if (type !== 'alert' && type !== 'confirm' && type !== 'prompt') { + return null + } + return { type, message: event.message, defaultPromptText: '' } +} + +/** Asks the person about the streamed page's open dialog and answers it on the host. */ +export class RemoteBrowserJavaScriptDialog { + private open: AbortController | null = null + + constructor( + private readonly deps: { + askJavaScriptDialog: RemoteBrowserJavaScriptDialogAsk + callRpc: RemoteBrowserRpcCall + } + ) {} + + show(event: BrowserScreencastDialogResult, page: RemoteBrowserDialogPage): void { + const dialog = toJavaScriptDialog(event) + // The host repeats the dialog it still holds when a turned-away nested one closes. + if (!dialog || this.open) { + return + } + const open = new AbortController() + this.open = open + // The stream reports no frame origin, so the title can't name one. + void this.deps.askJavaScriptDialog({ ...dialog, origin: null }, open.signal).then((answer) => { + if (!open.signal.aborted) { + void this.answerOnHost(page, answer, open.signal).catch(() => {}) + } + }) + } + + /** The host closed it (answered here, by an agent or a phone, or by the page), or the stream ended. */ + close(): void { + this.open?.abort() + this.open = null + } + + // A failed answer leaves the dialog to the host, which dismisses it when this stream stops. + private answerOnHost( + { target, worktree, pageId }: RemoteBrowserDialogPage, + answer: BrowserJavaScriptDialogAnswer | null, + // Closing it elsewhere first must not let this answer reach the page's next dialog. + signal: AbortSignal + ): Promise { + const params = { worktree, page: pageId } + const options = { timeoutMs: 15_000, suppressFeatureInteraction: true, signal } + return answer?.accept + ? this.deps.callRpc( + target, + 'browser.dialogAccept', + { ...params, text: answer.promptText }, + options + ) + : this.deps.callRpc(target, 'browser.dialogDismiss', params, options) + } +} diff --git a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-session.ts b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-session.ts index 41d62402a4c..d4e9b579c57 100644 --- a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-session.ts +++ b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-session.ts @@ -13,7 +13,7 @@ export type RemoteBrowserRpcCall = ( target: RuntimeClientTarget, method: string, params?: unknown, - options?: { timeoutMs?: number; suppressFeatureInteraction?: boolean } + options?: { timeoutMs?: number; suppressFeatureInteraction?: boolean; signal?: AbortSignal } ) => Promise export type RemoteBrowserPageHandle = { diff --git a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-screencast-subscription.ts b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-screencast-subscription.ts index 1611e8172dd..af6c78f38de 100644 --- a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-screencast-subscription.ts +++ b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-screencast-subscription.ts @@ -6,6 +6,10 @@ import type { import { withBrowserPaneUiRuntimeRpcSource } from '../../../../../shared/runtime-rpc-feature-interaction-source' import { e2eConfig } from '@/app-shell/startup/e2e-config' import { isRemoteBrowserPageMissingCode } from './remote-browser-stream-errors' +import type { + RemoteBrowserDialogPage, + RemoteBrowserJavaScriptDialog +} from './remote-browser-javascript-dialog' import type { RemoteBrowserViewportSize } from './remote-browser-stream-tokens' export type RemoteBrowserScreencastSubscribe = ( @@ -54,6 +58,8 @@ export type RemoteBrowserScreencastEvents = { onPageMissing: () => void onFrame: (bytes: Uint8Array) => void onClosed: () => void + /** This stream's page dialog; a stopped stream's host dismisses it, so it closes with the stream. */ + javaScriptDialog: RemoteBrowserJavaScriptDialog } export async function openRemoteBrowserScreencastStream( @@ -62,7 +68,12 @@ export async function openRemoteBrowserScreencastStream( events: RemoteBrowserScreencastEvents ): Promise<{ unsubscribe: () => void }> { recordE2eScreencastSubscribe() - return subscribe( + const dialogPage: RemoteBrowserDialogPage = { + target: { kind: 'environment', environmentId: request.environmentId }, + worktree: request.worktree, + pageId: request.pageId + } + const subscription = await subscribe( { selector: request.environmentId, method: 'browser.screencast', @@ -81,7 +92,7 @@ export async function openRemoteBrowserScreencastStream( timeoutMs: 15_000 }, { - onResponse: (response) => dispatchScreencastResponse(response, events), + onResponse: (response) => dispatchScreencastResponse(response, events, dialogPage), onBinary: (bytes) => events.onFrame(bytes), onError: (error) => { if (!events.isCurrent()) { @@ -93,14 +104,24 @@ export async function openRemoteBrowserScreencastStream( } events.onTransportError(error.message) }, - onClose: () => events.onClosed() + onClose: () => { + events.javaScriptDialog.close() + events.onClosed() + } } ) + return { + unsubscribe: () => { + events.javaScriptDialog.close() + subscription.unsubscribe() + } + } } function dispatchScreencastResponse( response: RuntimeRpcResponse, - events: RemoteBrowserScreencastEvents + events: RemoteBrowserScreencastEvents, + dialogPage: RemoteBrowserDialogPage ): void { if (!events.isCurrent()) { return @@ -121,6 +142,10 @@ function dispatchScreencastResponse( events.onReady(event) } else if (event.type === 'end') { events.onEnded() + } else if (event.type === 'dialog') { + events.javaScriptDialog.show(event, dialogPage) + } else if (event.type === 'dialogClosed') { + events.javaScriptDialog.close() } else if (event.type === 'error') { events.onFailed(event.message) } diff --git a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-errors.ts b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-errors.ts index 8e51b28308e..1df9dda1d1a 100644 --- a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-errors.ts +++ b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-errors.ts @@ -49,6 +49,11 @@ export function isRemoteBrowserPageMissingError(error: unknown): boolean { return isRemoteBrowserPageMissingCode(readErrorCode(error)) } +// The input landed and opened a page dialog, which the stream shows; its agent instructions are not for a person. +export function isRemoteBrowserInputOpenedDialogError(error: unknown): boolean { + return readErrorCode(error) === 'browser_dialog_opened' +} + // Stop automatic retry only for proven permanent failures; unclassified failures retain bounded recovery. export function isPermanentRemoteBrowserStreamFailure(error: unknown): boolean { const code = readErrorCode(error) diff --git a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle-deps.ts b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle-deps.ts index 85309a0e85f..5b3fea412c6 100644 --- a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle-deps.ts +++ b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle-deps.ts @@ -1,3 +1,4 @@ +import type { RemoteBrowserJavaScriptDialogAsk } from './remote-browser-javascript-dialog' import type { RemoteBrowserPageSessionDeps } from './remote-browser-page-session' import type { RemoteBrowserScreencastSubscribe } from './remote-browser-screencast-subscription' import type { RemoteBrowserStreamStatus } from './remote-browser-stream-status' @@ -18,4 +19,5 @@ export type RemoteBrowserStreamLifecycleDeps = Omit void clearFrame: () => void handleFrameBytes: (token: RemoteBrowserStreamToken, bytes: Uint8Array) => void + askJavaScriptDialog: RemoteBrowserJavaScriptDialogAsk } diff --git a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle-test-harness.ts b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle-test-harness.ts index bcbec3ba39b..c2052eb4fa9 100644 --- a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle-test-harness.ts +++ b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle-test-harness.ts @@ -3,6 +3,10 @@ import { RemoteBrowserStreamLifecycle } from './remote-browser-stream-lifecycle' import type { RemoteBrowserPageHandle, RemoteBrowserRpcCall } from './remote-browser-page-session' import type { RemoteBrowserScreencastSubscribe } from './remote-browser-screencast-subscription' import type { RemoteBrowserViewportSize } from './remote-browser-stream-tokens' +import type { + BrowserJavaScriptDialogAnswer, + BrowserJavaScriptDialogPrompt +} from '../../../../../shared/browser/prompts/browser-javascript-dialog' import { canReconnectRemoteBrowserStream, isRemoteBrowserStreamBusy, @@ -40,6 +44,14 @@ export type FakeScreencastStream = { emitResponseFailure: (code: string, message: string) => void emitTransportError: (code: string, message: string) => void emitClose: () => void + emitDialog: (dialogType: string, message: string) => void + emitDialogClosed: () => void +} + +export type DialogAsk = { + dialog: BrowserJavaScriptDialogPrompt + signal: AbortSignal + answer: (answer: BrowserJavaScriptDialogAnswer | null) => void } export function rpcError(code: string, message: string): Error { @@ -59,6 +71,8 @@ export function createHarness() { const closedPages: (string | null)[] = [] const streams: FakeScreencastStream[] = [] const rpcLog: string[] = [] + const rpcParams = new Map() + const dialogAsks: DialogAsk[] = [] let capabilities: string[] = ['browser.screencast.v1'] let storedHandle: RemoteBrowserPageHandle | null = null @@ -75,6 +89,7 @@ export function createHarness() { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fake answers each method with the reply shape its caller reads. const callRpc = (async (_target: unknown, method: string, params?: { page?: string }) => { rpcLog.push(method) + rpcParams.set(method, params) if (method === 'status.get') { if (statusGate) { const gate = statusGate @@ -152,7 +167,9 @@ export function createHarness() { _meta: { runtimeId: 'runtime-1' } }), emitTransportError: (code, message) => callbacks.onError?.({ code, message }), - emitClose: () => callbacks.onClose?.() + emitClose: () => callbacks.onClose?.(), + emitDialog: (dialogType, message) => respond({ type: 'dialog', dialogType, message }), + emitDialogClosed: () => respond({ type: 'dialogClosed' }) } streams.push(stream) return { @@ -193,6 +210,8 @@ export function createHarness() { }, readViewportSize: () => viewportSize, syncViewport: async () => {}, + askJavaScriptDialog: (dialog, signal) => + new Promise((answer) => dialogAsks.push({ dialog, signal, answer })), getDeviceScaleFactor: () => 1, setStatus: (status) => statusLog.push(status), clearFrame: () => {}, @@ -207,6 +226,8 @@ export function createHarness() { closedPages, streams, rpcLog, + dialogAsks, + rpcParams: (method: string): unknown => rpcParams.get(method), get subscribeAttempts(): number { return subscribeAttempts }, diff --git a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle.test.ts b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle.test.ts index 3265d28661d..499a965110b 100644 --- a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle.test.ts +++ b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle.test.ts @@ -15,6 +15,48 @@ describe('RemoteBrowserStreamLifecycle', () => { vi.useRealTimers() }) + it("asks once about the streamed page's dialog and answers it on the host", async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + const stream = harness.streams[0] + + stream.emitDialog('confirm', 'Proceed?') + // The host repeats the dialog it still holds when a turned-away nested one closes. + stream.emitDialog('confirm', 'Proceed?') + expect(harness.dialogAsks).toHaveLength(1) + expect(harness.dialogAsks[0].dialog).toEqual({ + type: 'confirm', + message: 'Proceed?', + defaultPromptText: '', + origin: null + }) + + harness.dialogAsks[0].answer({ accept: true, promptText: '' }) + await settle() + expect(harness.rpcParams('browser.dialogAccept')).toEqual({ + worktree: 'worktree:wt-1', + page: 'page-1', + text: '' + }) + }) + + it('drops the ask when the host closes the dialog some other way', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + const stream = harness.streams[0] + + stream.emitDialog('alert', 'Saved') + stream.emitDialogClosed() + expect(harness.dialogAsks[0].signal.aborted).toBe(true) + + // The next dialog gets its own ask. + stream.emitDialog('alert', 'Again') + expect(harness.dialogAsks).toHaveLength(2) + harness.dialogAsks[1].answer(null) + await settle() + expect(harness.rpcLog).toContain('browser.dialogDismiss') + }) + // Parked unmount has no effect cleanup left; parking must already have unsubscribed. it('leaves nothing subscribed when the pane unmounts while its stream is parked', async () => { const harness = createHarness() diff --git a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle.ts b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle.ts index 623c7728272..fb3c40ce406 100644 --- a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle.ts +++ b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle.ts @@ -1,6 +1,7 @@ import type { RuntimeClientTarget } from '../../../../../shared/runtime-client-target' import type { RuntimeStatus } from '../../../../../shared/runtime-types' import { RemoteBrowserPageSession } from './remote-browser-page-session' +import { RemoteBrowserJavaScriptDialog } from './remote-browser-javascript-dialog' import { openRemoteBrowserScreencastStream } from './remote-browser-screencast-subscription' import { RemoteBrowserStreamRestartScheduler } from './remote-browser-stream-restart-scheduler' import { RemoteBrowserStreamLiveness } from './remote-browser-stream-liveness' @@ -273,7 +274,8 @@ export class RemoteBrowserStreamLifecycle { }, onPageMissing: () => deps.closeMissingRemotePage(pageId), onFrame: (bytes) => deps.handleFrameBytes(token, bytes), - onClosed: () => this.handleStreamClosed(token, true) + onClosed: () => this.handleStreamClosed(token, true), + javaScriptDialog: new RemoteBrowserJavaScriptDialog(deps) } ) return { token, unsubscribe: subscription.unsubscribe } diff --git a/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-input.ts b/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-input.ts index e01a458bc49..3431469c0a5 100644 --- a/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-input.ts +++ b/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-input.ts @@ -1,6 +1,9 @@ import { useCallback, useRef } from 'react' import { callRuntimeRpc } from '@/runtime/rpc/calls/runtime-rpc-call' -import { isRemoteBrowserPageMissingError } from './remote-browser-stream-errors' +import { + isRemoteBrowserInputOpenedDialogError, + isRemoteBrowserPageMissingError +} from './remote-browser-stream-errors' import { useRemoteBrowserPageKeyboard } from './use-remote-browser-page-keyboard' import type { RemoteBrowserStreamLifecycle } from './remote-browser-stream-lifecycle' import type { @@ -175,6 +178,9 @@ export function useRemoteBrowserPageInput({ closeMissingRemotePage(pageId) return } + if (isRemoteBrowserInputOpenedDialogError(error)) { + return + } setPaneNotice({ kind: 'consequence', text: error instanceof Error ? error.message : 'Remote mouse input failed.' @@ -226,6 +232,9 @@ export function useRemoteBrowserPageInput({ closeMissingRemotePage(pageId) return } + if (isRemoteBrowserInputOpenedDialogError(error)) { + return + } setPaneNotice({ kind: 'consequence', text: error instanceof Error ? error.message : 'Remote mouse input failed.' diff --git a/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-keyboard.ts b/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-keyboard.ts index c20574aa963..536f0fdf723 100644 --- a/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-keyboard.ts +++ b/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-keyboard.ts @@ -14,7 +14,10 @@ import { getRemoteBrowserKeyboardShortcut, getRemoteBrowserKeypressKey } from './remote-browser-keyboard' -import { isRemoteBrowserPageMissingError } from './remote-browser-stream-errors' +import { + isRemoteBrowserInputOpenedDialogError, + isRemoteBrowserPageMissingError +} from './remote-browser-stream-errors' import type { RemoteBrowserStreamLifecycle } from './remote-browser-stream-lifecycle' import type { RemoteBrowserOperationToken } from './remote-browser-stream-tokens' import { @@ -178,6 +181,9 @@ export function useRemoteBrowserPageKeyboard({ closeMissingRemotePage(pageId) return } + if (isRemoteBrowserInputOpenedDialogError(error)) { + return + } setPaneNotice({ kind: 'consequence', text: error instanceof Error ? error.message : 'Remote keyboard input failed.' diff --git a/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-lifecycle.ts b/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-lifecycle.ts index 5870a9ccb9b..0f4f6a42ca5 100644 --- a/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-lifecycle.ts +++ b/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-lifecycle.ts @@ -23,6 +23,8 @@ import { type RemoteBrowserStreamBridge } from './remote-browser-page-input-model' import { subscribeRuntimeEnvironment } from '@/runtime/rpc/environment-revisions/runtime-environment-pairing-refresh' +import { useConfirmationDialog } from '@/components/confirmation-dialog-context' +import { javaScriptDialogPrompt } from '../browser-javascript-dialog-prompt' export function useRemoteBrowserPageLifecycle({ browserTab, @@ -68,6 +70,8 @@ export function useRemoteBrowserPageLifecycle({ const lifecycleRef = useRef(null) const closeBrowserPage = useAppStore((s) => s.closeBrowserPage) const closeBrowserTab = useAppStore((s) => s.closeBrowserTab) + // Stable for the provider's life, so the once-built lifecycle may hold it. + const confirm = useConfirmationDialog() useLayoutEffect(() => { currentBrowserTabIdRef.current = browserTab.id @@ -113,7 +117,8 @@ export function useRemoteBrowserPageLifecycle({ streamBridgeRef.current.closeMissingRemotePage(remotePageId), waitForViewportSize: () => streamBridgeRef.current.waitForViewportSize(), readViewportSize: () => remoteViewportSizeRef.current, - syncViewport: (pageId) => streamBridgeRef.current.syncViewport(pageId) + syncViewport: (pageId) => streamBridgeRef.current.syncViewport(pageId), + askJavaScriptDialog: (dialog, signal) => javaScriptDialogPrompt.ask(confirm, dialog, signal) }) } const lifecycle = lifecycleRef.current From 85873ef846af8735dcb6e5e2561b717864944478 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 13 Nov 2026 02:32:58 -0800 Subject: [PATCH 05/55] test: remove Git staging and status replays (#41966) Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../scripts/ci/sharding/ci-shard-timings.json | 1 - .../drive-checkout-suspect-paths.test.ts | 2 - .../ignores/huge-folder-ignore.test.ts | 9 - .../mutations/stage-worktree-changes.test.ts | 19 -- .../staging-discard-index-safety.test.ts | 15 -- .../mutations/status-discard-symlink.test.ts | 16 -- .../status/porcelain-v1-records.test.ts | 31 ---- ...us-branch-line-total-exec-contract.test.ts | 163 +----------------- .../status-branch-line-total-real-git.test.ts | 32 ---- ...s-branch-line-total-untracked-base.test.ts | 37 ---- .../status/status-cquoted-paths.test.ts | 45 ----- .../status-line-stats-host-paths.test.ts | 41 ----- ...status-line-stats-renames-real-git.test.ts | 2 - .../status/status-shared-symlinks.test.ts | 57 ------ 14 files changed, 1 insertion(+), 469 deletions(-) delete mode 100644 src/main/git/source-control/status/status-cquoted-paths.test.ts diff --git a/config/scripts/ci/sharding/ci-shard-timings.json b/config/scripts/ci/sharding/ci-shard-timings.json index 9aa6ee2513c..282a02f98c0 100644 --- a/config/scripts/ci/sharding/ci-shard-timings.json +++ b/config/scripts/ci/sharding/ci-shard-timings.json @@ -1732,7 +1732,6 @@ "src/main/git/source-control/status/status-branch-line-total-exec-contract.test.ts": 1033, "src/main/git/source-control/status/status-branch-line-total-real-git.test.ts": 785, "src/main/git/source-control/status/status-branch-line-total-untracked-base.test.ts": 700, - "src/main/git/source-control/status/status-cquoted-paths.test.ts": 348, "src/main/git/source-control/diffs/status-diff.test.ts": 327, "src/main/git/source-control/status-discard-and-bulk-staging.test.ts": 289, "src/main/git/source-control/mutations/status-discard-symlink.test.ts": 456, diff --git a/src/main/git/source-control/drive-checkout/drive-checkout-suspect-paths.test.ts b/src/main/git/source-control/drive-checkout/drive-checkout-suspect-paths.test.ts index a0c1427b75b..1ce9ae395a5 100644 --- a/src/main/git/source-control/drive-checkout/drive-checkout-suspect-paths.test.ts +++ b/src/main/git/source-control/drive-checkout/drive-checkout-suspect-paths.test.ts @@ -25,7 +25,6 @@ describe('DriveCheckoutSuspectPaths', () => { const reinstalled = { rootPath: REPO } tracker.recordEvents(reinstalled, [event('new.ts')]) expect([...(tracker.beginPoll(reinstalled)?.keys() ?? [])]).toEqual(['new.ts']) - expect(tracker.lastDegradeReason).toBe('watcher-restart') }) it('keeps rechecked-dirty paths through overflow, interruption, restart, and a lost watcher', () => { @@ -33,7 +32,6 @@ describe('DriveCheckoutSuspectPaths', () => { tracker.recordEvents(root, [event('hidden.ts')]) tracker.confirmDirty('hidden.ts', tracker.beginPoll(root)?.get('hidden.ts') ?? 0) tracker.recordEvents(root, [event('a'), event('b'), event('c')]) - expect(tracker.lastDegradeReason).toBe('over-limit') tracker.recordInterruption(root) const reinstalled = { rootPath: REPO } expect([...(tracker.beginPoll(reinstalled)?.keys() ?? [])]).toEqual(['hidden.ts']) diff --git a/src/main/git/source-control/ignores/huge-folder-ignore.test.ts b/src/main/git/source-control/ignores/huge-folder-ignore.test.ts index d4e09cd356b..1f053dc2415 100644 --- a/src/main/git/source-control/ignores/huge-folder-ignore.test.ts +++ b/src/main/git/source-control/ignores/huge-folder-ignore.test.ts @@ -25,13 +25,6 @@ describe('appendFolderToGitignore', () => { await fs.rm(dir, { recursive: true, force: true }) }) - it('creates .gitignore with the folder pattern when absent', async () => { - const wrote = await appendFolderToGitignore(dir, 'node_modules') - expect(wrote).toBe(true) - const content = await fs.readFile(path.join(dir, '.gitignore'), 'utf-8') - expect(content).toContain('node_modules/') - }) - it('appends with a leading newline when the file lacks a trailing one', async () => { await fs.writeFile(path.join(dir, '.gitignore'), '*.log') const wrote = await appendFolderToGitignore(dir, 'dist') @@ -44,7 +37,5 @@ describe('appendFolderToGitignore', () => { await expect(appendFolderToGitignore(dir, 'node_modules\n/etc/passwd')).rejects.toThrow( /Refusing to add/ ) - await expect(appendFolderToGitignore(dir, '../escape')).rejects.toThrow(/Refusing to add/) - await expect(appendFolderToGitignore(dir, 'arbitrary')).rejects.toThrow(/Refusing to add/) }) }) diff --git a/src/main/git/source-control/mutations/stage-worktree-changes.test.ts b/src/main/git/source-control/mutations/stage-worktree-changes.test.ts index cf678ddb4a8..72110bd59d8 100644 --- a/src/main/git/source-control/mutations/stage-worktree-changes.test.ts +++ b/src/main/git/source-control/mutations/stage-worktree-changes.test.ts @@ -85,15 +85,6 @@ describe('stageWorktreeChanges', () => { expect(names(repo, ['diff', '--cached', '--name-only'])).toHaveLength(OVER_CAP + 1) }) - it('stages only tracked changes for the tracked scope', async () => { - const repo = await createOverCapRepo() - - await stageWorktreeChanges(repo, 'tracked') - - expect(names(repo, ['diff', '--name-only'])).toEqual([]) - expect(names(repo, ['ls-files', '--others', '--exclude-standard'])).toEqual(['zz-new.txt']) - }) - it('leaves unresolved conflicts unstaged', async () => { const repo = await createConflictRepo() @@ -177,15 +168,5 @@ describe('stageWorktreeChanges', () => { ]) }) }) - - it('still stages a regular file the user put at a shared name', async () => { - const repo = await createRepo() - git(repo, ['commit', '-q', '--allow-empty', '-m', 'initial']) - await writeFile(path.join(repo, 'node_modules'), 'not a link\n') - - await stageWorktreeChanges(repo, 'all', { sharedLinkPaths: ['node_modules'] }) - - expect(names(repo, ['diff', '--cached', '--name-only'])).toEqual(['node_modules']) - }) }) }) diff --git a/src/main/git/source-control/mutations/staging-discard-index-safety.test.ts b/src/main/git/source-control/mutations/staging-discard-index-safety.test.ts index e966767ec53..95b8a7245dc 100644 --- a/src/main/git/source-control/mutations/staging-discard-index-safety.test.ts +++ b/src/main/git/source-control/mutations/staging-discard-index-safety.test.ts @@ -76,21 +76,6 @@ describe('staging and discard preserve index authority', () => { expect(await readFile(path.join(repo, 'selected.txt'), 'utf8')).toBe('selected\n') }) - it('discards a staged rename after unstaging both paths', async () => { - await writeFile(path.join(repo, 'old.txt'), 'rename content\n') - gitCommit(repo, 'initial') - await git(['mv', 'old.txt', 'new.txt']) - - await mutate({ bulk: true }, 'unstage', ['new.txt', 'old.txt']) - await mutate({ bulk: true }, 'discard', ['new.txt', 'old.txt']) - - expect(await git(['status', '--porcelain'])).toBe('') - expect(await readFile(path.join(repo, 'old.txt'), 'utf8')).toBe('rename content\n') - await expect(readFile(path.join(repo, 'new.txt'), 'utf8')).rejects.toMatchObject({ - code: 'ENOENT' - }) - }) - it.each(modes)('rejects an unresolved conflict on bulk=$bulk', async (mode) => { await writeFile(path.join(repo, 'file.txt'), 'base\n') gitCommit(repo, 'initial') diff --git a/src/main/git/source-control/mutations/status-discard-symlink.test.ts b/src/main/git/source-control/mutations/status-discard-symlink.test.ts index 611ccf73398..5eebbeac9b7 100644 --- a/src/main/git/source-control/mutations/status-discard-symlink.test.ts +++ b/src/main/git/source-control/mutations/status-discard-symlink.test.ts @@ -129,22 +129,6 @@ describe('discardChanges symlink safety', () => { await expect(readFile(path.join(repo, globMatchedFile), 'utf8')).resolves.toBe('keep modified') }) - it('treats bulk untracked discard paths with Git glob characters as literal paths', async () => { - const { repo } = await createRepoWithOutsideDirectory() - await writeFile(path.join(repo, '.gitignore'), 'ignored.log\n') - execFileSync('git', ['add', '.gitignore'], { cwd: repo }) - execFileSync('git', ['commit', '-q', '-m', 'ignore log fixture'], { cwd: repo }) - await writeFile(path.join(repo, globNamedFile), 'selected') - await writeFile(path.join(repo, globMatchedFile), 'unrelated') - await writeFile(path.join(repo, 'ignored.log'), 'ignored') - - await bulkDiscardChanges(repo, [globNamedFile]) - - await expect(access(path.join(repo, globNamedFile))).rejects.toThrow() - await expect(access(path.join(repo, globMatchedFile))).resolves.toBeUndefined() - await expect(access(path.join(repo, 'ignored.log'))).resolves.toBeUndefined() - }) - it('removes untracked nested git repos selected for discard', async () => { const { repo } = await createRepoWithOutsideDirectory() const nestedRepo = path.join(repo, 'nested') diff --git a/src/main/git/source-control/status/porcelain-v1-records.test.ts b/src/main/git/source-control/status/porcelain-v1-records.test.ts index 3d6a8188873..f0d1cf82468 100644 --- a/src/main/git/source-control/status/porcelain-v1-records.test.ts +++ b/src/main/git/source-control/status/porcelain-v1-records.test.ts @@ -26,37 +26,6 @@ afterEach(() => { }) describe('parsePorcelainV1Records', () => { - it('keeps paths containing spaces and quotes intact', () => { - const repo = createRepo() - writeFileSync(join(repo, 'a file "quoted".txt'), 'x') - - const records = parsePorcelainV1Records(git(['status', '--porcelain', '-z'], repo)) - - expect(records).toEqual([{ xy: '??', path: 'a file "quoted".txt' }]) - }) - - it('keeps non-ASCII paths raw rather than C-quoted', () => { - const repo = createRepo() - writeFileSync(join(repo, '日本語.txt'), 'x') - - expect(parsePorcelainV1Records(git(['status', '--porcelain', '-z'], repo))).toEqual([ - { xy: '??', path: '日本語.txt' } - ]) - }) - - // Rename origins are extra NUL fields; treating one as a record could invent an untracked status. - it('consumes the origin path of a rename instead of emitting it as a record', () => { - const repo = createRepo() - writeFileSync(join(repo, 'original.txt'), 'content\n') - git(['add', '-A'], repo) - git(['commit', '-qm', 'init'], repo) - git(['mv', 'original.txt', 'renamed.txt'], repo) - - const records = parsePorcelainV1Records(git(['status', '--porcelain', '-z'], repo)) - - expect(records).toEqual([{ xy: 'R ', path: 'renamed.txt' }]) - }) - it('reports a rename alongside a later untracked entry', () => { const repo = createRepo() writeFileSync(join(repo, 'original.txt'), 'content\n') diff --git a/src/main/git/source-control/status/status-branch-line-total-exec-contract.test.ts b/src/main/git/source-control/status/status-branch-line-total-exec-contract.test.ts index 0e7fe6e6207..4811d84928f 100644 --- a/src/main/git/source-control/status/status-branch-line-total-exec-contract.test.ts +++ b/src/main/git/source-control/status/status-branch-line-total-exec-contract.test.ts @@ -1,71 +1,15 @@ -import type * as GitExecFileModule from '../../command-runner/git-exec-file' -import type * as GitStreamStdoutModule from '../../command-runner/git-stream-stdout' import { execFileSync } from 'node:child_process' import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import * as path from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { invalidateGitBranchLineTotalInFlight } from '../../../../shared/git/status/git-branch-line-total' -import type * as BranchLineTotal from '../../../../shared/git/status/git-branch-line-total' - -// Spy on real Git argv to validate opt-in and coalescing without stubbing results. -const { gitExecCalls, execHooks, coalescerJoins } = vi.hoisted(() => ({ - gitExecCalls: [] as string[][], - execHooks: { beforeExec: undefined as undefined | ((args: string[]) => Promise | void) }, - coalescerJoins: { count: 0, onJoin: undefined as undefined | (() => void) } -})) - -vi.mock('../../../../shared/git/status/git-branch-line-total', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - computeGitBranchLineTotal: ( - input: Parameters[0] - ) => { - // Lease acquisition is synchronous, so a join is observable immediately after this call. - const total = actual.computeGitBranchLineTotal(input) - coalescerJoins.count += 1 - coalescerJoins.onJoin?.() - return total - } - } -}) - -vi.mock('../../command-runner/git-exec-file', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - gitExecFileAsync: async (...args: Parameters) => { - gitExecCalls.push(args[0]) - await execHooks.beforeExec?.(args[0]) - return actual.gitExecFileAsync(...args) - }, - gitExecFileAsyncBuffer: async (...args: Parameters) => { - gitExecCalls.push(args[0]) - return actual.gitExecFileAsyncBuffer(...args) - } - } -}) - -vi.mock('../../command-runner/git-stream-stdout', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - gitStreamStdout: async (...args: Parameters) => { - gitExecCalls.push(args[0]) - return actual.gitStreamStdout(...args) - } - } -}) - import { clearEffectiveUpstreamStatusCacheForTests } from '../upstream/effective-upstream-status-cache' import { clearSubmodulePathsCacheForTests } from '../submodules/submodule-paths' import { getStatus } from './status-read' import { invalidateGitReadCaches } from '../git-read-cache-invalidation' -const BOGUS_MERGE_BASE = 'deadbeef'.repeat(5) -const NO_LINES = { added: 0, removed: 0 } const tempRoots: string[] = [] function git(repo: string, args: string[]): string { @@ -97,26 +41,6 @@ async function write(repo: string, relativePath: string, contents: string): Prom await writeFile(target, contents) } -/** The `mergeBase → worktree` diff, distinguished from the per-area numstats by its rev + `--`. */ -function rangedDiffCalls(): string[][] { - return gitExecCalls.filter((args) => args.includes('--numstat') && args.at(-1) === '--') -} - -/** Resolves once `count` status passes have entered the branch-total coalescer. */ -function waitForCoalescerJoins(count: number): Promise { - if (coalescerJoins.count >= count) { - return Promise.resolve() - } - return new Promise((resolve) => { - coalescerJoins.onJoin = () => { - if (coalescerJoins.count >= count) { - coalescerJoins.onJoin = undefined - resolve() - } - } - }) -} - function resetGitReadCaches(): void { clearEffectiveUpstreamStatusCacheForTests() clearSubmodulePathsCacheForTests() @@ -125,16 +49,10 @@ function resetGitReadCaches(): void { } beforeEach(() => { - gitExecCalls.length = 0 - execHooks.beforeExec = undefined - coalescerJoins.count = 0 - coalescerJoins.onJoin = undefined resetGitReadCaches() }) afterEach(async () => { - execHooks.beforeExec = undefined - coalescerJoins.onJoin = undefined resetGitReadCaches() await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) }) @@ -151,84 +69,5 @@ describe('branch line total completeness', () => { expect(result.didHitLimit).toBe(true) expect(result.branchLineTotal).toBeUndefined() expect(result.branchLineTotalUnavailable).toBeUndefined() - expect(rangedDiffCalls()).toEqual([]) - }) - - it('invalidates the total when the ranged numstat fails, rather than reporting zero', async () => { - const { repo } = await createFixtureRepo() - - const result = await getStatus(repo, { branchLineTotalMergeBase: BOGUS_MERGE_BASE }) - - expect(result.entries.length).toBeGreaterThan(0) - expect(result.branchLineTotal).toBeUndefined() - expect(result.branchLineTotalUnavailable).toBe(BOGUS_MERGE_BASE) - expect(rangedDiffCalls()).toHaveLength(1) - }) - - it('rejects with an AbortError when the pass is cancelled mid-diff', async () => { - const { repo, mergeBase } = await createFixtureRepo() - const controller = new AbortController() - execHooks.beforeExec = (args) => { - if (args.includes('--numstat') && args.at(-1) === '--') { - controller.abort() - } - } - - await expect( - getStatus(repo, { branchLineTotalMergeBase: mergeBase, signal: controller.signal }) - ).rejects.toMatchObject({ name: 'AbortError' }) - expect(rangedDiffCalls()).toHaveLength(1) - }) -}) - -describe('branch line total exec budget', () => { - it('shares one ranged diff across status passes the status lease cannot merge', async () => { - const { repo, mergeBase } = await createFixtureRepo() - // Different limits require separate status leases; hold the diff until both join its shared lease. - execHooks.beforeExec = async (args) => { - if (args.includes('--numstat') && args.at(-1) === '--') { - await waitForCoalescerJoins(2) - } - } - - const [first, second] = await Promise.all([ - getStatus(repo, { branchLineTotalMergeBase: mergeBase, limit: 0 }), - getStatus(repo, { branchLineTotalMergeBase: mergeBase, limit: 4096 }) - ]) - - expect(first.branchLineTotal).toEqual({ - added: 1, - removed: 0, - mergeBase, - test: NO_LINES, - generated: NO_LINES - }) - expect(second.branchLineTotal).toEqual(first.branchLineTotal) - expect(gitExecCalls.filter((args) => args.includes('status'))).toHaveLength(2) - expect(rangedDiffCalls()).toHaveLength(1) - }) - - it('recomputes rather than reusing a total measured against another merge base', async () => { - const { repo, mergeBase } = await createFixtureRepo() - git(repo, ['add', '-A']) - git(repo, ['commit', '-q', '-m', 'second']) - const laterMergeBase = git(repo, ['rev-parse', 'HEAD']) - - await getStatus(repo, { branchLineTotalMergeBase: mergeBase, reuseLineStats: true }) - gitExecCalls.length = 0 - invalidateGitBranchLineTotalInFlight() - const result = await getStatus(repo, { - branchLineTotalMergeBase: laterMergeBase, - reuseLineStats: true - }) - - expect(result.branchLineTotal).toEqual({ - added: 0, - removed: 0, - mergeBase: laterMergeBase, - test: NO_LINES, - generated: NO_LINES - }) - expect(rangedDiffCalls()).toHaveLength(1) }) }) diff --git a/src/main/git/source-control/status/status-branch-line-total-real-git.test.ts b/src/main/git/source-control/status/status-branch-line-total-real-git.test.ts index c0cf4420fb0..855f4e69e1c 100644 --- a/src/main/git/source-control/status/status-branch-line-total-real-git.test.ts +++ b/src/main/git/source-control/status/status-branch-line-total-real-git.test.ts @@ -42,20 +42,6 @@ function commitAll(repo: string, message: string): string { const NO_LINES = { added: 0, removed: 0 } -function rangedDiffTotal(repo: string, mergeBase: string): { added: number; removed: number } { - let added = 0 - let removed = 0 - for (const line of git(repo, ['diff', '--numstat', '-M', mergeBase, '--']).split(/\r?\n/)) { - if (!line) { - continue - } - const [rawAdded, rawRemoved] = line.split('\t') - added += rawAdded === '-' ? 0 : Number.parseInt(rawAdded ?? '0', 10) - removed += rawRemoved === '-' ? 0 : Number.parseInt(rawRemoved ?? '0', 10) - } - return { added, removed } -} - beforeEach(() => { invalidateGitReadCaches() invalidateGitBranchLineTotalInFlight() @@ -79,7 +65,6 @@ describe('branch line total against a real repository', () => { const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) - expect(rangedDiffTotal(repo, mergeBase)).toEqual({ added: 1, removed: 0 }) expect(result.branchLineTotal).toEqual({ added: 1, removed: 0, @@ -128,21 +113,4 @@ describe('branch line total against a real repository', () => { generated: NO_LINES }) }) - - it('omits the total when a deleted base file is recreated as untracked', async () => { - const repo = await createFixtureRepo() - await write(repo, 'gone.txt', 'a\nb\nc\n') - const mergeBase = commitAll(repo, 'base') - git(repo, ['rm', '-q', 'gone.txt']) - commitAll(repo, 'delete gone.txt') - await write(repo, 'gone.txt', 'n1\nn2\n') - - const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) - - expect(result.entries.map((entry) => [entry.area, entry.path])).toEqual([ - ['untracked', 'gone.txt'] - ]) - expect(rangedDiffTotal(repo, mergeBase)).toEqual({ added: 0, removed: 3 }) - expect(result.branchLineTotal).toBeUndefined() - }) }) diff --git a/src/main/git/source-control/status/status-branch-line-total-untracked-base.test.ts b/src/main/git/source-control/status/status-branch-line-total-untracked-base.test.ts index 3afb62b5687..4d6a32e016b 100644 --- a/src/main/git/source-control/status/status-branch-line-total-untracked-base.test.ts +++ b/src/main/git/source-control/status/status-branch-line-total-untracked-base.test.ts @@ -3,7 +3,6 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, expect, it } from 'vitest' -import { getBranchCompare } from '../comparisons/branch-compare' import { getStatus } from './status-read' import { invalidateGitReadCaches } from '../git-read-cache-invalidation' @@ -36,42 +35,6 @@ afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) }) -it.each(['one\ntwo\n', 'one\nchanged\n'])( - 'withholds a merge-base total for a deleted tracked file recreated as untracked until staged: %j', - async (contents) => { - const repo = await createRepo() - await writeFile(join(repo, 'new.txt'), 'one\ntwo\n') - await git(repo, 'add', 'new.txt') - await git(repo, 'commit', '-qm', 'add upstream file') - await git(repo, 'checkout', '-B', 'feature', 'base') - await git(repo, 'rm', 'new.txt') - await git(repo, 'commit', '-qm', 'delete tracked file on feature') - const compare = await getBranchCompare(repo, 'base') - const comparisonBase = compare.summary.mergeBase - if (!comparisonBase) { - throw new Error('Missing comparison base') - } - const options = { branchLineTotalMergeBase: comparisonBase } - expect((await getStatus(repo, options)).branchLineTotal).toMatchObject({ added: 0, removed: 2 }) - - await writeFile(join(repo, 'new.txt'), contents) - const unavailable = await getStatus(repo, options) - expect(unavailable.branchLineTotal).toBeUndefined() - expect(unavailable.branchLineTotalUnavailable).toBe(comparisonBase) - const reused = await getStatus(repo, { ...options, reuseLineStats: true }) - expect(reused.branchLineTotal).toBeUndefined() - expect(reused.branchLineTotalUnavailable).toBe(comparisonBase) - - await git(repo, 'add', 'new.txt') - invalidateGitReadCaches() - const expected = contents === 'one\ntwo\n' ? 0 : 1 - expect((await getStatus(repo, options)).branchLineTotal).toMatchObject({ - added: expected, - removed: expected - }) - } -) - it('detects untracked overlaps with the source of a rename, including unusual filenames', async () => { const repo = await createRepo() const oldPath = process.platform === 'win32' ? 'old name.txt' : 'old\tname.txt' diff --git a/src/main/git/source-control/status/status-cquoted-paths.test.ts b/src/main/git/source-control/status/status-cquoted-paths.test.ts deleted file mode 100644 index ac047cc18bf..00000000000 --- a/src/main/git/source-control/status/status-cquoted-paths.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { execFileSync } from 'node:child_process' -import { mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import * as path from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { getStatus } from './status-read' -import { stageFile } from '../mutations/staging' - -const tempRoots: string[] = [] - -async function createRepo(): Promise { - const repo = await mkdtemp(path.join(tmpdir(), 'orca-status-cquoted-')) - tempRoots.push(repo) - execFileSync('git', ['init', '-q'], { cwd: repo }) - execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repo }) - execFileSync('git', ['config', 'user.name', 'Test User'], { cwd: repo }) - return repo -} - -function gitNames(repo: string, args: string[]): string[] { - const stdout = execFileSync('git', args, { cwd: repo, encoding: 'utf8' }) - return stdout.split('\0').filter(Boolean) -} - -afterEach(async () => { - await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) -}) - -describe('git status C-quoted paths', () => { - it('returns the real path for untracked UTF-8 filenames', async () => { - const repo = await createRepo() - const filePath = '日本語-file.txt' - await writeFile(path.join(repo, filePath), 'new file\n') - - const status = await getStatus(repo) - - expect(status.entries).toEqual([ - { path: filePath, status: 'untracked', area: 'untracked', added: 1 } - ]) - - await stageFile(repo, status.entries[0].path) - - expect(gitNames(repo, ['diff', '--cached', '--name-only', '-z'])).toEqual([filePath]) - }) -}) diff --git a/src/main/git/source-control/status/status-line-stats-host-paths.test.ts b/src/main/git/source-control/status/status-line-stats-host-paths.test.ts index f2287dd53fa..e9be6d37fdd 100644 --- a/src/main/git/source-control/status/status-line-stats-host-paths.test.ts +++ b/src/main/git/source-control/status/status-line-stats-host-paths.test.ts @@ -4,7 +4,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as FsPromises from 'node:fs/promises' import type * as BoundedFileReader from '../../../../shared/file-content/node-bounded-file-reader' import type { GitStatusEntry } from '../../../../shared/git/status/git-status-types' -import { invalidateGitBranchLineTotalInFlight } from '../../../../shared/git/status/git-branch-line-total' const { lstatPaths, untrackedFiles } = vi.hoisted(() => ({ lstatPaths: [] as string[], @@ -47,30 +46,10 @@ vi.mock('../../../../shared/file-content/node-bounded-file-reader', async (impor } }) -const { gitExecFileAsyncMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn() })) - -vi.mock('../../command-runner/git-exec-file', async (importOriginal) => ({ - ...(await importOriginal>()), - gitExecFileAsync: gitExecFileAsyncMock, - gitExecFileAsyncBuffer: vi.fn() -})) - -vi.mock('../../command-runner/git-environment/git-process-env', async (importOriginal) => ({ - ...(await importOriginal>()), - gitOptionalLocksDisabledEnv: (env: NodeJS.ProcessEnv = process.env) => env -})) - -vi.mock('../../command-runner/git-stream-stdout', async (importOriginal) => ({ - ...(await importOriginal>()), - gitStreamStdout: vi.fn() -})) - import { attachLineStats } from './status-line-stats' -import { createBranchLineTotalInput } from './status-branch-line-total-input' const GUEST_WORKTREE = '/home/me/repo' const UNC_WORKTREE = String.raw`\\wsl.localhost\Ubuntu\home\me\repo` -const MERGE_BASE = 'a'.repeat(40) function untrackedEntry(entryPath: string): GitStatusEntry { return { path: entryPath, area: 'untracked', status: 'added' } @@ -80,9 +59,6 @@ describe('untracked line stats on a WSL worktree read by a Windows host', () => beforeEach(() => { lstatPaths.length = 0 untrackedFiles.clear() - gitExecFileAsyncMock.mockReset() - gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) - invalidateGitBranchLineTotalInFlight() vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') }) @@ -90,21 +66,6 @@ describe('untracked line stats on a WSL worktree read by a Windows host', () => vi.restoreAllMocks() }) - it('invalidates an overlapping untracked branch total through the WSL filesystem spelling', async () => { - const target = path.join(UNC_WORKTREE, 'fresh.txt') - untrackedFiles.set(target, 'one\ntwo\n') - gitExecFileAsyncMock.mockResolvedValue({ stdout: '0\t2\tfresh.txt\0', stderr: '' }) - const input = createBranchLineTotalInput( - GUEST_WORKTREE, - [untrackedEntry('fresh.txt')], - { wslDistro: 'Ubuntu', branchLineTotalMergeBase: MERGE_BASE }, - true - ) - - expect(await input?.compute()).toBeNull() - expect(lstatPaths).toEqual([target]) - }) - it('re-spells the guest form only, leaving every host-openable spelling byte-identical', async () => { const cases: { worktree: string; expected: string }[] = [ { worktree: GUEST_WORKTREE, expected: UNC_WORKTREE }, @@ -113,7 +74,6 @@ describe('untracked line stats on a WSL worktree read by a Windows host', () => worktree: '//wsl.localhost/Ubuntu/home/me/repo', expected: '//wsl.localhost/Ubuntu/home/me/repo' }, - { worktree: '//wsl$/Ubuntu/home/me/repo', expected: '//wsl$/Ubuntu/home/me/repo' }, { worktree: '/home/me/my repo ', expected: '/home/me/my repo ' } ] @@ -126,7 +86,6 @@ describe('untracked line stats on a WSL worktree read by a Windows host', () => await attachLineStats(worktree, entries, { wslDistro: 'Ubuntu' }) - expect(lstatPaths, worktree).toEqual([target]) expect(entries[0].added, worktree).toBe(2) } }) diff --git a/src/main/git/source-control/status/status-line-stats-renames-real-git.test.ts b/src/main/git/source-control/status/status-line-stats-renames-real-git.test.ts index 578f7b29c7c..358d77ceac3 100644 --- a/src/main/git/source-control/status/status-line-stats-renames-real-git.test.ts +++ b/src/main/git/source-control/status/status-line-stats-renames-real-git.test.ts @@ -38,8 +38,6 @@ afterEach(async () => { describe.each(['staged', 'unstaged'] as const)('%s rename line counts', (area) => { it.each([ - { diffRenames: 'true', statusRenames: undefined, renamed: true }, - { diffRenames: 'false', statusRenames: undefined, renamed: false }, { diffRenames: 'true', statusRenames: 'false', renamed: false }, { diffRenames: 'false', statusRenames: 'true', renamed: true } ])('matches status with $diffRenames diff / $statusRenames status', async (config) => { diff --git a/src/main/git/source-control/status/status-shared-symlinks.test.ts b/src/main/git/source-control/status/status-shared-symlinks.test.ts index 6bf5283d84d..14644afadd8 100644 --- a/src/main/git/source-control/status/status-shared-symlinks.test.ts +++ b/src/main/git/source-control/status/status-shared-symlinks.test.ts @@ -37,29 +37,6 @@ describe('getStatus shared symlink exclusion', () => { rmSync(root, { recursive: true, force: true }) }) - it('Git reports the shared symlink as untracked, without a trailing slash', async () => { - const status = await getStatus(worktree) - - expect(status.entries).toEqual([ - expect.objectContaining({ path: 'node_modules', area: 'untracked' }) - ]) - }) - - it('drops the shared symlink when it is declared shared', async () => { - const status = await getStatus(worktree, { sharedLinkPaths: ['node_modules'] }) - - expect(status.entries).toEqual([]) - }) - - it.each(['./node_modules', 'node_modules/'])( - 'drops the shared symlink when the setting spells it %s', - async (sharedPath) => { - const status = await getStatus(worktree, { sharedLinkPaths: [sharedPath] }) - - expect(status.entries).toEqual([]) - } - ) - it('still reports a genuine untracked file alongside a shared symlink', async () => { writeFileSync(join(worktree, 'scratch.txt'), 'unsaved work\n') @@ -70,29 +47,6 @@ describe('getStatus shared symlink exclusion', () => { ]) }) - it('still reports a modified tracked file alongside a shared symlink', async () => { - writeFileSync(join(worktree, 'README.md'), '# edited\n') - - const status = await getStatus(worktree, { sharedLinkPaths: ['node_modules'] }) - - expect(status.entries).toEqual([ - expect.objectContaining({ path: 'README.md', area: 'unstaged' }) - ]) - }) - - it('keeps a regular directory the user created at a configured shared name', async () => { - mkdirSync(join(worktree, 'vendor')) - writeFileSync(join(worktree, 'vendor', 'real.txt'), 'user work\n') - - const status = await getStatus(worktree, { - sharedLinkPaths: ['node_modules', 'vendor'] - }) - - expect(status.entries).toEqual([ - expect.objectContaining({ path: 'vendor/real.txt', area: 'untracked' }) - ]) - }) - it('keeps a regular file the user created at a configured shared name', async () => { writeFileSync(join(worktree, 'notes'), 'user work\n') @@ -116,17 +70,6 @@ describe('getStatus shared symlink exclusion', () => { ]) }) - it('drops shared symlinks whose names have a space or non-ASCII characters', async () => { - const names = ['my shared dir', 'ライブラリ'] - for (const name of names) { - symlinkSync(join(primary, 'node_modules'), join(worktree, name), 'dir') - } - - const status = await getStatus(worktree, { sharedLinkPaths: ['node_modules', ...names] }) - - expect(status.entries).toEqual([]) - }) - it('keeps a symlink at a path that was never declared shared', async () => { symlinkSync(join(primary, 'node_modules'), join(worktree, 'vendor'), 'dir') From 1704173140ed36c7ac091d3ada75742748fea817 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Fri, 13 Nov 2026 10:34:29 +0000 Subject: [PATCH 06/55] fix(browser): don't offer to open a streamed page's server loopback URL on this machine (#41967) The streamed pane's context menu opened the page or link URL as-is in the desktop's default browser, so a server's localhost page opened this machine's localhost. Use resolveRemoteFailureExternalUrl, as the streamed error page already does, and leave the items out when it refuses. --- .../remote-browser-page-context-menu.tsx | 69 ++++++++++--------- 1 file changed, 38 insertions(+), 31 deletions(-) diff --git a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-context-menu.tsx b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-context-menu.tsx index 0c35b2407ea..ee5b265cdcb 100644 --- a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-context-menu.tsx +++ b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-context-menu.tsx @@ -3,8 +3,8 @@ import { createPortal } from 'react-dom' import { callRuntimeRpc } from '@/runtime/rpc/calls/runtime-rpc-call' import { useTranslate } from '@/i18n/use-translate' import { - normalizeExternalBrowserUrl, - redactKagiSessionToken + redactKagiSessionToken, + resolveRemoteFailureExternalUrl } from '../../../../../shared/browser-url' import { isRemoteBrowserPageMissingError } from './remote-browser-stream-errors' import type { RemoteBrowserStreamLifecycle } from './remote-browser-stream-lifecycle' @@ -171,6 +171,12 @@ export function RemoteBrowserPageContextMenu({ el.style.top = `${Math.max(0, renderY) + offsetY}px` }, [contextMenu]) + // The server's loopback address would open this machine's, not the page's. + const linkExternalUrl = contextMenu.linkUrl + ? resolveRemoteFailureExternalUrl(contextMenu.linkUrl) + : null + const pageExternalUrl = resolveRemoteFailureExternalUrl(contextMenu.pageUrl) + return createPortal( <>
@@ -193,22 +199,21 @@ export function RemoteBrowserPageContextMenu({ 'Open Link In Orca Browser' )} - + {linkExternalUrl ? ( + + ) : null} + {pageExternalUrl ? ( + + ) : null} +` + +const CALLBACK_HTML = `Callback` + +async function startFixture(): Promise<{ server: Server; origin: string }> { + const server = createServer((request, response) => { + const path = new URL(request.url ?? '/', 'http://127.0.0.1').pathname + response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }) + if (path === '/opener') { + response.end(OPENER_HTML) + } else if (path === '/authorize') { + response.end(``) + } else if (path === '/callback') { + response.end(CALLBACK_HTML) + } else { + response.end('Destination') + } + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('fixture server has no port') + } + return { server, origin: `http://127.0.0.1:${address.port}` } +} + +function readPagePlacements(page: Page, worktreeId: string): Promise> { + return page.evaluate((worktreeId) => { + const state = window.__store!.getState() + const placements: Record = {} + for (const workspace of state.browserTabsByWorktree[worktreeId] ?? []) { + for (const browserPage of state.browserPagesByWorkspace[workspace.id] ?? []) { + const kind = state.remoteBrowserPageHandlesByPageId[browserPage.id]?.placement?.kind + placements[new URL(browserPage.url).pathname] = kind ?? 'none' + } + } + return placements + }, worktreeId) +} + +async function showPage(page: Page, worktreeId: string, path: string): Promise { + await page.evaluate( + ({ worktreeId, path }) => { + const state = window.__store!.getState() + for (const workspace of state.browserTabsByWorktree[worktreeId] ?? []) { + for (const browserPage of state.browserPagesByWorkspace[workspace.id] ?? []) { + if (new URL(browserPage.url).pathname === path) { + state.focusBrowserTabInWorktree(worktreeId, browserPage.id, { surfacePane: true }) + } + } + } + }, + { worktreeId, path } + ) +} + +function guestEval(client: PairedElectronClient, url: string, code: string): Promise { + return client.app.evaluate( + ({ webContents }, { url, code }) => + webContents + .getAllWebContents() + .find((contents) => contents.getType() === 'webview' && contents.getURL() === url) + ?.executeJavaScript(code) ?? null, + { url, code } + ) +} + +// A real mouse click, so main sees the user's gesture as it would on a desktop. +async function clickInGuest( + client: PairedElectronClient, + url: string, + selector: string +): Promise { + await expect + .poll(() => guestEval(client, url, 'innerWidth > 320 && innerHeight > 200'), { + timeout: 30_000 + }) + .toBe(true) + await client.app.evaluate( + async ({ webContents }, { url, selector }) => { + const guest = webContents + .getAllWebContents() + .find((contents) => contents.getType() === 'webview' && contents.getURL() === url)! + const { x, y }: { x: number; y: number } = await guest.executeJavaScript( + `(() => { const r = document.querySelector(${JSON.stringify(selector)}).getBoundingClientRect(); return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) } })()` + ) + guest.sendInputEvent({ type: 'mouseDown', x, y, button: 'left', clickCount: 1 }) + guest.sendInputEvent({ type: 'mouseUp', x, y, button: 'left', clickCount: 1 }) + }, + { url, selector } + ) +} + +test('a This-device page opens new tabs on this device and keeps OAuth popups working', async ({ + testRepoPath +}, testInfo) => { + // Boots a headless host and a paired client, then waits on two tab creations and a popup round trip. + test.setTimeout(300_000) + const { server, origin } = await startFixture() + const host = await launchHeadlessPairedRuntimeHost() + let client: PairedElectronClient | null = null + try { + await host.client.call('repo.add', { path: testRepoPath, kind: 'git' }) + client = await launchPairedElectronClient(host.offer, testInfo, 'This-device popups') + const page = client.page + const worktreeId = await waitForPairedClientWorktree(page) + await page.evaluate( + ({ environmentId, worktreeId }) => + window.__store!.getState().setActiveWorktree(worktreeId, `runtime:${environmentId}`), + { environmentId: client.environmentId, worktreeId } + ) + const openerUrl = `${origin}/opener` + await page.evaluate(async (url) => { + const state = window.__store!.getState() + state.setBrowserDefaultUrl(url) + await state.openNewBrowserTabInActiveWorkspace( + state.activeGroupIdByWorktree[state.activeWorktreeId!]! + ) + }, openerUrl) + await expect + .poll(() => readPagePlacements(page, worktreeId), { timeout: 60_000 }) + .toEqual({ '/opener': 'client' }) + await expect + .poll(() => guestEval(client!, openerUrl, 'document.readyState'), { timeout: 60_000 }) + .toBe('complete') + + await showPage(page, worktreeId, '/opener') + await clickInGuest(client, openerUrl, '#blank') + await expect + .poll(() => readPagePlacements(page, worktreeId), { + timeout: 30_000, + message: 'the target=_blank link did not open a This-device tab' + }) + .toEqual({ '/opener': 'client', '/destination': 'client' }) + await expect + .poll(() => readHostBrowserPageUrls(host.client, `path:${testRepoPath}`), { timeout: 30_000 }) + .toContain(`${origin}/destination`) + + await showPage(page, worktreeId, '/opener') + await clickInGuest(client, openerUrl, '#oauth') + await expect + .poll(() => guestEval(client!, openerUrl, 'window.__oauthMessages.join()'), { + timeout: 30_000, + message: 'the OAuth popup never reached its opener' + }) + .toBe('code=abc') + await expect + .poll(() => guestEval(client!, openerUrl, 'window.__oauthPopup.closed'), { timeout: 10_000 }) + .toBe(true) + } finally { + await client?.dispose() + await host.dispose() + server.close() + } +}) From 52b48ebc0982e31b1737e863592954e0f3ec1a6f Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Fri, 13 Nov 2026 10:50:14 +0000 Subject: [PATCH 09/55] fix(browser): carry large This-device results and typed errors back to the server (#41971) --- .../browser-host-command-result-staging.ts | 80 ++++++++++++++ .../browser-host-command-result-submission.ts | 34 +++--- .../paired-runtime-browser-host-lease.ts | 5 +- .../paired-runtime-browser-client-host.ts | 29 ++++- .../browser-client-page-command-failure.ts | 15 ++- .../browser-host-command-ledger.test.ts | 39 +++++++ .../commands/browser-host-command-ledger.ts | 15 ++- .../browser-host-command-result-staging.ts | 101 ++++++++++++++++++ .../commands/browser-host-command-state.ts | 10 +- .../leases/browser-host-lease-registry.ts | 7 +- .../runtime-browser-client-automation.ts | 6 +- .../methods/browser/browser-client-host.ts | 76 ++++++++----- .../startup/main-process-ready-runtime.ts | 3 +- .../browser-client-automation-protocol.ts | 31 +++++- .../browser-client-host-protocol.ts | 15 +++ .../rpc-params-catalog.generated.ts | 2 + 16 files changed, 411 insertions(+), 57 deletions(-) create mode 100644 src/main/browser/client-hosts/commands/browser-host-command-result-staging.ts create mode 100644 src/main/runtime/browser-host/commands/browser-host-command-result-staging.ts diff --git a/src/main/browser/client-hosts/commands/browser-host-command-result-staging.ts b/src/main/browser/client-hosts/commands/browser-host-command-result-staging.ts new file mode 100644 index 00000000000..b13539501c4 --- /dev/null +++ b/src/main/browser/client-hosts/commands/browser-host-command-result-staging.ts @@ -0,0 +1,80 @@ +import type { BrowserClientHostCommandEvent } from '../../../../shared/browser/client-hosting/browser-client-host-protocol' +import { + BROWSER_CLIENT_AUTOMATION_RESULT_CHUNK_MAX_BYTES, + BROWSER_CLIENT_AUTOMATION_RESULT_MAX_BYTES, + BROWSER_CLIENT_AUTOMATION_STAGED_RESULT_MAX_BYTES, + truncateBrowserClientAutomationErrorMessage, + type BrowserClientAutomationResult as BrowserClientHostCommandResult +} from '../../../../shared/browser/client-hosting/browser-client-automation-protocol' +import { RemoteRuntimeClientError } from '../../../../shared/remote-runtime/remote-runtime-client-error' +import { browserHostCommandResultIdentity } from './browser-host-command-result-submission' + +const BROWSER_HOST_COMMAND_RESULT_TOO_LARGE_CODE = 'browser_host_command_result_too_large' + +/** Sends an automation value too big for one result frame to the host ahead of its settlement. */ +export async function stageBrowserHostCommandResult( + command: BrowserClientHostCommandEvent, + result: BrowserClientHostCommandResult, + sendChunk: (params: unknown) => Promise, + signal: AbortSignal +): Promise { + if ( + result.status !== 'completed' || + result.value === undefined || + command.command.type !== 'automation' + ) { + return result + } + const bytes = Buffer.from(JSON.stringify(result.value), 'utf8') + const hostStages = command.command.stagedResults === true + if ( + bytes.byteLength <= BROWSER_CLIENT_AUTOMATION_RESULT_CHUNK_MAX_BYTES || + (!hostStages && bytes.byteLength <= BROWSER_CLIENT_AUTOMATION_RESULT_MAX_BYTES) + ) { + return result + } + if (!hostStages || bytes.byteLength > BROWSER_CLIENT_AUTOMATION_STAGED_RESULT_MAX_BYTES) { + const limit = hostStages + ? BROWSER_CLIENT_AUTOMATION_STAGED_RESULT_MAX_BYTES + : BROWSER_CLIENT_AUTOMATION_RESULT_MAX_BYTES + return failed( + BROWSER_HOST_COMMAND_RESULT_TOO_LARGE_CODE, + `The result (${formatMegabytes(bytes.byteLength)}) is larger than the ${formatMegabytes(limit)} this server accepts from a page on this device. Use --format jpeg, a selector, or a smaller viewport${hostStages ? '' : ', or update the server'}.` + ) + } + const identity = browserHostCommandResultIdentity(command) + try { + for ( + let offset = 0; + offset < bytes.byteLength; + offset += BROWSER_CLIENT_AUTOMATION_RESULT_CHUNK_MAX_BYTES + ) { + if (signal.aborted) { + return failed('browser_client_page_command_aborted') + } + const chunk = bytes.subarray( + offset, + offset + BROWSER_CLIENT_AUTOMATION_RESULT_CHUNK_MAX_BYTES + ) + await sendChunk({ ...identity, offset, contentBase64: chunk.toString('base64') }) + } + } catch (error) { + // Keep the host or transport code (e.g. too large, unavailable) so the agent sees why. + return failed( + error instanceof RemoteRuntimeClientError + ? error.code + : 'browser_host_command_result_staging_failed', + error instanceof Error ? error.message : String(error) + ) + } + return { status: 'completed', stagedValueBytes: bytes.byteLength } +} + +function failed(errorCode: string, message?: string): BrowserClientHostCommandResult { + const errorMessage = message ? truncateBrowserClientAutomationErrorMessage(message) : undefined + return { status: 'failed', errorCode, ...(errorMessage ? { errorMessage } : {}) } +} + +function formatMegabytes(bytes: number): string { + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` +} diff --git a/src/main/browser/client-hosts/commands/browser-host-command-result-submission.ts b/src/main/browser/client-hosts/commands/browser-host-command-result-submission.ts index 3364bba5f94..a75d8cfe90b 100644 --- a/src/main/browser/client-hosts/commands/browser-host-command-result-submission.ts +++ b/src/main/browser/client-hosts/commands/browser-host-command-result-submission.ts @@ -18,21 +18,7 @@ export async function submitBrowserHostCommandResult( const result = BrowserClientHostCommandResult.parse(candidate) const response = await sendRequest( 'browser.clientHost.commandResult', - { - pageCommandProtocolVersion: command.pageCommandProtocolVersion, - ...(command.pageReconciliationProtocolVersion - ? { pageReconciliationProtocolVersion: command.pageReconciliationProtocolVersion } - : {}), - authorityRuntimeId: command.authorityRuntimeId, - authorityEpoch: command.authorityEpoch, - browserHostClientId: command.browserHostClientId, - browserHostGeneration: command.browserHostGeneration, - browserPageId: command.browserPageId, - pageHostGeneration: command.pageHostGeneration, - commandSequence: command.commandSequence, - commandId: command.commandId, - result - }, + { ...browserHostCommandResultIdentity(command), result }, timeoutMs ) if (!response.ok) { @@ -45,3 +31,21 @@ export async function submitBrowserHostCommandResult( throw new Error('Invalid browser host command result acknowledgement') } } + +/** The command identity a result, or a chunk of one, is filed under. */ +export function browserHostCommandResultIdentity(command: BrowserClientHostCommandEvent) { + return { + pageCommandProtocolVersion: command.pageCommandProtocolVersion, + ...(command.pageReconciliationProtocolVersion + ? { pageReconciliationProtocolVersion: command.pageReconciliationProtocolVersion } + : {}), + authorityRuntimeId: command.authorityRuntimeId, + authorityEpoch: command.authorityEpoch, + browserHostClientId: command.browserHostClientId, + browserHostGeneration: command.browserHostGeneration, + browserPageId: command.browserPageId, + pageHostGeneration: command.pageHostGeneration, + commandSequence: command.commandSequence, + commandId: command.commandId + } +} diff --git a/src/main/browser/client-hosts/leases/paired-runtime-browser-host-lease.ts b/src/main/browser/client-hosts/leases/paired-runtime-browser-host-lease.ts index 4d8dd54a953..852cc235165 100644 --- a/src/main/browser/client-hosts/leases/paired-runtime-browser-host-lease.ts +++ b/src/main/browser/client-hosts/leases/paired-runtime-browser-host-lease.ts @@ -6,7 +6,6 @@ import { BrowserClientAutomationResult as BrowserClientHostCommandResult, type BrowserClientAutomationResult as BrowserClientHostCommandResultType } from '../../../../shared/browser/client-hosting/browser-client-automation-protocol' -import { BROWSER_CLIENT_PAGE_METADATA_METHOD } from '../../../../shared/browser/client-hosting/browser-client-page-metadata-protocol' import { assertBrowserClientHostAttachOptions } from '../lifecycle/browser-client-host-attach-request' import { BrowserHostReconnectDelay, @@ -79,12 +78,12 @@ export class PairedRuntimeBrowserHostLease { } /** Unversioned, unlike the file channel: an older runtime answers with a reportable error. */ - sendPageMetadataRequest(params: unknown, timeoutMs: number) { + sendHostRequest(method: string, params: unknown, timeoutMs: number) { // close drops the connection synchronously. return requireBrowserHostLeaseSendRequest( this.connection?.sendRequest, 'Remote runtime browser host lease is unavailable.' - )(BROWSER_CLIENT_PAGE_METADATA_METHOD, params, timeoutMs) + )(method, params, timeoutMs) } sendFileChannelRequest(method: string, params: unknown, timeoutMs: number) { diff --git a/src/main/browser/client-hosts/lifecycle/paired-runtime-browser-client-host.ts b/src/main/browser/client-hosts/lifecycle/paired-runtime-browser-client-host.ts index d26053a63dc..937f2045a25 100644 --- a/src/main/browser/client-hosts/lifecycle/paired-runtime-browser-client-host.ts +++ b/src/main/browser/client-hosts/lifecycle/paired-runtime-browser-client-host.ts @@ -3,7 +3,12 @@ import type { BrowserClientHostCommandEvent, BrowserClientHostLeaseAuthority } from '../../../../shared/browser/client-hosting/browser-client-host-protocol' -import type { BrowserClientAutomationResult as BrowserClientHostCommandResult } from '../../../../shared/browser/client-hosting/browser-client-automation-protocol' +import { + BROWSER_CLIENT_HOST_COMMAND_RESULT_CHUNK_METHOD, + type BrowserClientAutomationResult as BrowserClientHostCommandResult +} from '../../../../shared/browser/client-hosting/browser-client-automation-protocol' +import { BROWSER_CLIENT_PAGE_METADATA_METHOD } from '../../../../shared/browser/client-hosting/browser-client-page-metadata-protocol' +import { RemoteRuntimeClientError } from '../../../../shared/remote-runtime/remote-runtime-client-error' import type { PairedRuntimeConnection } from '../../../../shared/remote-runtime/pairing/paired-runtime-connection' import type { RemoteRuntimeSubscriptionOptions } from '../../../../shared/remote-runtime/requests/remote-runtime-subscription-contract' import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope' @@ -14,6 +19,7 @@ import type { DispatcherOptions } from '../commands/browser-client-host-command-state' import { PairedRuntimeBrowserHostLease } from '../leases/paired-runtime-browser-host-lease' +import { stageBrowserHostCommandResult } from '../commands/browser-host-command-result-staging' type DispatcherLimits = Omit @@ -124,7 +130,7 @@ export class PairedRuntimeBrowserClientHost { params: unknown, timeoutMs: number ): Promise> { - return this.lease.sendPageMetadataRequest(params, timeoutMs) + return this.lease.sendHostRequest(BROWSER_CLIENT_PAGE_METADATA_METHOD, params, timeoutMs) } refreshPageInventory(): Promise { @@ -150,11 +156,28 @@ export class PairedRuntimeBrowserClientHost { this.options.onAuthority?.(authority) this.dispatcher = new BrowserClientHostCommandDispatcher({ authority, - handler: this.options.handler, + handler: async (command, signal) => + stageBrowserHostCommandResult( + command, + await this.options.handler(command, signal), + (params) => this.sendCommandResultChunk(params), + signal + ), ...this.options.dispatcher }) } + private async sendCommandResultChunk(params: unknown): Promise { + const response = await this.lease.sendHostRequest( + BROWSER_CLIENT_HOST_COMMAND_RESULT_CHUNK_METHOD, + params, + this.options.timeoutMs ?? 15_000 + ) + if (!response.ok) { + throw new RemoteRuntimeClientError(response.error.code, response.error.message) + } + } + private dispatch( command: BrowserClientHostCommandEvent ): Promise { diff --git a/src/main/browser/client-pages/execution/browser-client-page-command-failure.ts b/src/main/browser/client-pages/execution/browser-client-page-command-failure.ts index 1d73355598f..e168e9f75d0 100644 --- a/src/main/browser/client-pages/execution/browser-client-page-command-failure.ts +++ b/src/main/browser/client-pages/execution/browser-client-page-command-failure.ts @@ -1,15 +1,21 @@ -import type { BrowserClientAutomationResult as BrowserClientHostCommandResult } from '../../../../shared/browser/client-hosting/browser-client-automation-protocol' +import { + truncateBrowserClientAutomationErrorMessage, + type BrowserClientAutomationResult as BrowserClientHostCommandResult +} from '../../../../shared/browser/client-hosting/browser-client-automation-protocol' import type { BrowserExpectedUrlMismatch } from '../../../../shared/browser/client-hosting/browser-expected-url' export class BrowserClientPageCommandError extends Error { readonly expectedUrlMismatch?: BrowserExpectedUrlMismatch + /** The browser command's own message, so the host can show it with the code. */ + readonly detail?: string constructor( message: string, - options?: ErrorOptions & { expectedUrlMismatch?: BrowserExpectedUrlMismatch } + options?: ErrorOptions & { expectedUrlMismatch?: BrowserExpectedUrlMismatch; detail?: string } ) { super(message, options) this.expectedUrlMismatch = options?.expectedUrlMismatch + this.detail = options?.detail } } @@ -28,9 +34,14 @@ export function browserClientPageCommandFailure( ): BrowserClientHostCommandResult { const mismatch = error instanceof BrowserClientPageCommandError ? error.expectedUrlMismatch : undefined + const errorMessage = + error instanceof BrowserClientPageCommandError && error.detail + ? truncateBrowserClientAutomationErrorMessage(error.detail) + : undefined return { status: 'failed', errorCode: browserClientPageCommandFailureCode(error, signal), + ...(errorMessage ? { errorMessage } : {}), ...(mismatch ? { expectedUrlMismatch: mismatch } : {}) } } diff --git a/src/main/runtime/browser-host/commands/browser-host-command-ledger.test.ts b/src/main/runtime/browser-host/commands/browser-host-command-ledger.test.ts index 15a5e0933b9..1a7553cce37 100644 --- a/src/main/runtime/browser-host/commands/browser-host-command-ledger.test.ts +++ b/src/main/runtime/browser-host/commands/browser-host-command-ledger.test.ts @@ -31,6 +31,45 @@ describe('BrowserHostCommandLedger', () => { 'browser_host_command_result_page_stale' ) }) + + it('settles a chunked automation result as the value its chunks carried', async () => { + const ledger = new BrowserHostCommandLedger({ authority }) + ledger.attach(vi.fn()) + const created = ledger.issue({ + browserPageId: 'page-a', + pageHostGeneration: 1, + command: { type: 'createPage', browserProfileId: 'default', executionHostKey: 'host-key-a' } + }) + ledger.settle(resultParams(created.event, { status: 'completed' })) + const screenshot = ledger.issue({ + browserPageId: 'page-a', + pageHostGeneration: 1, + command: { + type: 'automation', + method: 'browser.screenshot', + params: {}, + stagedResults: true + } + }) + const { result: _result, ...identity } = resultParams(screenshot.event, { status: 'completed' }) + const json = Buffer.from('{"data":"iVBORw0KGgo=","format":"png"}') + ledger.stageResultChunk({ + ...identity, + offset: 0, + contentBase64: json.subarray(0, 20).toString('base64') + }) + ledger.stageResultChunk({ + ...identity, + offset: 20, + contentBase64: json.subarray(20).toString('base64') + }) + ledger.settle({ ...identity, result: { status: 'completed', stagedValueBytes: 38 } }) + + await expect(screenshot.result).resolves.toEqual({ + status: 'completed', + value: { data: 'iVBORw0KGgo=', format: 'png' } + }) + }) }) function resultParams( diff --git a/src/main/runtime/browser-host/commands/browser-host-command-ledger.ts b/src/main/runtime/browser-host/commands/browser-host-command-ledger.ts index 264abe87859..1c24827be15 100644 --- a/src/main/runtime/browser-host/commands/browser-host-command-ledger.ts +++ b/src/main/runtime/browser-host/commands/browser-host-command-ledger.ts @@ -15,6 +15,7 @@ import { type BrowserHostCommandInput, type BrowserHostCommandLedgerOptions, type BrowserHostCommandPageState, + type BrowserHostCommandResultChunkParams, type BrowserHostCommandResultParams, positiveBrowserHostCommandLimit, recordBrowserHostCommandOrder, @@ -29,6 +30,7 @@ import { replaySettledBrowserHostCommand } from './browser-host-command-result-replay' import { BrowserHostCommandResultCache } from './browser-host-command-result-cache' +import { BrowserHostCommandResultStaging } from './browser-host-command-result-staging' export class BrowserHostCommandLedger { private readonly authority: BrowserClientHostLeaseAuthority @@ -38,6 +40,7 @@ export class BrowserHostCommandLedger { private readonly maxPages: number private readonly pages = new Map() private readonly resultCache: BrowserHostCommandResultCache + private readonly staging = new BrowserHostCommandResultStaging() private delivery: ((event: BrowserClientHostCommandEvent) => void) | undefined private outstandingCommands = 0 private activePages = 0 @@ -157,6 +160,14 @@ export class BrowserHostCommandLedger { return hasOutstandingBrowserHostReconciliation(this.pages) } + stageResultChunk(params: BrowserHostCommandResultChunkParams): void { + if (this.closed) { + throw new Error('browser_host_command_ledger_closed') + } + assertBrowserHostCommandResultAuthority(this.authority, params) + this.staging.append(this.pages.get(params.browserPageId), params) + } + settle(params: BrowserHostCommandResultParams): boolean { if (this.closed) { throw new Error('browser_host_command_ledger_closed') @@ -186,7 +197,7 @@ export class BrowserHostCommandLedger { if (!record || record.event.commandId !== params.commandId || record.settled) { throw new Error('browser_host_command_result_conflict') } - const result = Object.freeze({ ...params.result }) + const result = Object.freeze({ ...this.staging.take(record, params.result) }) record.settled = result record.resolve(result) page.nextSettlementSequence += 1 @@ -230,6 +241,7 @@ export class BrowserHostCommandLedger { } this.pages.clear() this.resultCache.clear() + this.staging.clear() this.outstandingCommands = 0 this.activePages = 0 } @@ -243,6 +255,7 @@ export class BrowserHostCommandLedger { throw new Error('browser_host_command_page_stale') } for (const record of page.records.values()) { + this.staging.release(record) if (!record.settled) { record.reject(new Error('browser_host_command_outcome_unknown')) } diff --git a/src/main/runtime/browser-host/commands/browser-host-command-result-staging.ts b/src/main/runtime/browser-host/commands/browser-host-command-result-staging.ts new file mode 100644 index 00000000000..810fb5e2f9b --- /dev/null +++ b/src/main/runtime/browser-host/commands/browser-host-command-result-staging.ts @@ -0,0 +1,101 @@ +import { + BROWSER_CLIENT_AUTOMATION_STAGED_RESULT_MAX_BYTES, + type BrowserClientAutomationResult as BrowserClientHostCommandResult +} from '../../../../shared/browser/client-hosting/browser-client-automation-protocol' +import { BrowserError } from '../../../browser/browser-error' +import type { + BrowserHostCommandPageState, + BrowserHostCommandRecord, + BrowserHostCommandResultChunkParams as ChunkParams +} from './browser-host-command-state' + +// Several pages can stage at once; bound the lease's total, not just each result. +const MAX_STAGED_BYTES_PER_LEASE = 2 * BROWSER_CLIENT_AUTOMATION_STAGED_RESULT_MAX_BYTES + +type StagedResult = { chunks: Buffer[]; bytes: number } + +/** Holds chunked automation results until the desktop settles their command. */ +export class BrowserHostCommandResultStaging { + private readonly staged = new Map() + private bytes = 0 + + append( + page: BrowserHostCommandPageState | undefined, + { pageHostGeneration, commandSequence, commandId, offset, contentBase64 }: ChunkParams + ): void { + const record = + page?.generation === pageHostGeneration ? page.records.get(commandSequence) : undefined + if ( + !record || + record.event.commandId !== commandId || + record.settled || + record.event.command.type !== 'automation' || + record.event.command.stagedResults !== true + ) { + throw new Error('browser_host_command_result_chunk_conflict') + } + if (offset === 0) { + this.release(record) + } + const staged = this.staged.get(record) ?? { chunks: [], bytes: 0 } + if (offset !== staged.bytes) { + this.release(record) + throw new Error('browser_host_command_result_chunk_out_of_order') + } + const chunk = Buffer.from(contentBase64, 'base64') + if (chunk.byteLength === 0) { + this.release(record) + throw new Error('browser_host_command_result_chunk_invalid') + } + if ( + staged.bytes + chunk.byteLength > BROWSER_CLIENT_AUTOMATION_STAGED_RESULT_MAX_BYTES || + this.bytes + chunk.byteLength > MAX_STAGED_BYTES_PER_LEASE + ) { + this.release(record) + throw new BrowserError( + 'browser_host_command_result_too_large', + 'The server is already holding too many large browser results; retry, or use --format jpeg, a selector, or a smaller viewport.' + ) + } + staged.chunks.push(chunk) + staged.bytes += chunk.byteLength + this.bytes += chunk.byteLength + this.staged.set(record, staged) + } + + /** Swaps a staged marker for the value its chunks carried, and frees them either way. */ + take( + record: BrowserHostCommandRecord, + result: BrowserClientHostCommandResult + ): BrowserClientHostCommandResult { + const staged = this.staged.get(record) + this.release(record) + if (result.status !== 'completed' || result.stagedValueBytes === undefined) { + return result + } + if (staged?.bytes !== result.stagedValueBytes) { + return { status: 'failed', errorCode: 'browser_host_command_result_incomplete' } + } + try { + return { + status: 'completed', + value: JSON.parse(Buffer.concat(staged.chunks).toString('utf8')) + } + } catch { + return { status: 'failed', errorCode: 'browser_host_command_result_invalid' } + } + } + + release(record: BrowserHostCommandRecord): void { + const staged = this.staged.get(record) + if (staged) { + this.bytes -= staged.bytes + this.staged.delete(record) + } + } + + clear(): void { + this.staged.clear() + this.bytes = 0 + } +} diff --git a/src/main/runtime/browser-host/commands/browser-host-command-state.ts b/src/main/runtime/browser-host/commands/browser-host-command-state.ts index c29f9179166..d190f05e3f2 100644 --- a/src/main/runtime/browser-host/commands/browser-host-command-state.ts +++ b/src/main/runtime/browser-host/commands/browser-host-command-state.ts @@ -30,6 +30,14 @@ export type BrowserHostCommandResultParams = Omit< result: BrowserClientHostCommandResult } +export type BrowserHostCommandResultChunkParams = Omit< + BrowserClientHostCommandEvent, + 'type' | 'command' +> & { + offset: number + contentBase64: string +} + export type BrowserHostCommandRecord = { event: BrowserClientHostCommandEvent resultAdmission: BrowserHostCommandResultAdmission @@ -163,7 +171,7 @@ export function sameBrowserHostCommandResult( export function assertBrowserHostCommandResultAuthority( authority: BrowserClientHostLeaseAuthority, - params: BrowserHostCommandResultParams + params: Omit ): void { if ( params.pageCommandProtocolVersion !== authority.pageCommandProtocolVersion || diff --git a/src/main/runtime/browser-host/leases/browser-host-lease-registry.ts b/src/main/runtime/browser-host/leases/browser-host-lease-registry.ts index a8d5ebd1723..e1373a6ce3f 100644 --- a/src/main/runtime/browser-host/leases/browser-host-lease-registry.ts +++ b/src/main/runtime/browser-host/leases/browser-host-lease-registry.ts @@ -263,14 +263,17 @@ export class BrowserHostLeaseRegistry { identity: BrowserHostCommandResultIdentity, params: BrowserHostCommandResultParams ): boolean { - const state = this.requireLeaseState(identity) - const ledger = requireBrowserHostCommandResultLedger(state, identity) + const ledger = this.requireCommandResultLedger(identity) if (!ledger.isUnplacedPageResult(params)) { this.requireClientPage(params) } return ledger.settle(params) } + requireCommandResultLedger(identity: BrowserHostCommandResultIdentity) { + return requireBrowserHostCommandResultLedger(this.requireLeaseState(identity), identity) + } + getPlacement(browserPageId: string): RuntimeBrowserPlacement | undefined { return this.pagePlacements.getPlacement(browserPageId) } diff --git a/src/main/runtime/browser/client-pages/runtime-browser-client-automation.ts b/src/main/runtime/browser/client-pages/runtime-browser-client-automation.ts index bd824e02877..89f9f4c0e9f 100644 --- a/src/main/runtime/browser/client-pages/runtime-browser-client-automation.ts +++ b/src/main/runtime/browser/client-pages/runtime-browser-client-automation.ts @@ -1,4 +1,5 @@ import { BrowserClientAutomationMethod } from '../../../../shared/browser/client-hosting/browser-client-automation-protocol' +import { BrowserError } from '../../../browser/browser-error' import { browserExpectedUrlMismatchError } from '../../../browser/cdp/input/browser-expected-page-url' import type { BrowserHostLeaseRegistry } from '../../browser-host/leases/browser-host-lease-registry' import type { RuntimeBrowserPageRegistry } from './runtime-browser-page-registry' @@ -36,14 +37,15 @@ export async function routeRuntimeBrowserClientAutomation(options: { method: method.data, params: options.params, // A person's link or relayed input must not light the desktop page as agent-driven. - agent: isAgentRpcCaller(options.caller) + agent: isAgentRpcCaller(options.caller), + stagedResults: true } ) const result = await issued.result if (result.status === 'failed') { throw result.expectedUrlMismatch ? browserExpectedUrlMismatchError(result.expectedUrlMismatch) - : new Error(result.errorCode) + : new BrowserError(result.errorCode, result.errorMessage ?? result.errorCode) } return { handled: true, result: result.value } } diff --git a/src/main/runtime/rpc/methods/browser/browser-client-host.ts b/src/main/runtime/rpc/methods/browser/browser-client-host.ts index 87b96c1a590..7d5d3d9d409 100644 --- a/src/main/runtime/rpc/methods/browser/browser-client-host.ts +++ b/src/main/runtime/rpc/methods/browser/browser-client-host.ts @@ -11,8 +11,10 @@ import { import { BROWSER_CLIENT_HOST_AUTHORITY_MISMATCH_CODE, BrowserClientHostAttachParams, + BrowserClientHostCommandResultChunkParams, BrowserClientHostCommandResultParams } from '../../../../../shared/browser/client-hosting/browser-client-host-protocol' +import { BROWSER_CLIENT_HOST_COMMAND_RESULT_CHUNK_METHOD } from '../../../../../shared/browser/client-hosting/browser-client-automation-protocol' import { BrowserError } from '../../../../browser/browser-error' import { getBrowserHostLeaseRegistry } from '../../../browser-host/leases/browser-host-lease-registry-instance' import { getRuntimeBrowserPageRegistry } from '../../../browser/client-pages/runtime-browser-page-registry' @@ -20,7 +22,7 @@ import { adoptRuntimeBrowserClientPagesFromInventory } from '../../../browser/cl import { recoverUnavailableRuntimeBrowserClientPages } from '../../../browser/client-pages/runtime-browser-client-page-recovery' import { releaseRuntimeBrowserClientPageRecord } from '../../../browser/client-pages/runtime-browser-client-page-release' import { getBrowserHostRequestRegistry } from '../../../browser-host/leases/browser-host-request-registry' -import { defineMethod, defineStreamingMethod } from '../../core' +import { defineMethod, defineStreamingMethod, type RpcContext } from '../../core' export const BROWSER_CLIENT_HOST_METHODS = [ defineStreamingMethod({ @@ -169,35 +171,25 @@ export const BROWSER_CLIENT_HOST_METHODS = [ name: 'browser.clientHost.commandResult', permission: 'workspace', params: BrowserClientHostCommandResultParams, - handler: ( - params, - { runtime, pairedDeviceId, connectionId, clientKind, clientCapabilities } - ) => { - if (clientKind !== 'runtime' || !pairedDeviceId || !connectionId) { - throw new Error('authenticated_browser_client_host_required') - } - if (!clientCapabilities?.includes(BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY)) { - throw new Error('browser_client_host_capability_required') - } - if (params.authorityRuntimeId !== runtime.getRuntimeId()) { - throw new BrowserError( - BROWSER_CLIENT_HOST_AUTHORITY_MISMATCH_CODE, - BROWSER_CLIENT_HOST_AUTHORITY_MISMATCH_CODE - ) - } - const accepted = getBrowserHostLeaseRegistry(runtime).settleClientPageCommand( - { - authorityEpoch: params.authorityEpoch, - browserHostClientId: params.browserHostClientId, - browserHostGeneration: params.browserHostGeneration, - pairedDeviceId, - connectionId - }, + handler: (params, context) => { + const accepted = getBrowserHostLeaseRegistry(context.runtime).settleClientPageCommand( + requireCommandResultIdentity(params, context), params ) return { accepted } } }), + defineMethod({ + name: BROWSER_CLIENT_HOST_COMMAND_RESULT_CHUNK_METHOD, + permission: 'workspace', + params: BrowserClientHostCommandResultChunkParams, + handler: (params, context) => { + getBrowserHostLeaseRegistry(context.runtime) + .requireCommandResultLedger(requireCommandResultIdentity(params, context)) + .stageResultChunk(params) + return { accepted: true } + } + }), defineMethod({ name: 'browser.clientHost.requestResult', permission: 'workspace', @@ -256,3 +248,37 @@ export const BROWSER_CLIENT_HOST_METHODS = [ } }) ] + +function requireCommandResultIdentity( + params: { + authorityRuntimeId: string + authorityEpoch: string + browserHostClientId: string + browserHostGeneration: number + }, + context: Pick< + RpcContext, + 'runtime' | 'pairedDeviceId' | 'connectionId' | 'clientKind' | 'clientCapabilities' + > +) { + const { runtime, pairedDeviceId, connectionId, clientKind, clientCapabilities } = context + if (clientKind !== 'runtime' || !pairedDeviceId || !connectionId) { + throw new Error('authenticated_browser_client_host_required') + } + if (!clientCapabilities?.includes(BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY)) { + throw new Error('browser_client_host_capability_required') + } + if (params.authorityRuntimeId !== runtime.getRuntimeId()) { + throw new BrowserError( + BROWSER_CLIENT_HOST_AUTHORITY_MISMATCH_CODE, + BROWSER_CLIENT_HOST_AUTHORITY_MISMATCH_CODE + ) + } + return { + authorityEpoch: params.authorityEpoch, + browserHostClientId: params.browserHostClientId, + browserHostGeneration: params.browserHostGeneration, + pairedDeviceId, + connectionId + } +} diff --git a/src/main/startup/main-process-ready-runtime.ts b/src/main/startup/main-process-ready-runtime.ts index 4be3bb1912d..591411a8cb3 100644 --- a/src/main/startup/main-process-ready-runtime.ts +++ b/src/main/startup/main-process-ready-runtime.ts @@ -93,7 +93,8 @@ export async function initializeReadyRuntimeServices(): Promise { if (!response.ok) { const mismatch = BrowserExpectedUrlMismatch.safeParse(response.error.data) throw new BrowserClientPageCommandError(response.error.code, { - expectedUrlMismatch: mismatch.success ? mismatch.data : undefined + expectedUrlMismatch: mismatch.success ? mismatch.data : undefined, + detail: response.error.message }) } return response.result diff --git a/src/shared/browser/client-hosting/browser-client-automation-protocol.ts b/src/shared/browser/client-hosting/browser-client-automation-protocol.ts index 0dddb155b88..3d001333311 100644 --- a/src/shared/browser/client-hosting/browser-client-automation-protocol.ts +++ b/src/shared/browser/client-hosting/browser-client-automation-protocol.ts @@ -5,7 +5,14 @@ export const BROWSER_CLIENT_AUTOMATION_HOST_CAPABILITY = 'automation-v1' as cons // Older hosts re-parse params and would drop expectUrl, so commands carrying it need this. export const BROWSER_CLIENT_EXPECT_URL_HOST_CAPABILITY = 'expect-url-v1' as const export const BROWSER_CLIENT_AUTOMATION_PARAMS_MAX_BYTES = 256 * 1024 +// Why: sized to a host's 1 MiB inbound frame, which sealing inflates by 4/3. export const BROWSER_CLIENT_AUTOMATION_RESULT_MAX_BYTES = 768 * 1024 +// Larger results travel ahead of their settlement in chunks; base64 keeps each chunk inside one frame. +export const BROWSER_CLIENT_AUTOMATION_RESULT_CHUNK_MAX_BYTES = 512 * 1024 +export const BROWSER_CLIENT_AUTOMATION_STAGED_RESULT_MAX_BYTES = 64 * 1024 * 1024 +export const BROWSER_CLIENT_HOST_COMMAND_RESULT_CHUNK_METHOD = + 'browser.clientHost.commandResultChunk' as const +const BROWSER_CLIENT_AUTOMATION_ERROR_MESSAGE_MAX_LENGTH = 4096 export const BROWSER_CLIENT_AUTOMATION_METHODS = [ 'browser.snapshot', @@ -105,23 +112,43 @@ export const BrowserClientAutomationCommand = z.object({ method: BrowserClientAutomationMethod, params: BrowserClientAutomationParams, /** The host's caller was an agent's CLI; older hosts omit it and every command counts as one. */ - agent: z.boolean().optional() + agent: z.boolean().optional(), + /** The host assembles results sent as chunks; older hosts omit it and refuse results over one frame. */ + stagedResults: z.literal(true).optional() }) export type BrowserClientAutomationCommand = z.infer export const BrowserClientAutomationResult = z.discriminatedUnion('status', [ z.object({ status: z.literal('completed'), - value: BrowserClientAutomationResultValue.optional() + value: BrowserClientAutomationResultValue.optional(), + /** The value's JSON byte length, already sent as chunks; replaces `value`. */ + stagedValueBytes: z + .number() + .int() + .positive() + .max(BROWSER_CLIENT_AUTOMATION_STAGED_RESULT_MAX_BYTES) + .optional() }), z.object({ status: z.literal('failed'), errorCode: z.string().min(1).max(256), + errorMessage: z + .string() + .min(1) + .max(BROWSER_CLIENT_AUTOMATION_ERROR_MESSAGE_MAX_LENGTH) + .optional(), expectedUrlMismatch: BrowserExpectedUrlMismatch.optional() }) ]) export type BrowserClientAutomationResult = z.infer +export function truncateBrowserClientAutomationErrorMessage(message: string): string | undefined { + return message.length > 0 + ? message.slice(0, BROWSER_CLIENT_AUTOMATION_ERROR_MESSAGE_MAX_LENGTH) + : undefined +} + function enforceJsonByteBudget( value: unknown, maxBytes: number, diff --git a/src/shared/browser/client-hosting/browser-client-host-protocol.ts b/src/shared/browser/client-hosting/browser-client-host-protocol.ts index fcc27afaa90..bf54dce1863 100644 --- a/src/shared/browser/client-hosting/browser-client-host-protocol.ts +++ b/src/shared/browser/client-hosting/browser-client-host-protocol.ts @@ -5,6 +5,8 @@ import { import { z } from 'zod' import { BrowserNetworkExecutionHost } from './browser-network-execution-host' import { + BROWSER_CLIENT_AUTOMATION_RESULT_CHUNK_MAX_BYTES, + BROWSER_CLIENT_AUTOMATION_STAGED_RESULT_MAX_BYTES, BrowserClientAutomationCommand, BrowserClientAutomationResult as BrowserClientHostCommandResult } from './browser-client-automation-protocol' @@ -296,6 +298,19 @@ export const BrowserClientHostCommandResultParams = BrowserClientPageCommandAuth result: BrowserClientHostCommandResult }) +export const BrowserClientHostCommandResultChunkParams = BrowserClientPageCommandAuthority.extend({ + offset: z.number().int().nonnegative().max(BROWSER_CLIENT_AUTOMATION_STAGED_RESULT_MAX_BYTES), + contentBase64: z + .string() + .min(1) + .max(Math.ceil(BROWSER_CLIENT_AUTOMATION_RESULT_CHUNK_MAX_BYTES / 3) * 4) + .regex(/^[A-Za-z0-9+/]*={0,2}$/) +}) + +export type BrowserClientHostCommandResultChunkParams = z.infer< + typeof BrowserClientHostCommandResultChunkParams +> + export const BrowserClientHostCommandResultAck = z.object({ accepted: z.boolean() }) diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 0eb5af9d568..6d7ac6d4928 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -16,6 +16,7 @@ import { } from '../browser/client-hosting/browser-client-file-channel-protocol' import { BrowserClientHostAttachParams, + BrowserClientHostCommandResultChunkParams, BrowserClientHostCommandResultParams, BrowserNetworkTunnelAttachParams } from '../browser/client-hosting/browser-client-host-protocol' @@ -926,6 +927,7 @@ export const RPC_PARAMS_BY_METHOD = { 'browser.click': Element, 'browser.clientHost.attach': BrowserClientHostAttachParams, 'browser.clientHost.commandResult': BrowserClientHostCommandResultParams, + 'browser.clientHost.commandResultChunk': BrowserClientHostCommandResultChunkParams, 'browser.clientHost.fileChannel.abort': BrowserClientFileChannelAbortParams, 'browser.clientHost.fileChannel.read': BrowserClientFileChannelReadParams, 'browser.clientHost.fileChannel.write': BrowserClientFileChannelWriteParams, From 404ab9fccfac51eae6db56248648c11f11066d7c Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 13 Nov 2026 02:50:19 -0800 Subject: [PATCH 10/55] test: remove upstream projections and duplicate submodule guard (#41974) Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../scripts/ci/sharding/ci-shard-timings.json | 1 - .../submodules/status-submodule.test.ts | 33 ------------- .../upstream/status-upstream-ref.test.ts | 43 ----------------- .../source-control/upstream/upstream.test.ts | 34 ------------- .../git-config-snapshot-runner.test.ts | 24 ---------- .../git-resolution-config-snapshot.test.ts | 20 +------- .../diff/git-diff-transport-budget.test.ts | 48 +------------------ 7 files changed, 3 insertions(+), 200 deletions(-) delete mode 100644 src/main/git/source-control/upstream/upstream.test.ts diff --git a/config/scripts/ci/sharding/ci-shard-timings.json b/config/scripts/ci/sharding/ci-shard-timings.json index 282a02f98c0..fcbafaeab0f 100644 --- a/config/scripts/ci/sharding/ci-shard-timings.json +++ b/config/scripts/ci/sharding/ci-shard-timings.json @@ -1745,7 +1745,6 @@ "src/main/git/source-control/submodules/status-submodule.test.ts": 306, "src/main/git/source-control/upstream/status-upstream-ref.test.ts": 52, "src/main/git/source-control/upstream/upstream-deferred-fork-remote-real.test.ts": 319, - "src/main/git/source-control/upstream/upstream.test.ts": 406, "src/main/git/worktrees/creation/worktree-add-local-base-refresh.test.ts": 362, "src/main/git/worktrees/creation/worktree-add-pending-removal.test.ts": 346, "src/main/git/worktrees/creation/worktree-add-remote-branch-upstream-real-git.test.ts": 786, diff --git a/src/main/git/source-control/submodules/status-submodule.test.ts b/src/main/git/source-control/submodules/status-submodule.test.ts index 8dd28c57de6..47d29365545 100644 --- a/src/main/git/source-control/submodules/status-submodule.test.ts +++ b/src/main/git/source-control/submodules/status-submodule.test.ts @@ -67,8 +67,6 @@ import { clearSubmodulePathsCacheForTests } from './submodule-paths' import { getDiff } from '../diffs/file-diff' describe('submodule diff routing', () => { - const OLD_OID = 'a'.repeat(40) - const NEW_OID = 'b'.repeat(40) const PARENT = path.resolve('/repo-sm') beforeEach(() => { @@ -80,24 +78,6 @@ describe('submodule diff routing', () => { statMock.mockReset() existsSyncMock.mockReset() statMock.mockResolvedValue({ isFile: () => true, size: 12 }) - gitExecFileAsyncMock.mockImplementation((args: string[], options?: { cwd?: string }) => { - if (args[0] === 'config' && args.includes('.gitmodules')) { - // Declare the submodule only in its parent so recursion terminates. - return Promise.resolve({ - stdout: options?.cwd === PARENT ? 'submodule.flutter_mine.path\nflutter_mine\0' : '' - }) - } - if (args[0] === 'ls-files') { - return Promise.resolve({ stdout: `160000 ${OLD_OID} 0\tflutter_mine\n` }) - } - if (args[0] === 'ls-tree') { - return Promise.resolve({ stdout: `160000 commit ${OLD_OID}\tflutter_mine\n` }) - } - if (args[0] === 'rev-parse') { - return Promise.resolve({ stdout: `${NEW_OID}\n` }) - } - return Promise.resolve({ stdout: '' }) - }) }) it('rejects inner submodule diffs whose .gitmodules path escapes the worktree', async () => { @@ -112,17 +92,4 @@ describe('submodule diff routing', () => { await expect(getDiff(PARENT, '../evil/secret.txt', false)).rejects.toThrow('Access denied') }) - - it('rejects gitlink pointer diffs whose .gitmodules path escapes the worktree', async () => { - gitExecFileAsyncMock.mockImplementation((args: string[], options?: { cwd?: string }) => { - if (args[0] === 'config' && args.includes('.gitmodules')) { - return Promise.resolve({ - stdout: options?.cwd === PARENT ? 'submodule.evil.path\n../evil\0' : '' - }) - } - return Promise.resolve({ stdout: '' }) - }) - - await expect(getDiff(PARENT, '../evil', false)).rejects.toThrow('Access denied') - }) }) diff --git a/src/main/git/source-control/upstream/status-upstream-ref.test.ts b/src/main/git/source-control/upstream/status-upstream-ref.test.ts index 85071b37cc0..5b4b05dcc34 100644 --- a/src/main/git/source-control/upstream/status-upstream-ref.test.ts +++ b/src/main/git/source-control/upstream/status-upstream-ref.test.ts @@ -4,49 +4,6 @@ import { resolveGitStatusUpstreamRef } from './status-upstream-ref' const signal = (): AbortSignal => new AbortController().signal describe('resolveGitStatusUpstreamRef', () => { - it('preserves the exact namespace for a slash-containing remote name', async () => { - const execGit = vi.fn().mockResolvedValue({ - stdout: - 'refs/heads/feature/main\0refs/remotes/team/fork/feature/main\0team/fork/feature/main\n' - }) - - await expect( - resolveGitStatusUpstreamRef( - execGit, - '/repo', - 'refs/heads/feature/main', - 'team/fork/feature/main', - signal() - ) - ).resolves.toBe('refs/remotes/team/fork/feature/main') - expect(execGit).toHaveBeenCalledWith( - [ - 'for-each-ref', - '--format=%(refname)%00%(upstream)%00%(upstream:short)', - '--count=1', - 'refs/heads/feature/main' - ], - '/repo', - expect.any(AbortSignal) - ) - }) - - it('returns a slash-named local upstream for the binding validator to reject', async () => { - const execGit = vi.fn().mockResolvedValue({ - stdout: 'refs/heads/feature/topic\0refs/heads/feature/base\0feature/base\n' - }) - - await expect( - resolveGitStatusUpstreamRef( - execGit, - '/repo', - 'refs/heads/feature/topic', - 'feature/base', - signal() - ) - ).resolves.toBe('refs/heads/feature/base') - }) - it('resolves an accepted effective upstream override explicitly', async () => { const execGit = vi .fn() diff --git a/src/main/git/source-control/upstream/upstream.test.ts b/src/main/git/source-control/upstream/upstream.test.ts deleted file mode 100644 index 2f7ec01d798..00000000000 --- a/src/main/git/source-control/upstream/upstream.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { gitExecFileAsyncMock } = vi.hoisted(() => ({ - gitExecFileAsyncMock: vi.fn() -})) - -vi.mock('../../command-runner/git-exec-file', async (importOriginal) => ({ - ...(await importOriginal>()), - gitExecFileAsync: gitExecFileAsyncMock -})) - -import { getUpstreamStatus, invalidateGitUpstreamStatusReads } from './upstream' - -describe('getUpstreamStatus', () => { - beforeEach(() => { - gitExecFileAsyncMock.mockReset() - invalidateGitUpstreamStatusReads() - }) - - it('does not hide git failures while checking an explicit publish target', async () => { - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '', stderr: '' }).mockRejectedValueOnce( - Object.assign(new Error('fatal: not a git repository'), { - stderr: 'fatal: not a git repository' - }) - ) - - await expect( - getUpstreamStatus('/repo', { - remoteName: 'fork', - branchName: 'feature/fix' - }) - ).rejects.toThrow('fatal: not a git repository') - }) -}) diff --git a/src/shared/git/commands/git-config-snapshot-runner.test.ts b/src/shared/git/commands/git-config-snapshot-runner.test.ts index ef8ecf687a4..707f73353b2 100644 --- a/src/shared/git/commands/git-config-snapshot-runner.test.ts +++ b/src/shared/git/commands/git-config-snapshot-runner.test.ts @@ -10,17 +10,6 @@ function listSnapshot(records: string[]): string { type GitCommandRunner = Parameters[0] -function countCalls( - runGit: ReturnType>, - expectedArgs: string[] -): number { - return runGit.mock.calls.filter(([args]) => { - return ( - args.length === expectedArgs.length && args.every((arg, index) => arg === expectedArgs[index]) - ) - }).length -} - describe('createGitConfigSnapshotRunner', () => { it('case-folds the requested section and key', async () => { const runGit = vi.fn(async () => ({ @@ -76,7 +65,6 @@ describe('createGitConfigSnapshotRunner', () => { }) it('rejects an absent key from a loaded snapshot instead of returning empty success', async () => { - // Missing keys must reject like config --get, without respawning the command this snapshot coalesces. const runGit = vi.fn(async () => ({ stdout: listSnapshot(['branch.main.remote\norigin']) })) @@ -85,12 +73,6 @@ describe('createGitConfigSnapshotRunner', () => { await expect(runner(['config', '--get', 'branch.main.merge'])).rejects.toThrow( GitConfigSnapshotKeyNotFoundError ) - expect(countCalls(runGit, ['config', '--list', '-z'])).toBe(1) - expect( - runGit.mock.calls.filter(([args]) => { - return args[0] === 'config' && args[1] === '--get' - }) - ).toHaveLength(0) }) it('falls back to real config --get after snapshot fetch failure', async () => { @@ -108,11 +90,5 @@ describe('createGitConfigSnapshotRunner', () => { await expect(runner(['config', '--get', 'branch.main.remote'])).resolves.toEqual({ stdout: 'real:branch.main.remote' }) - await expect(runner(['config', '--get', 'branch.main.merge'])).resolves.toEqual({ - stdout: 'real:branch.main.merge' - }) - - expect(countCalls(runGit, ['config', '--list', '-z'])).toBe(1) - expect(runGit.mock.calls.filter(([args]) => args[1] === '--get')).toHaveLength(2) }) }) diff --git a/src/shared/git/compatibility/git-resolution-config-snapshot.test.ts b/src/shared/git/compatibility/git-resolution-config-snapshot.test.ts index fd433f1ddd3..61dae350b3d 100644 --- a/src/shared/git/compatibility/git-resolution-config-snapshot.test.ts +++ b/src/shared/git/compatibility/git-resolution-config-snapshot.test.ts @@ -1,27 +1,14 @@ import { describe, expect, it, vi } from 'vitest' import { resolveConfiguredGitPushTarget } from '../remotes/git-push-target-resolution' -function createRunner(config = new Map(), snapshotFails = false) { +function createRunner(config = new Map()) { return vi.fn(async (args: string[]) => { if (args[0] === 'symbolic-ref') { return { stdout: 'refs/heads/feature\n' } } if (args[0] === 'config' && args[1] === '--list') { - if (snapshotFails) { - throw new Error('snapshot unavailable') - } return { stdout: Array.from(config, ([key, value]) => `${key}\n${value}\0`).join('') } } - if (args[0] === 'config' && args[1] === '--get') { - const value = config.get((args[2] ?? '').toLowerCase()) - if (value === undefined) { - throw new Error('missing config key') - } - return { stdout: `${value}\n` } - } - if (args[0] === 'rev-parse') { - throw new Error(args.includes('HEAD@{u}') ? 'fatal: no upstream configured' : 'missing ref') - } throw new Error(`unexpected Git command: ${args.join(' ')}`) }) } @@ -43,10 +30,5 @@ describe('resolution config snapshots', () => { remote: 'other-fork', refspec: 'HEAD:feature' }) - - expect(runGit.mock.calls.filter(([args]) => args[0] === 'config')).toEqual([ - [['config', '--list', '-z']], - [['config', '--list', '-z']] - ]) }) }) diff --git a/src/shared/git/diff/git-diff-transport-budget.test.ts b/src/shared/git/diff/git-diff-transport-budget.test.ts index 6ff43690fa1..29c4b980be2 100644 --- a/src/shared/git/diff/git-diff-transport-budget.test.ts +++ b/src/shared/git/diff/git-diff-transport-budget.test.ts @@ -1,15 +1,8 @@ // JSON escaping can expand controls sixfold while NUL-only sniffing treats them as text; pin each budget branch to JSON.stringify. import { describe, expect, it } from 'vitest' import type { GitDiffResult } from './git-diff-compare-types' -import { REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES } from '../../remote-runtime/remote-runtime-memory-limits' -import { - assertGitDiffWithinTransportBudget, - gitDiffExceedsTransportBudget -} from './git-diff-transport-budget' -import { - REMOTE_RPC_MAX_CONTENT_BYTES, - remoteRpcContentBudget -} from '../../remote-rpc-content-budget' +import { assertGitDiffWithinTransportBudget } from './git-diff-transport-budget' +import { REMOTE_RPC_MAX_CONTENT_BYTES } from '../../remote-rpc-content-budget' const BUDGET = REMOTE_RPC_MAX_CONTENT_BYTES @@ -83,41 +76,4 @@ describe('gitDiffExceedsTransportBudget', () => { expect(referenceResultBytes(overBudget)).toBe(BUDGET + 1) expect(budgetError(overBudget).code).toBe('diff_too_large') }) - - it('rejects additive relay metadata beyond the result budget', () => { - const skewed = { - ...textDiff(''), - futureMetadata: 'x'.repeat(BUDGET) - } as unknown as GitDiffResult - - expect(gitDiffExceedsTransportBudget(skewed, BUDGET)).toBe(true) - expect(budgetError(skewed).code).toBe('diff_too_large') - }) -}) - -// Why: the invariant the cap rests on — a diff that exactly fills the content budget must still -// serialize inside the outbound JSON limit once wrapped in an RPC reply. A base64-only version of -// this passes trivially; the newline and control-char fixtures are the load-bearing ones. -describe('envelope ceiling', () => { - function replyBytes(result: GitDiffResult, requestId = 'req_0123456789abcdef'): number { - return Buffer.byteLength( - JSON.stringify({ - id: requestId, - ok: true, - result, - _meta: { runtimeId: '00000000-0000-4000-8000-000000000000' } - }), - 'utf8' - ) - } - - it('charges an escape-dense request id instead of overflowing the fixed reserve', () => { - const requestId = '\u0001'.repeat(8_192) - const budget = remoteRpcContentBudget(requestId) - - expect(budget).toBeLessThan(BUDGET) - expect(replyBytes(binaryDiffOfJsonBytes('a', budget), requestId)).toBeLessThanOrEqual( - REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES - ) - }) }) From 94e84e4044d00d6f863e7766f7520bbb37fcf72a Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 13 Nov 2026 03:04:34 -0800 Subject: [PATCH 11/55] test: remove RPC work budgets and push lease replays (#41981) Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../storage-lease.test.mjs | 19 +--------- .../src/delivery/durable-push-claim.test.ts | 18 +-------- .../src/delivery/durable-push-store.test.ts | 34 ----------------- src/shared/remote-rpc-content-budget.test.ts | 38 +------------------ 4 files changed, 3 insertions(+), 106 deletions(-) diff --git a/.github/actions/cloud-sql-rollout-lease/storage-lease.test.mjs b/.github/actions/cloud-sql-rollout-lease/storage-lease.test.mjs index 7463c8ee0b6..d70941ace81 100644 --- a/.github/actions/cloud-sql-rollout-lease/storage-lease.test.mjs +++ b/.github/actions/cloud-sql-rollout-lease/storage-lease.test.mjs @@ -11,18 +11,13 @@ const BUCKET = 'onorca-cloud-terraform-state' const OBJECT = 'terraform/state/cloud-sql-rollout/production.lock' const NOW = 1_756_000_000_000 -/** Model real GCS generation increments, ifGenerationMatch and 412 conflicts; faults inject failures. */ -function fakeStorage({ object = null, faults = [] } = {}) { +function fakeStorage({ object = null } = {}) { const state = { object, requests: [] } const fetcher = async (rawUrl, init = {}) => { const url = new URL(rawUrl) const method = init.method ?? 'GET' const record = { method, url, path: url.pathname, search: url.searchParams } state.requests.push(record) - const fault = faults.find((candidate) => candidate.when(record)) - if (fault) { - return json(fault.status, fault.body ?? {}) - } if (url.pathname.startsWith('/upload/')) { const want = url.searchParams.get('ifGenerationMatch') @@ -114,13 +109,6 @@ test('refuses a live lease held by another run and never writes', async () => { .catch((thrown) => thrown) assert.ok(error instanceof LeaseConflict) - assert.equal(error.holder.repository, 'stablyai/orca') - assert.equal(error.holder.run_url, 'https://github.com/stablyai/orca/actions/runs/9001') - assert.equal( - storage.state.requests.filter((request) => request.method !== 'GET').length, - 0, - 'a foreign live lease must not be written' - ) assert.equal(storage.state.object.generation, '1500') }) @@ -164,9 +152,6 @@ test('takes over an expired lease and warns naming the stale holder', async () = assert.equal(claim.state, 'takeover') assert.equal(warnings.length, 1) - assert.match(warnings[0], /stablyai\/orca/) - assert.match(warnings[0], /Deploy Relay Production Capacity/) - assert.match(warnings[0], /actions\/runs\/9001/) const upload = storage.state.requests.find((request) => request.path.startsWith('/upload/')) assert.equal(upload.search.get('ifGenerationMatch'), '1500') assert.equal(storage.state.object.body.holder_key, HOLDER.holderKey) @@ -180,8 +165,6 @@ test('releases with a generation match and leaves the object gone', async () => const released = await lease.release(HOLDER.holderKey) assert.deepEqual(released, { released: true, generation: claim.generation }) - const remove = storage.state.requests.find((request) => request.method === 'DELETE') - assert.equal(remove.search.get('ifGenerationMatch'), claim.generation) assert.equal(storage.state.object, null) }) diff --git a/cloud/apps/push/src/delivery/durable-push-claim.test.ts b/cloud/apps/push/src/delivery/durable-push-claim.test.ts index 93f65135504..86f20726234 100644 --- a/cloud/apps/push/src/delivery/durable-push-claim.test.ts +++ b/cloud/apps/push/src/delivery/durable-push-claim.test.ts @@ -1,6 +1,6 @@ import { afterEach, expect, it } from 'vitest' import type { CloudDatabase as PushDatabase } from '@orca-cloud/cloud-database/cloud-database' -import { DELIVERY_LEASE_MS, DurablePushStore } from './durable-push-store.js' +import { DurablePushStore } from './durable-push-store.js' import { cleanupDurablePushFixtures, @@ -139,20 +139,4 @@ it.skipIf(!durablePushTestDatabaseUrl)( } ) -// Only the TTL term keeps the candidate scan off an unpruned backlog; expires_at is not indexed first. -it('frees a device held by a crashed worker once its lease lapses', async () => { - const { db, store, clock, advance } = await fixture() - await store.accept('host', 'phone', notification(1)) - advance(1) - await store.accept('host', 'phone', notification(2)) - const crashed = (await new DurablePushStore(db, clock).claim())! - advance(DELIVERY_LEASE_MS - 1) - expect(await store.claim()).toBeNull() - advance(1) - const reclaimed = (await store.claim())! - expect(reclaimed.id).toBe(crashed.id) - expect(reclaimed.attempts).toBe(2) - await store.finish(reclaimed) - expect((await store.claim())?.notification.notificationSeq).toBe(2) -}) diff --git a/cloud/apps/push/src/delivery/durable-push-store.test.ts b/cloud/apps/push/src/delivery/durable-push-store.test.ts index 9be0e32ec8f..8caac39919d 100644 --- a/cloud/apps/push/src/delivery/durable-push-store.test.ts +++ b/cloud/apps/push/src/delivery/durable-push-store.test.ts @@ -161,37 +161,3 @@ it('does not resurrect an in-flight alert after a dismissal and transient provid expect(await store.pendingCount('phone')).toBe(0) }) -it('fences late renew and finish after an expired claim is dismissed', async () => { - const { db, store, advance } = await fixture() - const alert = notification(1) - await store.accept('host', 'phone', alert) - const stale = (await store.claim())! - advance(DELIVERY_LEASE_MS) - await store.accept('host', 'phone', { - ...notification(2, 'dismiss'), - notificationId: alert.notificationId - }) - const read = async () => - ( - await db.query( - 'SELECT state, payload_json, lease_until FROM push_delivery_batches WHERE batch_id = ?', - [stale.id] - ) - )[0] - expect(await read()).toBeUndefined() - await store.renew(stale) - await store.finish(stale, 1000) - await store.finish(stale) - expect(await read()).toBeUndefined() - const dismissal = (await store.claim())! - expect(dismissal.notification.kind).toBe('dismiss') - await store.finish(dismissal) - await store.accept('host', 'phone', notification(3)) - const fresh = (await store.claim())! - await store.finish(fresh, 1000) - advance(1000) - const retry = (await store.claim())! - expect(retry.id).toBe(fresh.id) - await store.finish(retry) - expect(await store.claim()).toBeNull() -}) diff --git a/src/shared/remote-rpc-content-budget.test.ts b/src/shared/remote-rpc-content-budget.test.ts index 69ff77d52c5..04f7a047ba8 100644 --- a/src/shared/remote-rpc-content-budget.test.ts +++ b/src/shared/remote-rpc-content-budget.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES } from './remote-runtime/remote-runtime-memory-limits' import { REMOTE_RPC_MAX_CONTENT_BYTES, @@ -15,42 +15,6 @@ describe('remoteRpcContentBudget', () => { expect(remoteRpcResultExceedsContentBudget(result, bytes - 1, ['content'])).toBe(true) }) - it('reuses exact measurements for a shared result object', () => { - const result = { content: '\n'.repeat(64 * 1024), isBinary: false } - const bytes = Buffer.byteLength(JSON.stringify(result), 'utf8') - const charCodeAt = vi.spyOn(String.prototype, 'charCodeAt') - try { - expect(remoteRpcResultExceedsContentBudget(result, bytes, ['content'])).toBe(false) - const firstCalls = charCodeAt.mock.calls.length - expect(firstCalls).toBeGreaterThan(result.content.length) - - expect(remoteRpcResultExceedsContentBudget(result, bytes - 1, ['content'])).toBe(true) - expect(charCodeAt.mock.calls.length - firstCalls).toBeLessThan(100) - } finally { - charCodeAt.mockRestore() - } - }) - - it('reuses raw measurements for a shared oversized result', () => { - const result = { content: 'A'.repeat(4 * 1024 * 1024), isBinary: false } - const byteLength = vi.spyOn(Buffer, 'byteLength') - const contentMeasurementCalls = () => - byteLength.mock.calls.filter(([value]) => value === result.content).length - try { - expect(remoteRpcResultExceedsContentBudget(result, result.content.length, ['content'])).toBe( - true - ) - expect(contentMeasurementCalls()).toBe(1) - - expect( - remoteRpcResultExceedsContentBudget(result, result.content.length - 128, ['content']) - ).toBe(true) - expect(contentMeasurementCalls()).toBe(1) - } finally { - byteLength.mockRestore() - } - }) - it('re-evaluates a cached raw measurement under a larger budget', () => { const result = { content: 'A'.repeat(1_000), isBinary: false } const bytes = Buffer.byteLength(JSON.stringify(result), 'utf8') From f4d294dac25504fc4c1fc20b87cd26fec11d0574 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Fri, 13 Nov 2026 11:18:51 +0000 Subject: [PATCH 12/55] fix(browser): an agent's new server tab takes the selection from the user's This-device page (#41953) Pageless agent commands prefer the workspace's active client page, and creating an offscreen or renderer tab never cleared it, so the next command after `orca tab create` acted on the user's page. Yield client selection wherever a create moves the bridge's active tab, as orcad already does. --- .../browser-client-page-queries.ts | 9 +++-- .../client-pages/orca-runtime-browser.test.ts | 38 +++++++++++++++++++ .../runtime-browser-page-registry.ts | 8 ++++ .../tabs/browser-tab-create-publication.ts | 7 ++++ .../browser/tabs/browser-tab-queries.ts | 5 +-- 5 files changed, 59 insertions(+), 8 deletions(-) diff --git a/src/main/runtime/browser/client-pages/browser-client-page-queries.ts b/src/main/runtime/browser/client-pages/browser-client-page-queries.ts index 07b6c157b09..98509e9c4ca 100644 --- a/src/main/runtime/browser/client-pages/browser-client-page-queries.ts +++ b/src/main/runtime/browser/client-pages/browser-client-page-queries.ts @@ -158,11 +158,12 @@ export async function yieldClientPageSelection( worktree: string | undefined ): Promise { const pages = host.getRuntimeBrowserPageRegistry() + if (worktree) { + pages.yieldSelection((await host.resolveBrowserWorkspace(worktree)).id) + return + } pages.deactivateGlobal() - const workspaceIds = worktree - ? [(await host.resolveBrowserWorkspace(worktree)).id] - : [...new Set(pages.listPages().map((page) => page.workspaceId))] - for (const workspaceId of workspaceIds) { + for (const workspaceId of new Set(pages.listPages().map((page) => page.workspaceId))) { pages.deactivateWorkspace(workspaceId) } } diff --git a/src/main/runtime/browser/client-pages/orca-runtime-browser.test.ts b/src/main/runtime/browser/client-pages/orca-runtime-browser.test.ts index 35465319fc9..57e0e6f0245 100644 --- a/src/main/runtime/browser/client-pages/orca-runtime-browser.test.ts +++ b/src/main/runtime/browser/client-pages/orca-runtime-browser.test.ts @@ -191,4 +191,42 @@ describe('RuntimeBrowserCommands browser screencast', () => { expect(waitForTabRegistrationMock).toHaveBeenCalledWith('page-new') expect(bridge.setActiveTab).toHaveBeenCalledWith(101, 'wt-1') }) + // Pageless agent commands prefer an active client page; an agent's new server tab must take that over. + it('moves the workspace selection off a client page when a server tab is created', async () => { + const { RuntimeBrowserCommands } = await import('../commands/browser-commands') + const pages = new RuntimeBrowserPageRegistry() + pages.publishClientPage({ + browserPageId: 'user-page', + workspaceId: 'wt-1', + browserProfileId: 'default', + executionHostKey: 'local', + placement: { + kind: 'client', + browserHostClientId: 'desktop-1', + browserHostGeneration: 1, + pageHostGeneration: 1 + }, + url: 'https://user.example/', + loading: false, + active: true + }) + const bridge = { + getRegisteredTabs: vi.fn(() => new Map([['agent-page', 102]])), + setActiveTab: vi.fn() + } + const offscreen = { createTab: vi.fn(async () => ({ browserPageId: 'agent-page' })) } + const host = createHost({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: create reaches only these two bridge methods. + getAgentBrowserBridge: () => bridge as unknown as AgentBrowserBridge, + getRuntimeBrowserPageRegistry: () => pages, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: create reaches only createTab. + getOffscreenBrowserBackend: () => offscreen as never + }) + const commands = new RuntimeBrowserCommands(host) + + await commands.browserTabCreate({ worktree: 'id:wt-1', url: 'https://agent.example/' }) + + expect(bridge.setActiveTab).toHaveBeenCalledWith(102, 'wt-1') + expect(pages.listPages('wt-1').find((page) => page.active)).toBeUndefined() + }) }) diff --git a/src/main/runtime/browser/client-pages/runtime-browser-page-registry.ts b/src/main/runtime/browser/client-pages/runtime-browser-page-registry.ts index 519ed9d37d9..a7fb5cb373d 100644 --- a/src/main/runtime/browser/client-pages/runtime-browser-page-registry.ts +++ b/src/main/runtime/browser/client-pages/runtime-browser-page-registry.ts @@ -199,6 +199,14 @@ export class RuntimeBrowserPageRegistry { this.globalActivePageId = null } + /** An engine tab took the selection; unscoped creates and switches move only the global pick. */ + yieldSelection(workspaceId: string | undefined): void { + this.deactivateGlobal() + if (workspaceId !== undefined) { + this.deactivateWorkspace(workspaceId) + } + } + deactivateWorkspace(workspaceId: string): void { for (const [browserPageId, page] of this.pages) { if (page.workspaceId === workspaceId && page.active) { diff --git a/src/main/runtime/browser/tabs/browser-tab-create-publication.ts b/src/main/runtime/browser/tabs/browser-tab-create-publication.ts index 08ba33d6c28..9d6401abb34 100644 --- a/src/main/runtime/browser/tabs/browser-tab-create-publication.ts +++ b/src/main/runtime/browser/tabs/browser-tab-create-publication.ts @@ -1,4 +1,5 @@ import type { AgentBrowserBridge } from '../../../browser/agent-browser/agent-browser-bridge' +import type { RuntimeBrowserPageRegistry } from '../client-pages/runtime-browser-page-registry' import { navigationTargetsHost, resolveRuntimeNavigationTarget, @@ -20,6 +21,7 @@ type BrowserTabCreatePublicationBridge = Pick< export type BrowserTabCreatePublicationHost = { getAgentBrowserBridge(): BrowserTabCreatePublicationBridge | null + getRuntimeBrowserPageRegistry(): Pick markHeadlessBrowserSessionTabActive?( worktreeId: string | undefined, browserPageId: string, @@ -194,6 +196,11 @@ export function publishCreatedBrowserSessionTab( ?.getRegisteredTabs(publication.worktreeId) .get(publication.browserPageId) if (bridge && webContentsId != null) { + // Pageless commands prefer an active client page, so the new engine tab must take over from it. + // Yield first so the bridge's own tabs-changed publication carries the cleared client row. + if (publication.focus.startsActive) { + host.getRuntimeBrowserPageRegistry().yieldSelection(publication.worktreeId) + } bridge.setActiveTab(webContentsId, publication.worktreeId) } } diff --git a/src/main/runtime/browser/tabs/browser-tab-queries.ts b/src/main/runtime/browser/tabs/browser-tab-queries.ts index 51f31bdbe76..5f6deea8b96 100644 --- a/src/main/runtime/browser/tabs/browser-tab-queries.ts +++ b/src/main/runtime/browser/tabs/browser-tab-queries.ts @@ -166,10 +166,7 @@ export class RuntimeBrowserTabQueries { ? (await this.host.resolveBrowserWorkspace(params.worktree)).id : undefined const result = await bridge.tabSwitch(undefined, worktreeId, selected.browserPageId) - this.host.getRuntimeBrowserPageRegistry().deactivateGlobal() - if (worktreeId) { - this.host.getRuntimeBrowserPageRegistry().deactivateWorkspace(worktreeId) - } + this.host.getRuntimeBrowserPageRegistry().yieldSelection(worktreeId) // Scope focus to the owning worktree; never switch the user's workspace. const focusWorktreeId = worktreeId ?? browserManager.getWorktreeIdForTab(result.browserPageId) ?? undefined From 7658aebc4d00f9c5abf4ae8aca8ddf4328dbd3d2 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 13 Nov 2026 03:24:50 -0800 Subject: [PATCH 13/55] test: remove repeated readiness and telemetry schema cases (#41983) Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../scripts/ci/sharding/ci-shard-timings.json | 1 - ...agent-screen-readiness-transcripts.test.ts | 31 ++- .../screen-ruled-agent-transcript-suite.ts | 249 ------------------ .../nested-repo-telemetry-schema.test.ts | 97 ------- .../profile-state-telemetry-schema.spec.ts | 17 -- .../telemetry-payload-contracts.test.ts | 1 - 6 files changed, 15 insertions(+), 381 deletions(-) delete mode 100644 src/main/runtime/terminal/wait/screen-ruled-agent-transcript-suite.ts delete mode 100644 src/shared/nested-repo-telemetry-schema.test.ts delete mode 100644 src/shared/profile-state-telemetry-schema.spec.ts diff --git a/config/scripts/ci/sharding/ci-shard-timings.json b/config/scripts/ci/sharding/ci-shard-timings.json index fcbafaeab0f..62734da3d05 100644 --- a/config/scripts/ci/sharding/ci-shard-timings.json +++ b/config/scripts/ci/sharding/ci-shard-timings.json @@ -6401,7 +6401,6 @@ "src/shared/native-chat/transcript/native-chat-unverifiable-turn-status.test.ts": 150, "src/shared/native-file-drop.test.ts": 59, "src/shared/ndjson-retained-chunk-memory.test.ts": 327, - "src/shared/nested-repo-telemetry-schema.test.ts": 196, "src/shared/nested-repo-telemetry.test.ts": 55, "src/shared/network-proxy.test.ts": 44, "src/shared/network/manual-address.test.ts": 68, diff --git a/src/main/runtime/terminal/wait/prime-agent-screen-readiness-transcripts.test.ts b/src/main/runtime/terminal/wait/prime-agent-screen-readiness-transcripts.test.ts index e3b7efea742..e44a6ebe0c5 100644 --- a/src/main/runtime/terminal/wait/prime-agent-screen-readiness-transcripts.test.ts +++ b/src/main/runtime/terminal/wait/prime-agent-screen-readiness-transcripts.test.ts @@ -1,13 +1,6 @@ -import { describe, vi } from 'vitest' -import './agent-transcript-replay-test-harness' -import { describeScreenRuledAgentTranscripts } from './screen-ruled-agent-transcript-suite' - -vi.mock('electron', () => ({ - BrowserWindow: { fromId: vi.fn(() => null) }, - webContents: { fromId: vi.fn(() => null) }, - ipcMain: { on: vi.fn(), removeListener: vi.fn() }, - app: { getPath: vi.fn(() => '/tmp') } -})) +import { describe, expect, it } from 'vitest' +import { finalReplayFrame } from './agent-transcript-replay-test-harness' +import { evaluateAgentStateRules } from '../../agent-state-rules/agent-state-rules-engine' // prime-agent 0.9.8 on macOS, OpenRouter, isolated HOME; 0.9.5 recorded for PR #22154 (see // each .meta.json); STA-8741. @@ -30,11 +23,17 @@ const NOT_READY = [ ] describe('Prime Agent readiness from captured bytes', () => { - describeScreenRuledAgentTranscripts({ - agent: 'prime-agent', - foregroundProcess: 'prime-agent', - ready: READY, - notReady: NOT_READY, - readyWithoutScreen: READY.map(({ name }) => name) + it.each(READY)('$name ($what) reads ready', async ({ name, cols, rows }) => { + const { ruledScreenLines } = await finalReplayFrame(name, cols, rows) + expect( + evaluateAgentStateRules('prime-agent', { readScreenLines: () => ruledScreenLines })?.state + ).toBe('idle') + }) + + it.each(NOT_READY)('$name ($what) does not read ready', async ({ name, cols, rows }) => { + const { ruledScreenLines } = await finalReplayFrame(name, cols, rows) + expect( + evaluateAgentStateRules('prime-agent', { readScreenLines: () => ruledScreenLines })?.state + ).not.toBe('idle') }) }) diff --git a/src/main/runtime/terminal/wait/screen-ruled-agent-transcript-suite.ts b/src/main/runtime/terminal/wait/screen-ruled-agent-transcript-suite.ts deleted file mode 100644 index 43f4d71c54d..00000000000 --- a/src/main/runtime/terminal/wait/screen-ruled-agent-transcript-suite.ts +++ /dev/null @@ -1,249 +0,0 @@ -// One replay suite for every agent whose readiness its live screen decides (terminal-wait-detection.ts). -import { describe, expect, it, vi } from 'vitest' -import { - createTranscriptPane, - TRANSCRIPT_PANE_PTY_ID, - waitForTranscriptIdle -} from '../../orca-runtime-tests/agent-transcript-pane-test-harness' -import { - finalReadProjection, - finalReplayFrame, - readRuntimeFixture, - type TranscriptReplayResize -} from './agent-transcript-replay-test-harness' -import { isQuietReadyScreenBody } from './terminal-wait-detection' -import { evaluateAgentStateRules } from '../../agent-state-rules/agent-state-rules-engine' -import type { TuiAgent } from '../../../../shared/agent-launch/catalog/tui-agent' - -export type ScreenRuledFixture = { name: string; cols: number; rows: number; what: string } - -export type ScreenRuledAgentSuite = { - agent: TuiAgent - foregroundProcess: string - ready: readonly ScreenRuledFixture[] - notReady: readonly ScreenRuledFixture[] - /** Ready recordings the text rules or the quiet-process lane settle with no screen. */ - readyWithoutScreen: readonly string[] -} - -// Mismatched grids garble cursor-addressed chrome (#23475 review). -const MISMATCHED_GRIDS = [ - [80, 24], - [100, 30], - [60, 20], - [150, 50] -] as const - -// 8s covers 3s quiescence and the 2s poll; 5s refusal allows both plus at least one poll tick. -const READY_TIMEOUT_MS = 8_000 -const REFUSAL_TIMEOUT_MS = 5_000 -// Allow for multi-MB animated recordings taking seconds in onPtyData. -const PANE_SETUP_SLACK_MS = 30_000 - -function chunkCount(name: string): number { - return Math.ceil(readRuntimeFixture(name).length / 64) -} - -/** Whether the agent's own rules read `screenLines` as an idle composer. */ -export function readsIdleComposer(agent: TuiAgent, screenLines: readonly string[]): boolean { - return evaluateAgentStateRules(agent, { readScreenLines: () => screenLines })?.state === 'idle' -} - -export function describeScreenRuledAgentTranscripts(suite: ScreenRuledAgentSuite): void { - const { agent } = suite - const rule = (screenLines: readonly string[]): boolean => readsIdleComposer(agent, screenLines) - const [firstReady] = suite.ready - if (!firstReady) { - throw new Error(`${agent}: a suite needs a ready recording`) - } - - describe('the screen rule on the final recorded frame', () => { - it.each(suite.ready)('$name ($what) reads ready', async ({ name, cols, rows }) => { - const { ruledScreenLines } = await finalReplayFrame(name, cols, rows) - expect(rule(ruledScreenLines)).toBe(true) - }) - it.each(suite.notReady)('$name ($what) does not', async ({ name, cols, rows }) => { - const { ruledScreenLines } = await finalReplayFrame(name, cols, rows) - expect(rule(ruledScreenLines)).toBe(false) - }) - }) - - describe('fails safe on a garbled grid', () => { - it.each(suite.notReady)('$name never reads ready', async ({ name, cols, rows }) => { - const resize: TranscriptReplayResize = { - atChunk: Math.floor(chunkCount(name) / 2), - cols: 100, - rows: 30 - } - const frames = [ - ...(await Promise.all(MISMATCHED_GRIDS.map(([c, r]) => finalReplayFrame(name, c, r)))), - await finalReplayFrame(name, cols, rows, resize) - ] - for (const { ruledScreenLines, waitText } of frames) { - expect(isQuietReadyScreenBody(waitText, agent, () => ruledScreenLines)).toBe(false) - } - }) - }) - - describe('through the runtime', () => { - async function pane(fixture: ScreenRuledFixture) { - const created = await createTranscriptPane({ - paneTitle: 'Terminal', - foregroundProcess: suite.foregroundProcess, - launchAgent: agent, - data: readRuntimeFixture(fixture.name), - size: { cols: fixture.cols, rows: fixture.rows } - }) - // Drain emulator parsing: one multi-MB chunk can outlive the output quiet clock unlike a live PTY's silence. - await created.runtime.readTerminal(created.handle, { screen: true }) - return created - } - - it.each(suite.ready)( - '$name: a tui-idle wait settles once the pane is quiet', - async (fixture) => { - const { runtime, handle } = await pane(fixture) - await expect( - waitForTranscriptIdle({ runtime, handle }, READY_TIMEOUT_MS) - ).resolves.toMatchObject({ - condition: 'tui-idle', - satisfied: true - }) - }, - READY_TIMEOUT_MS + PANE_SETUP_SLACK_MS - ) - - it.each(suite.notReady)( - '$name: a tui-idle wait does not settle ready', - async (fixture) => { - const { runtime, handle } = await pane(fixture) - const result = await waitForTranscriptIdle({ runtime, handle }, REFUSAL_TIMEOUT_MS).catch( - (error: unknown) => ({ satisfied: false, error: String(error) }) - ) - expect(result.satisfied).toBe(false) - }, - REFUSAL_TIMEOUT_MS + PANE_SETUP_SLACK_MS - ) - - // Why 100x30: a grid no recording was painted for. The PTY reports its real size. - it.each(suite.ready)( - '$name: on a grid the PTY does not have, the lanes the screen would shut decide', - async (fixture) => { - const options = { - paneTitle: 'Terminal', - foregroundProcess: suite.foregroundProcess, - launchAgent: agent, - data: readRuntimeFixture(fixture.name), - size: { cols: 100, rows: 30 } - } - const { runtime, handle } = await createTranscriptPane(options) - await runtime.readTerminal(handle, { screen: true }) - options.size = { cols: fixture.cols, rows: fixture.rows } - const result = await waitForTranscriptIdle({ runtime, handle }, READY_TIMEOUT_MS).catch( - () => ({ satisfied: false }) - ) - expect(result.satisfied).toBe(suite.readyWithoutScreen.includes(fixture.name)) - }, - READY_TIMEOUT_MS + PANE_SETUP_SLACK_MS - ) - - // Reattach reflows without a TUI repaint. - it( - 'on a grid reflowed to the PTY size without a repaint, the lanes the screen would shut decide', - async () => { - const options = { - paneTitle: 'Terminal', - foregroundProcess: suite.foregroundProcess, - launchAgent: agent, - data: readRuntimeFixture(firstReady.name), - size: { cols: 100, rows: 30 } - } - const { runtime, handle } = await createTranscriptPane(options) - options.size = { cols: firstReady.cols, rows: firstReady.rows } - runtime.reflowHeadlessTerminalToPtyGrid( - TRANSCRIPT_PANE_PTY_ID, - firstReady.cols, - firstReady.rows - ) - // Echoing the already-reflowed size sends no SIGWINCH, so the TUI never repaints. - runtime.onExternalPtyResize(TRANSCRIPT_PANE_PTY_ID, firstReady.cols, firstReady.rows) - await runtime.readTerminal(handle, { screen: true }) - const result = await waitForTranscriptIdle({ runtime, handle }, READY_TIMEOUT_MS).catch( - () => ({ satisfied: false }) - ) - expect(result.satisfied).toBe(suite.readyWithoutScreen.includes(firstReady.name)) - }, - READY_TIMEOUT_MS + PANE_SETUP_SLACK_MS - ) - - // Why no bytes: a restored pane has no output clock, so the provider's screen is all it has. - // Why the read projection: it is what the probe's screen read returns, draft blanked. - async function probedVerdict(fixture: ScreenRuledFixture): Promise { - const { lines, draft } = await finalReadProjection(fixture.name, fixture.cols, fixture.rows) - const { runtime, handle } = await createTranscriptPane({ - paneTitle: 'Terminal', - foregroundProcess: suite.foregroundProcess, - launchAgent: agent, - data: '' - }) - const read = vi.spyOn(runtime, 'readTerminal').mockResolvedValue({ - handle, - status: 'running', - tail: lines, - ...(draft === undefined ? {} : { draft }), - truncated: false, - nextCursor: null, - source: 'screen' - }) - const result = await waitForTranscriptIdle({ runtime, handle }, 2_500).catch(() => ({ - satisfied: false - })) - // Presence precondition: the visible-screen probe actually ran. - expect(read).toHaveBeenCalled() - return result.satisfied === true - } - - it.each(suite.ready)( - '$name: a restored pane settles from a visible-screen read', - async (fixture) => { - expect(await probedVerdict(fixture)).toBe(true) - }, - 15_000 - ) - - it.each(suite.notReady)( - '$name: a restored pane does not settle from a visible-screen read', - async (fixture) => { - expect(await probedVerdict(fixture)).toBe(false) - }, - 15_000 - ) - - it('waits for quiet again after a ready pane repaints', async () => { - const { runtime, handle } = await pane(firstReady) - vi.useFakeTimers({ - toFake: ['Date', 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] - }) - try { - const settled = vi.fn() - const waiting = runtime.waitForTerminal(handle, { - condition: 'tui-idle', - timeoutMs: READY_TIMEOUT_MS - }) - void waiting.then(settled, () => {}) - await vi.advanceTimersByTimeAsync(2_000) - expect(settled).not.toHaveBeenCalled() - - runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, readRuntimeFixture(firstReady.name), Date.now()) - await runtime.readTerminal(handle, { screen: true }) - await vi.advanceTimersByTimeAsync(2_000) - expect(settled).not.toHaveBeenCalled() - - await vi.advanceTimersByTimeAsync(2_000) - await expect(waiting).resolves.toMatchObject({ condition: 'tui-idle', satisfied: true }) - } finally { - vi.useRealTimers() - } - }) - }) -} diff --git a/src/shared/nested-repo-telemetry-schema.test.ts b/src/shared/nested-repo-telemetry-schema.test.ts deleted file mode 100644 index 0caf6601dcc..00000000000 --- a/src/shared/nested-repo-telemetry-schema.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { eventSchemas } from './telemetry-events' - -const attemptId = '2fbac1e3-5094-45b4-80a6-90281e6e9e09' - -describe('nested repo import telemetry schemas', () => { - it('rejects raw paths, repo names, and raw errors via .strict()', () => { - expect( - eventSchemas.add_repo_nested_scan_result.safeParse({ - attempt_id: attemptId, - surface: 'onboarding', - runtime_kind: 'local', - result: 'review_shown', - selected_path_kind: 'non_git_folder', - found_count: 1, - found_count_bucket: '1', - truncated: false, - timed_out: false, - selected_path: '/Users/alice/work/platform' - }).success - ).toBe(false) - - expect( - eventSchemas.add_repo_nested_import_action.safeParse({ - attempt_id: attemptId, - surface: 'sidebar', - runtime_kind: 'local', - action: 'import_group', - found_count: 1, - found_count_bucket: '1', - selected_count: 1, - selected_count_bucket: '1', - all_selected: true, - repo_name: 'secret-service' - }).success - ).toBe(false) - - expect( - eventSchemas.add_repo_nested_import_result.safeParse({ - attempt_id: attemptId, - surface: 'sidebar', - runtime_kind: 'ssh', - mode: 'group', - outcome: 'failed', - found_count: 1, - found_count_bucket: '1', - selected_count: 1, - selected_count_bucket: '1', - imported_count: 0, - imported_count_bucket: '0', - already_known_count: 0, - already_known_count_bucket: '0', - failed_count: 1, - failed_count_bucket: '1', - all_selected: true, - error_message: 'failed at /Users/alice/work/platform' - }).success - ).toBe(false) - }) - - it('rejects mismatched exact counts and buckets', () => { - expect( - eventSchemas.add_repo_nested_import_action.safeParse({ - attempt_id: attemptId, - surface: 'sidebar', - runtime_kind: 'local', - action: 'import_group', - found_count: 1, - found_count_bucket: '16+', - selected_count: 1, - selected_count_bucket: '1', - all_selected: true - }).success - ).toBe(false) - - expect( - eventSchemas.add_repo_nested_import_result.safeParse({ - attempt_id: attemptId, - surface: 'sidebar', - runtime_kind: 'local', - mode: 'group', - outcome: 'success', - found_count: 2, - found_count_bucket: '2-3', - selected_count: 2, - selected_count_bucket: '2-3', - imported_count: 2, - imported_count_bucket: '16+', - already_known_count: 0, - already_known_count_bucket: '0', - failed_count: 0, - failed_count_bucket: '0', - all_selected: true - }).success - ).toBe(false) - }) -}) diff --git a/src/shared/profile-state-telemetry-schema.spec.ts b/src/shared/profile-state-telemetry-schema.spec.ts deleted file mode 100644 index 57718096e8d..00000000000 --- a/src/shared/profile-state-telemetry-schema.spec.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { eventSchemas } from './telemetry-events' - -describe('profile state authority telemetry', () => { - it('rejects paths or other unbounded diagnostic fields', () => { - expect( - eventSchemas.profile_state_authority_selected.safeParse({ - backend: 'sqlite', - classification: 'sqlite-only', - authority_mode: 'sqlite-established', - runtime: 'desktop', - migrated: false, - database_path: '/private/profile.sqlite' - }).success - ).toBe(false) - }) -}) diff --git a/src/shared/telemetry-payload-contracts.test.ts b/src/shared/telemetry-payload-contracts.test.ts index 9a4cf8ea3e8..c83bd727095 100644 --- a/src/shared/telemetry-payload-contracts.test.ts +++ b/src/shared/telemetry-payload-contracts.test.ts @@ -1,4 +1,3 @@ import './telemetry-orca-cli-feature-tip.spec' import './telemetry-events-feature-education.spec' import './telemetry-feature-wall-events.spec' -import './profile-state-telemetry-schema.spec' From 4245b597659df71fe076a7296fe959b0ad9c69b5 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 13 Nov 2026 03:27:33 -0800 Subject: [PATCH 14/55] refactor: group agent hook providers by domain (#41968) * refactor: group agent hook providers by domain * chore: shrink retired hook test ratchet allowances --- config/flaky-test-pattern-baseline.txt | 3 +- .../scripts/ci/sharding/ci-shard-timings.json | 19 +- .../server/server-codex-question-skip.ts | 2 +- .../server/server-foreign-turn-hook.ts | 2 +- .../agent-hooks/server/server-hydration.ts | 4 +- .../server/server-ingest-remote.ts | 2 +- .../server/server-relay-sessions.ts | 2 +- .../server/server-status-application.ts | 2 +- .../server/server-status-inference.ts | 2 +- .../server/server-status-retries.ts | 4 +- .../server/server-status-update.ts | 8 +- src/main/agent-hooks/server/server-types.ts | 2 +- .../server-claude-helper-recovery.test.ts | 463 ------------------ ...er-claude-owed-notification-expiry.test.ts | 114 ----- .../tool-events/agent-tool-call-reader.ts | 2 +- .../wait/tui-idle-hook-lane-runtime.test.ts | 269 +--------- .../claude-tool-request-identity.ts | 2 +- .../grok-result-discovery.ts | 2 +- .../agent-hook-listener/listener-state.ts | 6 +- ...gent-hook-listener-codex-interrupt.test.ts | 2 +- .../claude-background-task-status.test.ts | 210 +------- .../normalization/normalize-hook-payload.ts | 8 +- .../pane-provider-session.ts | 4 +- .../agent-hook-listener/provider-dispatch.ts | 56 ++- .../provider-event-routing.ts | 30 +- .../providers/{ => amp}/amp-events.ts | 14 +- .../providers/{ => amp}/amp-tool-fields.ts | 6 +- .../antigravity-background-command.test.ts | 6 +- .../antigravity-event-rules.ts | 0 .../{ => antigravity}/antigravity-events.ts | 12 +- .../antigravity-tool-fields.ts | 8 +- .../claude-helper-kill.test-fixture.ts | 109 ----- .../providers/claude-helper-kill.test.ts | 79 --- .../claude-transcript-semantics.test.ts | 190 ------- .../claude-owed-notification-expiry-timers.ts | 12 +- .../claude-owed-task-notifications.ts | 4 +- .../claude-task-notification-delivery.ts | 8 +- .../providers/{ => claude}/claude-events.ts | 34 +- .../{ => claude}/claude-tool-fields.ts | 13 +- .../lifecycle}/claude-compact-completion.ts | 4 +- .../lifecycle}/claude-lifecycle-events.ts | 10 +- .../lifecycle}/claude-pane-hold-evidence.ts | 12 +- .../lifecycle}/claude-roster-state.ts | 20 +- .../claude-session-start-sources.ts | 0 .../lifecycle}/claude-status-build.ts | 12 +- .../lifecycle}/claude-teammate-spawn.ts | 6 +- .../lifecycle}/claude-tool-request-wait.ts | 10 +- .../transcripts}/claude-helper-kill-poller.ts | 16 +- .../claude-helper-transcript-evidence.ts | 4 +- .../transcripts}/claude-transcript-cursor.ts | 0 .../transcripts}/claude-transcript-decoder.ts | 4 +- .../claude-transcript-semantics.test.ts | 34 ++ ...ent-hook-listener-codex-main-agent.test.ts | 6 +- .../{ => codex}/codex-async-questions.ts | 6 +- .../{ => codex}/codex-ended-turn-event.ts | 6 +- .../{ => codex}/codex-ephemeral-threads.ts | 6 +- .../providers/{ => codex}/codex-events.ts | 24 +- .../{ => codex}/codex-question-skip.ts | 4 +- .../{ => codex}/codex-session-start.ts | 12 +- .../providers/{ => codex}/codex-state.ts | 18 +- .../{ => codex}/codex-tool-fields.ts | 6 +- .../{ => codex}/codex-transcript-poll.ts | 12 +- .../{ => command-code}/command-code-events.ts | 8 +- .../command-code-tool-fields.ts | 8 +- .../providers/{ => copilot}/copilot-events.ts | 10 +- .../{ => copilot}/copilot-tool-fields.ts | 8 +- .../providers/{ => cursor}/cursor-events.ts | 8 +- .../{ => cursor}/cursor-tool-fields.ts | 6 +- .../providers/{ => devin}/devin-events.ts | 8 +- .../providers/{ => droid}/droid-events.ts | 10 +- .../{ => droid}/droid-tool-fields.ts | 8 +- .../providers/{ => dsh}/dsh-events.test.ts | 4 +- .../providers/{ => dsh}/dsh-events.ts | 12 +- .../providers/{ => gemini}/gemini-events.ts | 8 +- .../{ => gemini}/gemini-tool-fields.ts | 4 +- .../{ => grok}/grok-background-tasks.ts | 12 +- .../providers/{ => grok}/grok-events.ts | 20 +- .../providers/{ => grok}/grok-tool-fields.ts | 12 +- .../{ => hermes}/hermes-events.test.ts | 2 +- .../providers/{ => hermes}/hermes-events.ts | 8 +- .../{ => hermes}/hermes-tool-fields.ts | 6 +- .../providers/jcode-turn-prompt.test.ts | 48 -- .../providers/{ => jcode}/jcode-events.ts | 10 +- .../jcode-helper-permission-wait.ts | 12 +- .../{ => jcode}/jcode-tool-fields.ts | 4 +- .../{ => jcode}/jcode-turn-prompt.ts | 6 +- .../providers/{ => kimi}/kimi-events.ts | 10 +- .../providers/{ => kiro}/kiro-events.ts | 8 +- .../providers/{ => kiro}/kiro-tool-fields.ts | 6 +- .../providers/muse-events.test.ts | 229 --------- .../providers/muse/muse-events.test.ts | 85 ++++ .../providers/{ => muse}/muse-events.ts | 18 +- .../opencode-family-events.ts | 12 +- .../opencode-family-tool-fields.ts | 8 +- .../{ => pi-family}/pi-family-events.ts | 14 +- .../{ => pi-family}/pi-family-tool-fields.ts | 15 +- .../providers/qoder-events.ts | 19 - .../{ => zcode}/zcode-events.test.ts | 4 +- .../providers/{ => zcode}/zcode-events.ts | 12 +- .../transcript-poll-policy.ts | 4 +- .../agent-hook-result-retry-scheduler.ts | 6 +- ...er-claude-owed-notification-expiry.test.ts | 84 ---- .../status/agent-hook-event-admission.ts | 2 +- .../agent-hook-interrupt-reconciliation.ts | 4 +- 104 files changed, 529 insertions(+), 2200 deletions(-) delete mode 100644 src/main/agent-hooks/server/testing/interruptions/server-claude-helper-recovery.test.ts delete mode 100644 src/main/agent-hooks/server/testing/task-wakeup/server-claude-owed-notification-expiry.test.ts rename src/shared/{ => agent-hook-listener/normalization}/claude-background-task-status.test.ts (54%) rename src/shared/agent-hook-listener/providers/{ => amp}/amp-events.ts (92%) rename src/shared/agent-hook-listener/providers/{ => amp}/amp-tool-fields.ts (90%) rename src/shared/agent-hook-listener/providers/{ => antigravity}/antigravity-background-command.test.ts (87%) rename src/shared/agent-hook-listener/providers/{ => antigravity}/antigravity-event-rules.ts (100%) rename src/shared/agent-hook-listener/providers/{ => antigravity}/antigravity-events.ts (93%) rename src/shared/agent-hook-listener/providers/{ => antigravity}/antigravity-tool-fields.ts (87%) delete mode 100644 src/shared/agent-hook-listener/providers/claude-helper-kill.test-fixture.ts delete mode 100644 src/shared/agent-hook-listener/providers/claude-helper-kill.test.ts delete mode 100644 src/shared/agent-hook-listener/providers/claude-transcript-semantics.test.ts rename src/shared/agent-hook-listener/providers/{ => claude/background-tasks}/claude-owed-notification-expiry-timers.ts (84%) rename src/shared/agent-hook-listener/providers/{ => claude/background-tasks}/claude-owed-task-notifications.ts (98%) rename src/shared/agent-hook-listener/providers/{ => claude/background-tasks}/claude-task-notification-delivery.ts (91%) rename src/shared/agent-hook-listener/providers/{ => claude}/claude-events.ts (92%) rename src/shared/agent-hook-listener/providers/{ => claude}/claude-tool-fields.ts (87%) rename src/shared/agent-hook-listener/providers/{ => claude/lifecycle}/claude-compact-completion.ts (95%) rename src/shared/agent-hook-listener/providers/{ => claude/lifecycle}/claude-lifecycle-events.ts (95%) rename src/shared/agent-hook-listener/providers/{ => claude/lifecycle}/claude-pane-hold-evidence.ts (88%) rename src/shared/agent-hook-listener/providers/{ => claude/lifecycle}/claude-roster-state.ts (93%) rename src/shared/agent-hook-listener/providers/{ => claude/lifecycle}/claude-session-start-sources.ts (100%) rename src/shared/agent-hook-listener/providers/{ => claude/lifecycle}/claude-status-build.ts (83%) rename src/shared/agent-hook-listener/providers/{ => claude/lifecycle}/claude-teammate-spawn.ts (88%) rename src/shared/agent-hook-listener/providers/{ => claude/lifecycle}/claude-tool-request-wait.ts (85%) rename src/shared/agent-hook-listener/providers/{ => claude/transcripts}/claude-helper-kill-poller.ts (92%) rename src/shared/agent-hook-listener/providers/{ => claude/transcripts}/claude-helper-transcript-evidence.ts (97%) rename src/shared/agent-hook-listener/providers/{ => claude/transcripts}/claude-transcript-cursor.ts (100%) rename src/shared/agent-hook-listener/providers/{ => claude/transcripts}/claude-transcript-decoder.ts (97%) create mode 100644 src/shared/agent-hook-listener/providers/claude/transcripts/claude-transcript-semantics.test.ts rename src/shared/agent-hook-listener/{normalization => providers/codex}/agent-hook-listener-codex-main-agent.test.ts (92%) rename src/shared/agent-hook-listener/providers/{ => codex}/codex-async-questions.ts (96%) rename src/shared/agent-hook-listener/providers/{ => codex}/codex-ended-turn-event.ts (79%) rename src/shared/agent-hook-listener/providers/{ => codex}/codex-ephemeral-threads.ts (94%) rename src/shared/agent-hook-listener/providers/{ => codex}/codex-events.ts (93%) rename src/shared/agent-hook-listener/providers/{ => codex}/codex-question-skip.ts (92%) rename src/shared/agent-hook-listener/providers/{ => codex}/codex-session-start.ts (95%) rename src/shared/agent-hook-listener/providers/{ => codex}/codex-state.ts (94%) rename src/shared/agent-hook-listener/providers/{ => codex}/codex-tool-fields.ts (88%) rename src/shared/agent-hook-listener/providers/{ => codex}/codex-transcript-poll.ts (87%) rename src/shared/agent-hook-listener/providers/{ => command-code}/command-code-events.ts (80%) rename src/shared/agent-hook-listener/providers/{ => command-code}/command-code-tool-fields.ts (86%) rename src/shared/agent-hook-listener/providers/{ => copilot}/copilot-events.ts (88%) rename src/shared/agent-hook-listener/providers/{ => copilot}/copilot-tool-fields.ts (97%) rename src/shared/agent-hook-listener/providers/{ => cursor}/cursor-events.ts (88%) rename src/shared/agent-hook-listener/providers/{ => cursor}/cursor-tool-fields.ts (95%) rename src/shared/agent-hook-listener/providers/{ => devin}/devin-events.ts (89%) rename src/shared/agent-hook-listener/providers/{ => droid}/droid-events.ts (90%) rename src/shared/agent-hook-listener/providers/{ => droid}/droid-tool-fields.ts (93%) rename src/shared/agent-hook-listener/providers/{ => dsh}/dsh-events.test.ts (93%) rename src/shared/agent-hook-listener/providers/{ => dsh}/dsh-events.ts (84%) rename src/shared/agent-hook-listener/providers/{ => gemini}/gemini-events.ts (83%) rename src/shared/agent-hook-listener/providers/{ => gemini}/gemini-tool-fields.ts (91%) rename src/shared/agent-hook-listener/providers/{ => grok}/grok-background-tasks.ts (89%) rename src/shared/agent-hook-listener/providers/{ => grok}/grok-events.ts (93%) rename src/shared/agent-hook-listener/providers/{ => grok}/grok-tool-fields.ts (92%) rename src/shared/agent-hook-listener/providers/{ => hermes}/hermes-events.test.ts (99%) rename src/shared/agent-hook-listener/providers/{ => hermes}/hermes-events.ts (93%) rename src/shared/agent-hook-listener/providers/{ => hermes}/hermes-tool-fields.ts (93%) delete mode 100644 src/shared/agent-hook-listener/providers/jcode-turn-prompt.test.ts rename src/shared/agent-hook-listener/providers/{ => jcode}/jcode-events.ts (89%) rename src/shared/agent-hook-listener/providers/{ => jcode}/jcode-helper-permission-wait.ts (90%) rename src/shared/agent-hook-listener/providers/{ => jcode}/jcode-tool-fields.ts (96%) rename src/shared/agent-hook-listener/providers/{ => jcode}/jcode-turn-prompt.ts (81%) rename src/shared/agent-hook-listener/providers/{ => kimi}/kimi-events.ts (90%) rename src/shared/agent-hook-listener/providers/{ => kiro}/kiro-events.ts (84%) rename src/shared/agent-hook-listener/providers/{ => kiro}/kiro-tool-fields.ts (84%) delete mode 100644 src/shared/agent-hook-listener/providers/muse-events.test.ts create mode 100644 src/shared/agent-hook-listener/providers/muse/muse-events.test.ts rename src/shared/agent-hook-listener/providers/{ => muse}/muse-events.ts (92%) rename src/shared/agent-hook-listener/providers/{ => opencode-family}/opencode-family-events.ts (85%) rename src/shared/agent-hook-listener/providers/{ => opencode-family}/opencode-family-tool-fields.ts (92%) rename src/shared/agent-hook-listener/providers/{ => pi-family}/pi-family-events.ts (93%) rename src/shared/agent-hook-listener/providers/{ => pi-family}/pi-family-tool-fields.ts (90%) delete mode 100644 src/shared/agent-hook-listener/providers/qoder-events.ts rename src/shared/agent-hook-listener/providers/{ => zcode}/zcode-events.test.ts (94%) rename src/shared/agent-hook-listener/providers/{ => zcode}/zcode-events.ts (87%) delete mode 100644 src/wsl-guest/agent-hooks/results/agent-hook-server-claude-owed-notification-expiry.test.ts diff --git a/config/flaky-test-pattern-baseline.txt b/config/flaky-test-pattern-baseline.txt index 9bbf1ed4d8d..a965c78d283 100644 --- a/config/flaky-test-pattern-baseline.txt +++ b/config/flaky-test-pattern-baseline.txt @@ -490,7 +490,7 @@ 23 test-no-wall-clock:src/main/runtime/terminal/output/terminal-query-responder.test.ts 3 test-no-wall-clock:src/main/runtime/terminal/query/orca-runtime-codex-idle-title-chat-address.test.ts 1 test-no-wall-clock:src/main/runtime/terminal/recovery/terminal-pane-recovery-liveness-gate.test.ts -16 test-no-wall-clock:src/main/runtime/terminal/wait/tui-idle-hook-lane-runtime.test.ts +4 test-no-wall-clock:src/main/runtime/terminal/wait/tui-idle-hook-lane-runtime.test.ts 1 test-no-wall-clock:src/main/runtime/workspaces/process-listing/runtime-worktree-structured-agent-rows-liveness.test.ts 1 test-no-wall-clock:src/main/runtime/workspaces/removal/owned-session-teardown-policy.test.ts 6 test-no-wall-clock:src/main/skills/install/skill-bundle-process-termination.integration.test.ts @@ -612,7 +612,6 @@ 2 test-timeout-needs-reason:src/main/runtime/terminal/wait/launched-agent-crash-guard.live-shell.test.ts 2 test-timeout-needs-reason:src/main/runtime/terminal/wait/terminal-interactive-wait-visibility.test.ts 1 test-timeout-needs-reason:src/main/runtime/terminal/wait/terminal-wait-name-only-idle.test.ts -4 test-timeout-needs-reason:src/main/runtime/terminal/wait/tui-idle-hook-lane-runtime.test.ts 1 test-timeout-needs-reason:src/main/skills/install/skill-bundle-process-termination.integration.test.ts 1 test-timeout-needs-reason:src/main/sqlite/sync-database-concurrent-backup.test.ts 2 test-timeout-needs-reason:src/main/ssh/connection/ssh-connection-sftp-wire.test.ts diff --git a/config/scripts/ci/sharding/ci-shard-timings.json b/config/scripts/ci/sharding/ci-shard-timings.json index 62734da3d05..ca81603760a 100644 --- a/config/scripts/ci/sharding/ci-shard-timings.json +++ b/config/scripts/ci/sharding/ci-shard-timings.json @@ -511,8 +511,6 @@ "src/main/agent-hooks/server/testing/providers/antigravity/server-antigravity-resume-identity.test.ts": 820, "src/main/agent-hooks/server/testing/permissions/server-claude-child-permission-lifecycle.test.ts": 681, "src/main/agent-hooks/server/testing/interruptions/server-claude-helper-kill.test.ts": 10279, - "src/main/agent-hooks/server/testing/interruptions/server-claude-helper-recovery.test.ts": 34523, - "src/main/agent-hooks/server/testing/task-wakeup/server-claude-owed-notification-expiry.test.ts": 877, "src/main/agent-hooks/server/testing/identity/server-claude-session-continuation.test.ts": 675, "src/main/agent-hooks/server/testing/http/server-claude-statusline.test.ts": 679, "src/main/agent-hooks/server/testing/task-wakeup/server-claude-task-wakeup-lifecycle.test.ts": 5451, @@ -6027,7 +6025,7 @@ "src/shared/agent-hook-listener/normalization/agent-hook-listener-claude-elicitation.test.ts": 229, "src/shared/agent-hook-listener/normalization/agent-hook-listener-codex-approval-ownership.test.ts": 258, "src/shared/agent-hook-listener/normalization/agent-hook-listener-codex-interrupt.test.ts": 240, - "src/shared/agent-hook-listener/normalization/agent-hook-listener-codex-main-agent.test.ts": 207, + "src/shared/agent-hook-listener/providers/codex/agent-hook-listener-codex-main-agent.test.ts": 207, "src/shared/agent-hook-listener/normalization/agent-hook-listener-codex-subagent-names.test.ts": 260, "src/shared/agent-hook-listener/normalization/agent-hook-listener-command-code-transcript.test.ts": 330, "src/shared/agent-hook-listener/normalization/agent-hook-listener-grok-completion.test.ts": 255, @@ -6037,13 +6035,11 @@ "src/shared/agent-hook-listener/transport/agent-hook-listener-transport.test.ts": 326, "src/shared/agent-hook-listener/claude-session-continuation.test.ts": 114, "src/shared/agent-hook-listener/claude-settled-permission-requests.test.ts": 54, - "src/shared/agent-hook-listener/providers/claude-helper-kill.test.ts": 1288, - "src/shared/agent-hook-listener/providers/claude-transcript-semantics.test.ts": 163, - "src/shared/agent-hook-listener/providers/dsh-events.test.ts": 231, - "src/shared/agent-hook-listener/providers/hermes-events.test.ts": 143, - "src/shared/agent-hook-listener/providers/jcode-turn-prompt.test.ts": 122, - "src/shared/agent-hook-listener/providers/muse-events.test.ts": 246, - "src/shared/agent-hook-listener/providers/zcode-events.test.ts": 241, + "src/shared/agent-hook-listener/providers/claude/transcripts/claude-transcript-semantics.test.ts": 163, + "src/shared/agent-hook-listener/providers/dsh/dsh-events.test.ts": 231, + "src/shared/agent-hook-listener/providers/hermes/hermes-events.test.ts": 143, + "src/shared/agent-hook-listener/providers/muse/muse-events.test.ts": 246, + "src/shared/agent-hook-listener/providers/zcode/zcode-events.test.ts": 241, "src/shared/agent-hook-listener/reverse-file-region-scan.test.ts": 51, "src/shared/agent-hook-listener/transcript-reader.test.ts": 109, "src/shared/agent-hook-listener/transport/agent-hook-spool-drain.test.ts": 50, @@ -6165,7 +6161,7 @@ "src/shared/claimed-agent-pty-owner.test.ts": 72, "src/shared/claude-agent-view-launch.test.ts": 55, "src/shared/claude-background-run-isolation.test.ts": 43, - "src/shared/claude-background-task-status.test.ts": 315, + "src/shared/agent-hook-listener/normalization/claude-background-task-status.test.ts": 315, "src/shared/claude-model-list-probe.test.ts": 55, "src/shared/claude-shell-function.test.ts": 220, "src/shared/cloud-service-url.test.ts": 37, @@ -6843,7 +6839,6 @@ "src/wsl-guest/agent-hooks/status/agent-hook-interrupt-reconciliation.test.ts": 586, "src/wsl-guest/agent-hooks/server/agent-hook-opencode-tui-identity.test.ts": 551, "src/wsl-guest/agent-hooks/status/agent-hook-provider-normalization.test.ts": 489, - "src/wsl-guest/agent-hooks/results/agent-hook-server-claude-owed-notification-expiry.test.ts": 792, "src/wsl-guest/agent-hooks/status/agent-hook-server-claude-session-continuation.test.ts": 462, "src/wsl-guest/agent-hooks/status/agent-hook-server-codex-side-thread.test.ts": 786, "src/wsl-guest/agent-hooks/status/agent-hook-server-codex-subagent-transcript.test.ts": 1589, diff --git a/src/main/agent-hooks/server/server-codex-question-skip.ts b/src/main/agent-hooks/server/server-codex-question-skip.ts index e59e028baaf..0cb25cf6d89 100644 --- a/src/main/agent-hooks/server/server-codex-question-skip.ts +++ b/src/main/agent-hooks/server/server-codex-question-skip.ts @@ -1,4 +1,4 @@ -import { codexRowAfterQuestionSkip } from '../../../shared/agent-hook-listener/providers/codex-question-skip' +import { codexRowAfterQuestionSkip } from '../../../shared/agent-hook-listener/providers/codex/codex-question-skip' import { AGENT_STATUS_STALE_AFTER_MS } from '../../../shared/agent-status/contracts/agent-status-freshness' import type { AgentQuestionAnsweredInferenceRequest } from '../../../shared/agent-status/lifecycle/agent-question-answered-intent' import { CODEX_ASYNC_QUESTION_TOOL } from '../../../shared/codex-async-question-input' diff --git a/src/main/agent-hooks/server/server-foreign-turn-hook.ts b/src/main/agent-hooks/server/server-foreign-turn-hook.ts index 78bca1b8942..c204ec960f4 100644 --- a/src/main/agent-hooks/server/server-foreign-turn-hook.ts +++ b/src/main/agent-hooks/server/server-foreign-turn-hook.ts @@ -1,5 +1,5 @@ import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' -import { rowHoldsOtherCodexThread } from '../../../shared/agent-hook-listener/providers/codex-ephemeral-threads' +import { rowHoldsOtherCodexThread } from '../../../shared/agent-hook-listener/providers/codex/codex-ephemeral-threads' import { isStaleGrokTurnEnd } from './server-grok-status-rules' import type { EnrichedAgentHookEventPayload } from './server-types' diff --git a/src/main/agent-hooks/server/server-hydration.ts b/src/main/agent-hooks/server/server-hydration.ts index ebabcfb7ad3..781d5edf6c6 100644 --- a/src/main/agent-hooks/server/server-hydration.ts +++ b/src/main/agent-hooks/server/server-hydration.ts @@ -5,8 +5,8 @@ import { import { seedClaudeLeadTurnFromPersistedStatus, seedClaudeSubagentRosterFromSnapshots -} from '../../../shared/agent-hook-listener/providers/claude-roster-state' -import { seedCodexStateFromSnapshot } from '../../../shared/agent-hook-listener/providers/codex-state' +} from '../../../shared/agent-hook-listener/providers/claude/lifecycle/claude-roster-state' +import { seedCodexStateFromSnapshot } from '../../../shared/agent-hook-listener/providers/codex/codex-state' import { AGENT_STATUS_PERSISTED_HYDRATION_MODE } from '../../../shared/agent-status/transport/agent-status-legacy-adapter' import { AGENT_STATUS_HYDRATE_MAX_AGE_MS as HYDRATE_MAX_AGE_MS } from '../../../shared/agent-status/contracts/agent-status-freshness' import { LAST_STATUS_FILE_VERSION, STATUS_FLUSH_BUSY_TIMEOUT_MS } from './server-constants' diff --git a/src/main/agent-hooks/server/server-ingest-remote.ts b/src/main/agent-hooks/server/server-ingest-remote.ts index 675a68e5aa1..1c4feee87d3 100644 --- a/src/main/agent-hooks/server/server-ingest-remote.ts +++ b/src/main/agent-hooks/server/server-ingest-remote.ts @@ -16,7 +16,7 @@ import { isClaudeCompactCompletionConsumed, markClaudeCompactCompletionConsumed, resolveLegacyCompactTrigger -} from '../../../shared/agent-hook-listener/providers/claude-compact-completion' +} from '../../../shared/agent-hook-listener/providers/claude/lifecycle/claude-compact-completion' import { launchTokenHash } from '../../../shared/agent-hook-listener/transport/agent-hook-spool' import { parsePaneKey } from '../../../shared/workspace-layout/stable-pane-id' import type { IncomingAgentHookEvent } from './server-types' diff --git a/src/main/agent-hooks/server/server-relay-sessions.ts b/src/main/agent-hooks/server/server-relay-sessions.ts index 29daa5c2837..bb8374a8db7 100644 --- a/src/main/agent-hooks/server/server-relay-sessions.ts +++ b/src/main/agent-hooks/server/server-relay-sessions.ts @@ -1,6 +1,6 @@ import { hasShedSubagentsField } from '../../../shared/agent-hook-listener/transport/agent-hook-relay' import { readAgentPaneRootBinding } from '../../../shared/agent-status/process/agent-process-presence' -import type { CodexRemoteRosterAuthority } from '../../../shared/agent-hook-listener/providers/codex-state' +import type { CodexRemoteRosterAuthority } from '../../../shared/agent-hook-listener/providers/codex/codex-state' import type { ParsedAgentStatusPayload } from '../../../shared/agent-status/contracts/agent-status-types' // One binding per pane a relay asked about; a link restart drops them all. diff --git a/src/main/agent-hooks/server/server-status-application.ts b/src/main/agent-hooks/server/server-status-application.ts index e7a625f10ed..6cf5f15661a 100644 --- a/src/main/agent-hooks/server/server-status-application.ts +++ b/src/main/agent-hooks/server/server-status-application.ts @@ -14,7 +14,7 @@ import type { } from '../../../shared/agent-status/contracts/agent-status-observation' import { AGENT_STATUS_2A_CURRENT_PRODUCER_MODE } from '../../../shared/agent-status/transport/agent-status-legacy-adapter' import { mainAgentState } from '../../../shared/agent-status/lifecycle/main-agent-status' -import { previousCodexTurnContext } from '../../../shared/agent-hook-listener/providers/codex-session-start' +import { previousCodexTurnContext } from '../../../shared/agent-hook-listener/providers/codex/codex-session-start' import { admitLegacyAgentStatus } from '../../../shared/agent-hook-listener/listener-state' import type { EnrichedAgentHookEventPayload } from './server-types' import { agentTypeToPromptSentAgentKind } from './server-status-identity' diff --git a/src/main/agent-hooks/server/server-status-inference.ts b/src/main/agent-hooks/server/server-status-inference.ts index c7352198056..ed31c3a116e 100644 --- a/src/main/agent-hooks/server/server-status-inference.ts +++ b/src/main/agent-hooks/server/server-status-inference.ts @@ -2,7 +2,7 @@ import type { RemoteAgentInterruptDispatch } from '../../../shared/agent-hook-li import { markClaudeLeadTurnDone, clearClaudeAnsweredQuestionWait -} from '../../../shared/agent-hook-listener/providers/claude-roster-state' +} from '../../../shared/agent-hook-listener/providers/claude/lifecycle/claude-roster-state' import { isAgentInterruptInputIntent, isNavigationEscapeIntent, diff --git a/src/main/agent-hooks/server/server-status-retries.ts b/src/main/agent-hooks/server/server-status-retries.ts index c23da129c52..8486042e4e9 100644 --- a/src/main/agent-hooks/server/server-status-retries.ts +++ b/src/main/agent-hooks/server/server-status-retries.ts @@ -1,5 +1,5 @@ -import { ClaudeHelperKillPoller } from '../../../shared/agent-hook-listener/providers/claude-helper-kill-poller' -import { pollCodexTranscriptStatus } from '../../../shared/agent-hook-listener/providers/codex-transcript-poll' +import { ClaudeHelperKillPoller } from '../../../shared/agent-hook-listener/providers/claude/transcripts/claude-helper-kill-poller' +import { pollCodexTranscriptStatus } from '../../../shared/agent-hook-listener/providers/codex/codex-transcript-poll' import { normalizeHookPayload } from '../../../shared/agent-hook-listener/normalization/normalize-hook-payload' import { hasPendingAgentResultText, diff --git a/src/main/agent-hooks/server/server-status-update.ts b/src/main/agent-hooks/server/server-status-update.ts index df37b55c600..8bd55299dfb 100644 --- a/src/main/agent-hooks/server/server-status-update.ts +++ b/src/main/agent-hooks/server/server-status-update.ts @@ -6,16 +6,16 @@ import { needsHookRootEvidence, readHookProcessVerdict } from '../../../shared/agent-hook-listener/presence/agent-hook-owner-presence' -import { markCodexLeadTurnInterrupted } from '../../../shared/agent-hook-listener/providers/codex-state' -import { reconcileCodexStatusEvent } from '../../../shared/agent-hook-listener/providers/codex-session-start' +import { markCodexLeadTurnInterrupted } from '../../../shared/agent-hook-listener/providers/codex/codex-state' +import { reconcileCodexStatusEvent } from '../../../shared/agent-hook-listener/providers/codex/codex-session-start' import { resolveAgentStatusIdentity, shouldSuppressInheritedTerminalStatus } from '../../../shared/agent-status/identity/agent-status-identity' import type { EnrichedAgentHookEventPayload, IncomingAgentHookEvent } from './server-types' import type { AgentStatusObservationOrigin } from '../../../shared/agent-status/contracts/agent-status-observation' -import { ClaudeOwedNotificationExpiryTimers } from '../../../shared/agent-hook-listener/providers/claude-owed-notification-expiry-timers' -import { setClaudeMainAgentTurnState } from '../../../shared/agent-hook-listener/providers/claude-roster-state' +import { ClaudeOwedNotificationExpiryTimers } from '../../../shared/agent-hook-listener/providers/claude/background-tasks/claude-owed-notification-expiry-timers' +import { setClaudeMainAgentTurnState } from '../../../shared/agent-hook-listener/providers/claude/lifecycle/claude-roster-state' import { attachClaudePermissionToolUseId, pairedClaudeNonAgentWork, diff --git a/src/main/agent-hooks/server/server-types.ts b/src/main/agent-hooks/server/server-types.ts index 4d69de23568..f538246b821 100644 --- a/src/main/agent-hooks/server/server-types.ts +++ b/src/main/agent-hooks/server/server-types.ts @@ -8,7 +8,7 @@ import type { AgentStatusState } from '../../../shared/agent-status/contracts/ag import type { AgentStatusObservation } from '../../../shared/agent-status/contracts/agent-status-observation' import type { AgentKind } from '../../../shared/telemetry-events' import type { LegacyPaneKeyAliasEntry } from '../../../shared/persisted-state-types' -import type { CodexRemoteRosterAuthority } from '../../../shared/agent-hook-listener/providers/codex-state' +import type { CodexRemoteRosterAuthority } from '../../../shared/agent-hook-listener/providers/codex/codex-state' import type { AgentHookSource } from '../../../shared/agent-hook-listener/transport/agent-hook-relay' export type HookServerStartOptions = { diff --git a/src/main/agent-hooks/server/testing/interruptions/server-claude-helper-recovery.test.ts b/src/main/agent-hooks/server/testing/interruptions/server-claude-helper-recovery.test.ts deleted file mode 100644 index 1311a436db7..00000000000 --- a/src/main/agent-hooks/server/testing/interruptions/server-claude-helper-recovery.test.ts +++ /dev/null @@ -1,463 +0,0 @@ -import { appendFileSync } from 'node:fs' -import { appendFile, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { createServer } from 'node:http' -import { join } from 'node:path' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { RelayAgentHookServer } from '../../../../../wsl-guest/agent-hooks/server/agent-hook-server' -import { AgentHookServer } from '../../agent-hook-server' -import { buildBody, PANE, postHookEvent } from '../server.test-fixtures' -import { ClaudeHelperKillPoller } from '../../../../../shared/agent-hook-listener/providers/claude-helper-kill-poller' -import '../../../../../shared/agent-hook-listener/providers/claude-transcript-decoder' -import '../../../../../shared/agent-hook-listener/transcripts/agent-transcript-poll-scheduler' - -// Complete captured lifecycle records, with runtime replay seeded by real persisted HTTP state. -vi.mock('../../../../telemetry/client', () => ({ track: vi.fn() })) -vi.mock('../../../../telemetry/cohort-classifier', () => ({ - getCohortAtEmit: () => ({ nth_repo_added: 2 }) -})) -const servers: { stop: () => void }[] = [] -const folders: string[] = [] -const realSetTimeout = globalThis.setTimeout -beforeEach(() => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance'] }) -}) -afterEach(async () => { - for (const server of servers.splice(0)) { - server.stop() - } - vi.useRealTimers() - // Only exact test-created mkdtemp paths; no external state is reachable. - for (const folder of folders.splice(0)) { - await rm(folder, { recursive: true, force: true }) - } - vi.restoreAllMocks() -}) -const lines = (records: Record[]) => - records.map((r) => `${JSON.stringify(r)}\n`).join('') -// ast-grep-ignore: test-no-real-time-wait -- Real elapsed gaps separate hook R, provider K and restart S. -const pause = (ms: number) => new Promise((resolve) => realSetTimeout(resolve, ms)) -// Transcript polls run on the fake clock while transcript I/O and wall time stay real. -const settle = (assertion: () => void) => - vi.waitFor( - () => { - vi.advanceTimersByTime(250) - assertion() - }, - { timeout: 3000, interval: 10 } - ) -async function load(stream: string): Promise[]> { - return ( - await readFile( - new URL( - `../../../../../shared/__fixtures__/claude-helper-kill-2.1.310-${stream}.jsonl`, - import.meta.url - ), - 'utf8' - ) - ) - .trim() - .split('\n') - .map((line) => JSON.parse(line)) -} -const hooks = [ - { hook_event_name: 'UserPromptSubmit', prompt: 'work' }, - { hook_event_name: 'SubagentStart', agent_id: 'a123' }, - { - hook_event_name: 'Stop', - last_assistant_message: 'READY', - background_tasks: [ - { id: 'a123', type: 'subagent', status: 'running' }, - { id: 'shell', type: 'shell', status: 'running' } - ] - } -] - -async function setup( - kind: string, - when: string, - options: { - cutoff?: number - killAt?: number - throwOnce?: boolean - disabled?: boolean - beforeStart?: (transcript: string) => void - retryBind?: boolean - busyPort?: number - stopBeforeBind?: boolean - advanceBeforeBind?: () => void - retired?: boolean - } = {} -) { - const inputHooks = options.throwOnce ? [hooks[1]] : hooks - const parent = await load('parent'), - child = await load('child') - let oldKill = parent.find((r) => r.subtype === 'agents_killed')! - const folder = await mkdtemp(join(tmpdir(), 'pr35930-c5-bounded-')) - folders.push(folder) - const transcript = join(folder, 'session.jsonl') - await writeFile(transcript, lines(parent.filter((r) => r !== oldKill))) - await mkdir(join(folder, 'session', 'subagents'), { recursive: true }) - await writeFile(join(folder, 'session', 'subagents', 'agent-a123.jsonl'), lines(child)) - const body = (payload: Record) => - buildBody( - { session_id: 'session', transcript_path: transcript, ...payload }, - { launchToken: 'same-owner' } - ) - const endpointDir = - kind === 'desktop-spool' ? join(folder, 'agent-hooks', 'c5-bounded') : join(folder, 'endpoint') - const startOptions = { env: 'production', userDataPath: folder, endpointNamespace: 'c5-bounded' } - // Desktop restart is seeded by actual HTTP admission/persistence, not injected snapshot roster. - if (kind === 'desktop-spool') { - const previous = new AgentHookServer() - servers.push(previous) - await previous.start(startOptions) - for (const h of inputHooks) { - expect((await postHookEvent(previous, body(h))).status).toBe(204) - } - expect(previous.getStatusSnapshotForPane(PANE)[0]?.subagents?.[0]?.state).toBe('working') - previous.flushStatusPersistSync() - previous.stop() - } - if (kind.endsWith('spool')) { - await mkdir(join(endpointDir, 'spool'), { recursive: true }) - // Actual failed-hook emission while stopped: use the shell writer's second floor. - const receivedAt = options.cutoff ?? Math.floor(Date.now() / 1000) * 1000 - await writeFile( - join(endpointDir, 'spool', 'claude.jsonl'), - lines( - inputHooks.map((h) => ({ - ...body(h), - source: 'claude', - launchToken: 'same-owner', - receivedAt - })) - ) - ) - } - if (when === 'before-start') { - // ast-grep-ignore: test-no-real-time-wait -- Provider K must follow failed-hook R with real I/O. - await pause(5) - oldKill = { ...oldKill, timestamp: new Date(options.killAt ?? Date.now()).toISOString() } - await appendFile(transcript, lines([oldKill])) - // ast-grep-ignore: test-no-real-time-wait -- Restart S must follow provider K without mocking host time. - await pause(20) - } - options.beforeStart?.(transcript) - const desktop = new AgentHookServer() - servers.push(desktop) - if (options.retired) { - desktop.dropStatusEntriesByTabPrefix('tab-1') - } - let prebind = false, - threw = false - const beforeBind = () => { - if (when === 'accepted-before-bind' && !prebind) { - prebind = true - appendFileSync( - transcript, - lines([{ ...oldKill, timestamp: new Date(Date.now()).toISOString() }]) - ) - options.advanceBeforeBind?.() - if (options.stopBeforeBind) { - if (kind.startsWith('desktop')) { - desktop.stop() - } else { - relay.stop() - } - } - } - } - desktop.setListener((event) => { - if (options.throwOnce && !threw && event.hookEventName === 'SubagentStart') { - threw = true - appendFileSync( - transcript, - lines([{ ...oldKill, timestamp: new Date(Date.now()).toISOString() }]) - ) - throw new Error('test outward listener') - } - if (event.hookEventName !== 'Stop') { - return - } - if (kind === 'desktop-spool') { - if (!options.disabled) { - expect(desktop['server']).toBe(null) - } - beforeBind() - } - }) - const relay = new RelayAgentHookServer({ - endpointDir, - token: 'c5-bounded', - preferredPort: options.retryBind ? -1 : options.busyPort, - isPaneSurfaceRetired: () => options.retired === true, - forward: (e) => { - desktop.ingestRemote(e, 'connection') - if (kind === 'relay-spool' && e.hookEventName === 'Stop') { - beforeBind() - } - } - }) - servers.push(relay) - const start = () => - kind.startsWith('desktop') - ? desktop.start({ ...startOptions, statusHooksEnabled: !options.disabled }) - : relay.start({ publishEndpoint: false }) - if (options.retryBind || (options.throwOnce && kind === 'desktop-spool')) { - await expect(start()).rejects.toThrow() - const observer = kind.startsWith('desktop') - ? desktop['claudeHelperKills'] - : relay['retryScheduler']['claudeHelperKills'] - const retained = observer['observations'].get(PANE)! - expect(retained.parent.offset).toBe(0) - expect(observer['scheduler']['entries'].size).toBe(0) - if (options.retryBind) { - Reflect.set(relay, 'preferredPort', 0) - } - await start() - expect(observer['observations'].get(PANE)).toBe(retained) - } else { - await start() - } - const post = async (payload: Record, extra: Record = {}) => { - if (kind.startsWith('desktop')) { - expect((await postHookEvent(desktop, { ...body(payload), ...extra })).status).toBe(204) - } else { - const { port, token } = relay.getCoordinates() - expect( - ( - await fetch(`http://127.0.0.1:${port}/hook/claude`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-Orca-Agent-Hook-Token': token }, - body: JSON.stringify({ ...body(payload), ...extra }) - }) - ).status - ).toBe(204) - } - } - if (!kind.endsWith('spool')) { - for (const h of inputHooks) { - await post(h) - } - } - const poller = kind.startsWith('desktop') - ? desktop['claudeHelperKills'] - : relay['retryScheduler']['claudeHelperKills'] - const entries = poller['observations'] - return { - desktop, - relay, - poller, - post, - transcript, - oldKill, - entries, - prebind, - threw, - row: () => desktop.getStatusSnapshotForPane(PANE)[0] - } -} - -it.each([ - ['relay-spool', 'before-start'], - ['relay-spool', 'accepted-before-bind'], - ['desktop-spool', 'before-start'], - ['desktop-spool', 'accepted-before-bind'] -])('delivers the first kill through %s %s, then wakes retained evidence', async (kind, when) => { - const clock = - when === 'accepted-before-bind' ? vi.spyOn(Date, 'now').mockReturnValue(Date.now()) : undefined - const acceptedAt = Date.now() - const p = await setup(kind, when, { - advanceBeforeBind: () => clock?.mockReturnValue(acceptedAt + 10) - }) - const before = p.row()! - await settle(() => expect(p.row()?.subagents ?? []).toEqual([])) - expect(p.row()?.workingMode).toBe('monitoring') - expect(p.row()?.mainAgent).toEqual(before.mainAgent) - expect(p.row()?.lastAssistantMessage).toBe('READY') - expect(p.row()?.turnEndedAt).toBe(before.turnEndedAt) - const owner = p.entries.get(PANE)! - const offset = owner.parent.offset - // A delayed hook is the legitimate parked wake. No second kill is appended. - await p.post(hooks[1]) - await settle(() => expect(p.row()?.subagents ?? []).toEqual([])) - expect(p.entries.get(PANE)).toBe(owner) - expect(owner.parent.offset).toBe(offset) - if (when === 'accepted-before-bind') { - expect(p.prebind).toBe(true) - } -}) - -it.each(['desktop-http', 'relay-http', 'desktop-spool', 'relay-spool'])( - 'retains accepted observation when %s fanout throws', - async (kind) => { - const p = await setup(kind, 'accepted-before-bind', { throwOnce: true }) - expect(p.threw).toBe(true) - expect(p.entries.size).toBe(1) - await settle(() => expect(p.row()?.subagents ?? []).toEqual([])) - expect(p.row()?.state).toBe('working') // Unknown root remains live; no parent hook was supplied. - } -) - -it.each(['relay-spool', 'desktop-spool'])( - 'keeps history and genuine within-second generations on %s', - async (kind) => { - const R = Math.floor(Date.now() / 1000) * 1000 - const p = await setup(kind, 'before-start', { cutoff: R, killAt: R - 1001 }) - await settle(() => expect(p.entries.get(PANE)?.parent.offset).toBeGreaterThan(0)) - expect(p.entries.get(PANE)?.killedAt).toBeUndefined() - expect(p.row()?.subagents?.[0]?.state).toBe('working') - const child = join(p.transcript.slice(0, -6), 'subagents', 'agent-a123.jsonl') - await appendFile( - child, - lines([ - { - type: 'user', - timestamp: new Date(R + 520).toISOString(), - message: { content: [{ type: 'text', text: 'Resume the work' }] } - } - ]) - ) - await appendFile( - p.transcript, - lines([{ ...p.oldKill, timestamp: new Date(R + 500).toISOString() }]) - ) - await settle(() => expect(p.entries.get(PANE)?.killedAt).toBe(R + 500)) - expect(p.row()?.subagents?.[0]?.state).toBe('working') - // R's precision admits K, but only provider generation decides whether this work ends. - await appendFile( - p.transcript, - lines([{ ...p.oldKill, timestamp: new Date(R + 600).toISOString() }]) - ) - await settle(() => expect(p.row()?.subagents ?? []).toEqual([])) - } -) - -it('initialization of an already-listening desktop activates parked runtime spool', async () => { - const p = await setup('desktop-spool', 'before-start', { disabled: true }) - expect(p.entries.size).toBe(0) - p.desktop.setStatusHooksEnabled(true) - await settle(() => { - expect(p.entries.size).toBe(1) - expect(p.row()?.subagents ?? []).toEqual([]) - expect(p.row()?.workingMode).toBe('monitoring') - }) -}) - -it('caps future spool cutoffs and keeps HTTP cutoff provenance private', async () => { - const S = Date.now() - const p = await setup('relay-spool', 'before-start', { cutoff: S + 60000 }) - expect(p.entries.get(PANE)?.observedAt).toBeLessThan(S + 60000) - await appendFile(p.transcript, lines([{ ...p.oldKill, timestamp: new Date().toISOString() }])) - await settle(() => expect(p.row()?.subagents ?? []).toEqual([])) - const q = await setup('desktop-http', 'after-start') - await q.post(hooks[2], { receivedAt: 0, hookReceivedAt: 0 }) - expect(q.entries.get(PANE)?.observedAt).toBeGreaterThan(S - 1000) - await appendFile( - q.transcript, - lines([{ ...q.oldKill, timestamp: new Date(S - 10000).toISOString() }]) - ) - await settle(() => expect(q.entries.get(PANE)?.parent.offset).toBeGreaterThan(0)) - expect(q.row()?.subagents?.[0]?.state).toBe('working') -}) - -it('a real failed bind parks without resources and explicit retry delivers its first fact', async () => { - const p = await setup('relay-spool', 'before-start', { retryBind: true }) - await settle(() => expect(p.row()?.subagents ?? []).toEqual([])) - expect(p.row()?.workingMode).toBe('monitoring') -}) - -it('a held local child permission cannot reset the admitted owner', async () => { - const p = await setup('desktop-http', 'after-start') - await p.post({ - hook_event_name: 'PermissionRequest', - agent_id: 'a123', - tool_name: 'Bash', - tool_use_id: 'approval', - tool_input: { command: 'false' } - }) - const owner = p.entries.get(PANE) - const capture = vi.spyOn(ClaudeHelperKillPoller.prototype, 'schedule') - await p.post({ - hook_event_name: 'PreToolUse', - tool_name: 'Bash', - tool_use_id: 'unrelated', - tool_input: { command: 'true' } - }) - expect(p.row()?.state).toBe('waiting') - expect(capture).not.toHaveBeenCalled() - expect(p.entries.get(PANE)).toBe(owner) - await appendFile(p.transcript, lines([{ ...p.oldKill, timestamp: new Date().toISOString() }])) - await settle(() => expect(p.row()?.subagents ?? []).toEqual([])) -}) - -it('preferred-port fallback activates the original admitted observation', async () => { - const busy = createServer() - await new Promise((resolve) => busy.listen(0, '127.0.0.1', resolve)) - try { - const address = busy.address() - if (!address || typeof address === 'string') { - throw new Error('missing listener address') - } - const p = await setup('relay-spool', 'before-start', { busyPort: address.port }) - expect(p.relay.usedPortFallback).toBe(true) - await settle(() => expect(p.row()?.subagents ?? []).toEqual([])) - expect(p.row()?.workingMode).toBe('monitoring') - } finally { - await new Promise((resolve) => busy.close(() => resolve())) - } -}) - -it.each(['desktop-http', 'relay-http'])( - 'cached SessionStart activation on %s keeps its boundary and first later kill', - async (kind) => { - const p = await setup(kind, 'after-start') - const S = Date.now() - const clock = vi.spyOn(Date, 'now').mockReturnValue(S) - await p.post({ hook_event_name: 'SessionStart', source: 'resume' }) - const owner = p.entries.get(PANE)! - expect(owner.observedAt).toBe(S) - clock.mockReturnValue(S + 10) - p.poller.activate() - p.poller.activate() - expect(p.entries.get(PANE)).toBe(owner) - expect(owner.observedAt).toBe(S) - await p.post(hooks[1]) - await appendFile( - p.transcript, - lines([{ ...p.oldKill, timestamp: new Date(S + 1).toISOString() }]) - ) - await settle(() => expect(p.row()?.subagents ?? []).toEqual([])) - } -) - -it('reentrant mirror and OSC writes cannot borrow local hook admission', async () => { - const p = await setup('desktop-http', 'after-start') - const other = PANE.replace('111111111111', '222222222222') - let reentered = false - p.desktop.subscribeStatusRowMutations(() => { - if (reentered) { - return - } - reentered = true - p.desktop.ingestRemote( - { - paneKey: other, - providerSession: { key: 'session_id', id: 'other', transcriptPath: p.transcript }, - payload: { agentType: 'claude', state: 'working' } - }, - 'other-host' - ) - p.desktop.ingestTerminalStatus({ - paneKey: other, - payload: { agentType: 'claude', state: 'working', prompt: '' } - }) - }) - await p.post({ hook_event_name: 'UserPromptSubmit', prompt: 'another real turn' }) - expect(reentered).toBe(true) - expect(p.entries.size).toBe(1) - expect(p.desktop.getStatusSnapshotForPane(other)[0]?.state).toBe('working') - await appendFile(p.transcript, lines([{ ...p.oldKill, timestamp: new Date().toISOString() }])) - await settle(() => expect(p.row()?.subagents ?? []).toEqual([])) -}) diff --git a/src/main/agent-hooks/server/testing/task-wakeup/server-claude-owed-notification-expiry.test.ts b/src/main/agent-hooks/server/testing/task-wakeup/server-claude-owed-notification-expiry.test.ts deleted file mode 100644 index 0c9aaed459f..00000000000 --- a/src/main/agent-hooks/server/testing/task-wakeup/server-claude-owed-notification-expiry.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -// No hook fires at lease expiry, so the server must release owed-notification holds itself. -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { CLAUDE_OWED_TASK_NOTIFICATION_LEASE_MS } from '../../../../../shared/agent-hook-listener/providers/claude-owed-task-notifications' -import { AgentHookServer, _internals } from '../../agent-hook-server' -import { buildBody, PANE, postHookEvent, RUNNING_SHELL } from '../server.test-fixtures' - -vi.mock('../../../../telemetry/client', () => ({ track: vi.fn() })) -vi.mock('../../../../telemetry/cohort-classifier', () => ({ - getCohortAtEmit: vi.fn(() => ({ nth_repo_added: 2 })) -})) -// The fixture's owner process is fictional; the pane's owning Claude is still running. -vi.mock('../../../../../shared/agent-status/process/agent-process-presence-probe', () => ({ - probeAgentProcessPresence: vi.fn(async () => 'live') -})) - -beforeEach(() => { - _internals.resetCachesForTests() - // Real loopback POSTs need fake time to advance. - vi.useFakeTimers({ - shouldAdvanceTime: true, - toFake: ['setTimeout', 'clearTimeout', 'Date', 'performance'] - }) -}) - -afterEach(() => { - vi.useRealTimers() -}) - -describe('Claude owed task notification expiry on the hook server', () => { - it('settles a pane whose launched shell vanished without a notification', async () => { - const server = new AgentHookServer() - await server.start({ env: 'production' }) - const states: string[] = [] - server.setListener((event) => states.push(event.payload.state)) - const post = (payload: Record) => - postHookEvent(server, buildBody({ session_id: 'session-1', ...payload })) - try { - await post({ hook_event_name: 'UserPromptSubmit', prompt: 'start the dev server' }) - await post({ - hook_event_name: 'PostToolUse', - tool_name: 'Bash', - tool_response: { backgroundTaskId: RUNNING_SHELL.id } - }) - await post({ hook_event_name: 'Stop', background_tasks: [RUNNING_SHELL] }) - await post({ hook_event_name: 'UserPromptSubmit', prompt: 'thanks' }) - await post({ hook_event_name: 'Stop', background_tasks: [] }) - expect(server.getStatusSnapshotForPane(PANE)[0]?.state).toBe('working') - - vi.advanceTimersByTime(CLAUDE_OWED_TASK_NOTIFICATION_LEASE_MS) - - expect(server.getStatusSnapshotForPane(PANE)[0]?.state).toBe('done') - expect(states.at(-1)).toBe('done') - } finally { - server.stop() - } - }) - - it('drops what was owed when the owning Claude process exits', async () => { - const server = await heldByAnOwedShell() - try { - await server.post({ hook_event_name: 'SessionEnd', reason: 'prompt_input_exit' }, OWNER) - vi.advanceTimersByTime(CLAUDE_OWED_TASK_NOTIFICATION_LEASE_MS) - - expect(server.row()).toMatchObject({ providerSessionOnly: true }) - } finally { - server.stop() - } - }) - - it('keeps what is owed when a nested Claude in the same pane exits', async () => { - const server = await heldByAnOwedShell() - try { - await server.post({ hook_event_name: 'SessionEnd', reason: 'prompt_input_exit' }, 5151) - expect(server.row()).toMatchObject({ state: 'working' }) - expect(server.row().providerSessionOnly).toBeUndefined() - - vi.advanceTimersByTime(CLAUDE_OWED_TASK_NOTIFICATION_LEASE_MS) - expect(server.row()).toMatchObject({ state: 'done' }) - } finally { - server.stop() - } - }) -}) - -const OWNER = 4242 - -async function heldByAnOwedShell() { - const server = new AgentHookServer() - await server.start({ env: 'production' }) - const agentProcess = (pid: number) => - JSON.stringify({ pid, platform: 'darwin', startTime: 'Fri Oct 2 12:00:00 2026' }) - const post = (payload: Record, pid = OWNER) => - postHookEvent( - server, - buildBody({ session_id: 'session-1', ...payload }, { agentProcess: agentProcess(pid) }) - ) - await post({ hook_event_name: 'UserPromptSubmit', prompt: 'start the dev server' }) - await post({ - hook_event_name: 'PostToolUse', - tool_name: 'Bash', - tool_response: { backgroundTaskId: RUNNING_SHELL.id } - }) - await post({ hook_event_name: 'Stop', background_tasks: [RUNNING_SHELL] }) - await post({ hook_event_name: 'UserPromptSubmit', prompt: 'thanks' }) - await post({ hook_event_name: 'Stop', background_tasks: [] }) - const row = () => { - const entry = server.getStatusSnapshotForPane(PANE)[0] - if (!entry) { - throw new Error('the pane has no row') - } - return entry - } - return { post, row, stop: () => server.stop() } -} diff --git a/src/main/agent-hooks/tool-events/agent-tool-call-reader.ts b/src/main/agent-hooks/tool-events/agent-tool-call-reader.ts index 3527e45afc6..20d680f5a4c 100644 --- a/src/main/agent-hooks/tool-events/agent-tool-call-reader.ts +++ b/src/main/agent-hooks/tool-events/agent-tool-call-reader.ts @@ -8,7 +8,7 @@ import { normalizeCopilotEventName, readCopilotToolCall, resolveCopilotEventName -} from '../../../shared/agent-hook-listener/providers/copilot-tool-fields' +} from '../../../shared/agent-hook-listener/providers/copilot/copilot-tool-fields' import type { agentToolAfterHookPayloadSchema } from '../../../shared/plugins/events/plugin-hook-payloads' const BEFORE_EVENTS = new Set([ diff --git a/src/main/runtime/terminal/wait/tui-idle-hook-lane-runtime.test.ts b/src/main/runtime/terminal/wait/tui-idle-hook-lane-runtime.test.ts index fc14365be28..1a30a96daaf 100644 --- a/src/main/runtime/terminal/wait/tui-idle-hook-lane-runtime.test.ts +++ b/src/main/runtime/terminal/wait/tui-idle-hook-lane-runtime.test.ts @@ -1,13 +1,6 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' import { describe, expect, it, vi, afterEach } from 'vitest' -import { - createTranscriptPane, - TRANSCRIPT_PANE_PTY_ID, - waitForTranscriptIdle -} from '../../orca-runtime-tests/agent-transcript-pane-test-harness' -import { createHookListenerState } from '../../../../shared/agent-hook-listener/listener-state' -import { normalizeHermesEvent } from '../../../../shared/agent-hook-listener/providers/hermes-events' +import { createTranscriptPane } from '../../orca-runtime-tests/agent-transcript-pane-test-harness' + import type { AgentStatusIpcPayload } from '../../../../shared/agent-status/contracts/agent-status-ipc-payload' import { makePaneKey } from '../../../../shared/workspace-layout/stable-pane-id' import type { TuiAgent } from '../../../../shared/agent-launch/catalog/tui-agent' @@ -20,182 +13,6 @@ vi.mock('electron', () => ({ app: { getPath: vi.fn(() => '/tmp') } })) -describe('Hermes captured hook readiness', () => { - const captured = readFileSync( - join(__dirname, '../../__fixtures__', 'hermes-tui-ready.txt'), - 'utf8' - ) - - /** Run managed Hermes payloads through the real normalizer so event-mapping regressions fail at this runtime boundary too. */ - const status = (eventName: string, payload: Record = {}): string => { - const parsed = normalizeHermesEvent(createHookListenerState(), eventName, '', 'pane-1', payload) - expect(parsed, `hermes plugin event ${eventName} must normalize to a status`).not.toBeNull() - return `\x1b]9999;${JSON.stringify(parsed)}\x07` - } - - const readyPane = async () => { - const pane = await createTranscriptPane({ - paneTitle: 'Hermes Agent', - foregroundProcess: 'hermes', - launchAgent: 'hermes', - size: { cols: 120, rows: 31 }, - data: captured - }) - await pane.runtime.readTerminal(pane.handle, { screen: true }) - return pane - } - - describe('Hermes TUI readiness from a captured PTY', () => { - it('settles tui-idle on a ready composer after the session-boundary hook', async () => { - const { runtime, handle } = await readyPane() - // What Orca's own managed plugin sends for `on_session_start`. - runtime.onPtyData( - TRANSCRIPT_PANE_PTY_ID, - status('on_session_start', { session_id: 's1' }), - Date.now() - ) - - const read = vi.spyOn(runtime, 'readTerminal') - await expect( - runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 2_000 }) - ).resolves.toMatchObject({ satisfied: true }) - // The retained screen decides without a provider screen round trip. - expect(read.mock.calls.filter(([, options]) => options?.screen === true)).toEqual([]) - }, 5_000) - - it('settles through a leaf handle whose retained tail never showed the ready screen', async () => { - const { runtime } = await readyPane() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Access the real synced leaf and handle issuer for this route regression. - const internals = runtime as unknown as { - leaves: Map - issueHandle: (leaf: unknown) => string - } - const leaf = internals.leaves.values().next().value - expect(leaf).toBeDefined() - expect([...leaf!.tailBuffer, leaf!.tailPartialLine].join('\n')).not.toMatch(/\bready\b/i) - runtime.onPtyData( - TRANSCRIPT_PANE_PTY_ID, - status('on_session_start', { session_id: 's1' }), - Date.now() - ) - - await expect( - runtime.waitForTerminal(internals.issueHandle(leaf), { - condition: 'tui-idle', - timeoutMs: 2_000 - }) - ).resolves.toMatchObject({ satisfied: true }) - }, 5_000) - - it('does not settle while a turn the user started is still running', async () => { - const { runtime, handle } = await readyPane() - runtime.onPtyData( - TRANSCRIPT_PANE_PTY_ID, - status('on_session_start', { session_id: 's1' }), - Date.now() - ) - // `pre_llm_call` — the first event that means a turn actually began. - runtime.onPtyData( - TRANSCRIPT_PANE_PTY_ID, - status('pre_llm_call', { user_message: 'hi' }), - Date.now() - ) - - await expect( - runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 550 }) - ).rejects.toThrow('timeout') - }, 3_000) - - it('does not settle on a turn-end done row without a ready screen', async () => { - const { runtime, handle } = await createTranscriptPane({ - paneTitle: 'Hermes Agent', - foregroundProcess: 'hermes', - launchAgent: 'hermes', - data: '' - }) - // `post_llm_call` lands `done` without the boundary flag; the #6011 rule still applies. - runtime.onPtyData( - TRANSCRIPT_PANE_PTY_ID, - status('post_llm_call', { session_id: 's1' }), - Date.now() - ) - - await expect( - runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 550 }) - ).rejects.toThrow('timeout') - }, 3_000) - }) -}) - -describe('Claude indexed task wakeup readiness', () => { - const PANE = 'tab-1:11111111-1111-4111-8111-111111111111' - const ready = readFileSync( - join(import.meta.dirname, '../../__fixtures__/claude-ready-task-wakeup.txt'), - 'utf8' - ) - - function owedRow(paneKey: string): AgentStatusIpcPayload { - const now = Date.now() - return { - paneKey, - state: 'working', - mainAgent: { state: 'done', stateStartedAt: now }, - agentType: 'claude', - prompt: '', - connectionId: null, - launchToken: 'transcript-launch', - claudeTaskWakeupPending: 'notification', - providerSession: { key: 'session_id', id: 'session-a' }, - observation: { - origin: 'hook', - authorityId: 'host-a', - incarnation: 1, - revision: 1, - observedAt: now - }, - receivedAt: now, - stateStartedAt: now - } - } - - afterEach(() => vi.useRealTimers()) - - describe('Claude readiness indexed status reads', () => { - it.each([true, false])( - 'reads only its pane with 512 unrelated agents, including an empty index: pending=%s', - async (pending) => { - const unrelated = Array.from({ length: 512 }, (_, index) => owedRow(`other:${index}`)) - const row = owedRow(PANE) - const full = vi.fn(() => [row, ...unrelated]) - const indexed = vi.fn((paneKey: string) => (pending && paneKey === PANE ? [row] : [])) - const pane = await createTranscriptPane( - { - paneTitle: 'Terminal', - foregroundProcess: 'claude', - data: ready, - launchAgent: 'claude' - }, - { getAgentStatusSnapshot: full, getAgentStatusSnapshotForPane: indexed } - ) - row.receivedAt = Date.now() - full.mockClear() - indexed.mockClear() - try { - const waiting = waitForTranscriptIdle(pane, 2_000) - await (pending - ? expect(waiting).rejects.toThrow(/timeout/) - : expect(waiting).resolves.toMatchObject({ satisfied: true })) - expect(indexed).toHaveBeenCalled() - expect(new Set(indexed.mock.calls.map(([paneKey]) => paneKey))).toEqual(new Set([PANE])) - expect(full).not.toHaveBeenCalled() - } finally { - pane.runtime.onPtyExit(TRANSCRIPT_PANE_PTY_ID, 0) - } - } - ) - }) -}) - describe('Canonical hook status readiness', () => { // The harness pane's identity (agent-transcript-pane-test-harness.ts). const PANE_KEY = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') @@ -213,14 +30,7 @@ describe('Canonical hook status readiness', () => { '' ].join('\r\n') // OpenCode 1.18's question tool, which paints no dialog wording the blocked layer knows. - const OPENCODE_QUESTION = [ - ' ┃ Which color do you prefer?', - ' ┃ 1. Red', - ' ┃ 2. Blue', - ' ┃ 3. Type your own answer', - ' ┃ ↑↓ select enter submit esc dismiss', - '' - ].join('\r\n') + // Shorter than the 2 s poll, so only the synchronous verdict can settle a wait. const WAIT_MS = 150 @@ -280,63 +90,7 @@ describe('Canonical hook status readiness', () => { ).toBe('ready') }) - it('ignores the row the PTY id held before a respawn', async () => { - const before = Date.now() - 1000 - const rows = (): AgentStatusIpcPayload[] => [ - row({ receivedAt: before, stateStartedAt: before }) - ] - expect(await waitOutcome({ rows })).toBe('ready') - expect( - await waitOutcome({ - rows, - afterCreate: (runtime) => - runtime.synchronizePtyOutputSequenceFromProvider(TRANSCRIPT_PANE_PTY_ID, { - value: 0, - generation: 'reset' - }) - }) - ).toBe('timeout') - }) - // Input starts a possible new turn, including typed agent restarts; prior done hooks cannot settle it. - it.each([ - ['a prompt Orca sent', 'next task\r'], - ['the user restarting the agent in the same shell', 'pi\r'] - ])('reads no done from before %s', async (_label, input) => { - const before = Date.now() - 1000 - const rows = (): AgentStatusIpcPayload[] => [ - row({ receivedAt: before, stateStartedAt: before }) - ] - const typeAt = (at: number) => (runtime: OrcaRuntimeService) => { - runtime.terminalRunFacts.recordInput(TRANSCRIPT_PANE_PTY_ID, 'driving', input, at) - } - expect(await waitOutcome({ rows, afterCreate: typeAt(before - 1) })).toBe('ready') - expect(await waitOutcome({ rows, afterCreate: typeAt(before + 1) })).toBe('timeout') - }) - - it('reads no done from before a prompt sent just ahead of an adoption of the running agent', async () => { - const before = Date.now() - 1000 - const rows = (): AgentStatusIpcPayload[] => [ - row({ receivedAt: before, stateStartedAt: before }) - ] - expect( - await waitOutcome({ - rows, - afterCreate: (runtime) => { - runtime.terminalRunFacts.recordInput( - TRANSCRIPT_PANE_PTY_ID, - 'driving', - 'next\r', - before + 1 - ) - runtime.noteTerminalSpawnCommit({ - id: TRANSCRIPT_PANE_PTY_ID, - agentSessionEnsure: { disposition: 'adopted' } - }) - } - }) - ).toBe('timeout') - }) // Pi brackets each message in OSC 133 zones itself, so a command marker is no process boundary. it('keeps the row while the agent paints its own shell-integration markers', async () => { @@ -366,22 +120,5 @@ describe('Canonical hook status readiness', () => { }) ).toBe('ready') }) - - it('blocks on an open question the hook reports, and settles once it is answered', async () => { - const options = { launchAgent: 'opencode' as const, data: OPENCODE_QUESTION } - expect(await waitOutcome({ ...options, rows: () => [] })).toBe('timeout') - expect( - await waitOutcome({ - ...options, - rows: () => [row({ agentType: 'opencode', state: 'waiting', receivedAt: Date.now() + 1 })] - }) - ).toBe('blocked:agent-interactive-prompt') - expect( - await waitOutcome({ - ...options, - rows: () => [row({ agentType: 'opencode', receivedAt: Date.now() + 1 })] - }) - ).toBe('ready') - }) }) }) diff --git a/src/shared/agent-hook-listener/claude-tool-request-identity.ts b/src/shared/agent-hook-listener/claude-tool-request-identity.ts index 9ac8daea5eb..50a8062db1e 100644 --- a/src/shared/agent-hook-listener/claude-tool-request-identity.ts +++ b/src/shared/agent-hook-listener/claude-tool-request-identity.ts @@ -5,7 +5,7 @@ import { deriveToolInputPreview, readString } from './tool-input-preview' import { readClaudeBatchSettledRequests } from './claude-settled-permission-requests' import { extractPromptText } from './prompt-fields' import { hasExplicitUserPrompt } from './provider-event-routing' -import { CLAUDE_NEW_SESSION_START_SOURCES } from './providers/claude-session-start-sources' +import { CLAUDE_NEW_SESSION_START_SOURCES } from './providers/claude/lifecycle/claude-session-start-sources' /** The fields that tie a Claude PermissionRequest to the PreToolUse it is asking about. */ export type ClaudeToolRequestFields = { diff --git a/src/shared/agent-hook-listener/grok-result-discovery.ts b/src/shared/agent-hook-listener/grok-result-discovery.ts index 7d13902a460..ebc1b8c30f7 100644 --- a/src/shared/agent-hook-listener/grok-result-discovery.ts +++ b/src/shared/agent-hook-listener/grok-result-discovery.ts @@ -16,7 +16,7 @@ import { parseAgentHookJson } from './request-body' import { GROK_HOME_ENVELOPE_MAX_LENGTH, GROK_SESSION_CWD_MAX_LENGTH } from './listener-limits' import { readLastAssistantFromTranscriptOnce } from './transcript-reader' import { isGrokEvent } from './provider-event-names' -import { isAntigravityStopStillBusy } from './providers/antigravity-event-rules' +import { isAntigravityStopStillBusy } from './providers/antigravity/antigravity-event-rules' export function parseHookBodyPayloadRecord(body: unknown): Record | null { if (typeof body !== 'object' || body === null) { diff --git a/src/shared/agent-hook-listener/listener-state.ts b/src/shared/agent-hook-listener/listener-state.ts index 78409182259..b50e64ad0c2 100644 --- a/src/shared/agent-hook-listener/listener-state.ts +++ b/src/shared/agent-hook-listener/listener-state.ts @@ -9,12 +9,12 @@ import { type AgentStatusLegacyAdmissionMode } from '../agent-status/transport/agent-status-legacy-adapter' import type { AgentStatusLegacyIngressCaller } from '../agent-status/transport/agent-status-legacy-ingress-manifest' -import type { ClaudeLaunchedBackgroundTasks } from './providers/claude-owed-task-notifications' +import type { ClaudeLaunchedBackgroundTasks } from './providers/claude/background-tasks/claude-owed-task-notifications' import type { ClaudeSubagentRoster } from '../claude-subagent-roster' import type { ClaudeToolRequest } from './claude-tool-request-identity' import type { CodexSubagentRoster } from '../codex-subagent-roster' import type { CodexSubagentTranscriptState } from '../codex-subagent-transcript' -import type { CodexAsyncQuestions } from './providers/codex-async-questions' +import type { CodexAsyncQuestions } from './providers/codex/codex-async-questions' import type { MuseSessionLogState } from '../muse-session-log' import type { AgentHookEventPayload, ToolSnapshot } from './listener-event' import { @@ -24,7 +24,7 @@ import { movePaneScopedSetEntries } from './pane-scoped-cache-entries' import type { JcodeUserPromptEvidence } from '../jcode-session-files' -import type { JcodeHelperPermissionWait } from './providers/jcode-helper-permission-wait' +import type { JcodeHelperPermissionWait } from './providers/jcode/jcode-helper-permission-wait' import type { HookRootAttempt } from './presence/hook-process-root-evidence' import { moveOpenCodeSessionBindings, diff --git a/src/shared/agent-hook-listener/normalization/agent-hook-listener-codex-interrupt.test.ts b/src/shared/agent-hook-listener/normalization/agent-hook-listener-codex-interrupt.test.ts index 4b773277bb8..2b28d839b91 100644 --- a/src/shared/agent-hook-listener/normalization/agent-hook-listener-codex-interrupt.test.ts +++ b/src/shared/agent-hook-listener/normalization/agent-hook-listener-codex-interrupt.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it } from 'vitest' import { createHookListenerState, type HookListenerState } from '../listener-state' import { normalizeHookPayload } from './normalize-hook-payload' -import '../providers/codex-state' +import '../providers/codex/codex-state' import { PANE_KEY } from './agent-hook-listener-test-harness' // Payload shape from codex-rs/hooks/schema/generated/interrupt.command.input.schema.json. diff --git a/src/shared/claude-background-task-status.test.ts b/src/shared/agent-hook-listener/normalization/claude-background-task-status.test.ts similarity index 54% rename from src/shared/claude-background-task-status.test.ts rename to src/shared/agent-hook-listener/normalization/claude-background-task-status.test.ts index 4a57728f280..ff91f384a8a 100644 --- a/src/shared/claude-background-task-status.test.ts +++ b/src/shared/agent-hook-listener/normalization/claude-background-task-status.test.ts @@ -1,17 +1,12 @@ import { describe, expect, it } from 'vitest' import { - clearAllListenerCaches, - clearPaneCacheState, createHookListenerState, movePaneCacheState, type HookListenerState -} from './agent-hook-listener/listener-state' -import { - markClaudeLeadTurnInterrupted, - seedClaudeSubagentRosterFromSnapshots -} from './agent-hook-listener/providers/claude-roster-state' -import { normalizeHookPayload } from './agent-hook-listener/normalization/normalize-hook-payload' -import { makePaneKey } from './workspace-layout/stable-pane-id' +} from '../listener-state' +import { seedClaudeSubagentRosterFromSnapshots } from '../providers/claude/lifecycle/claude-roster-state' +import { normalizeHookPayload } from './normalize-hook-payload' +import { makePaneKey } from '../../workspace-layout/stable-pane-id' const SOURCE_PANE = makePaneKey('tab-source', '11111111-1111-4111-8111-111111111111') const TARGET_PANE = makePaneKey('tab-target', '22222222-2222-4222-8222-222222222222') @@ -30,46 +25,6 @@ function claudeEvent(state: HookListenerState, paneKey: string, payload: Record< describe('Claude background task status', () => { // SDK local_* names share inventory classification; teammate tasks stay running permanently, so lifecycle events own their pane state (#8825). - it('drains the Claude 2.1.229 background-task completion sequence', () => { - const state = createHookListenerState() - - expect( - claudeEvent(state, SOURCE_PANE, { - hook_event_name: 'UserPromptSubmit', - prompt: 'run a background sleep' - }) - ).toMatchObject({ state: 'working', workingMode: undefined }) - expect( - claudeEvent(state, SOURCE_PANE, { - hook_event_name: 'Stop', - background_tasks: [RUNNING_SHELL] - }) - ).toMatchObject({ state: 'working', workingMode: 'monitoring' }) - expect( - claudeEvent(state, SOURCE_PANE, { - hook_event_name: 'SubagentStop', - agent_id: 'a70fdf2986e38302b', - background_tasks: [RUNNING_SHELL] - }) - ).toMatchObject({ state: 'working', workingMode: 'monitoring' }) - - expect( - claudeEvent(state, SOURCE_PANE, { - hook_event_name: 'UserPromptSubmit', - prompt: - 'b8rs2wmxgcompleted' - }) - ).toMatchObject({ state: 'working', workingMode: undefined }) - expect( - claudeEvent(state, SOURCE_PANE, { - hook_event_name: 'Stop', - background_tasks: [], - session_crons: [] - })?.state - ).toBe('done') - expect(state.claudeRunningNonAgentTaskPaneKeys.has(SOURCE_PANE)).toBe(false) - }) - // Completion notifications need turnCompletedAt while the pane remains working for monitoring; pin both predicates (STA-4119). it('holds the finish of a lead that ends into a working subagent, unless the lead failed', () => { @@ -213,52 +168,6 @@ describe('Claude background task status', () => { ).toMatchObject({ state: 'done', interrupted: true, mainAgent: { outcome: 'cancellation' } }) }) - it('drains a reported cron inventory mid-turn, not only at a turn boundary', () => { - const state = createHookListenerState() - - claudeEvent(state, SOURCE_PANE, { - hook_event_name: 'Stop', - session_crons: [{ id: 'cron-1' }] - }) - expect(state.claudeActiveSessionCronPaneKeys.has(SOURCE_PANE)).toBe(true) - - expect( - claudeEvent(state, SOURCE_PANE, { - hook_event_name: 'PostToolUse', - session_crons: [] - })?.state - ).toBe('working') - expect(state.claudeActiveSessionCronPaneKeys.has(SOURCE_PANE)).toBe(false) - - // A mid-turn drain must survive the boundary or a missing next inventory leaves stale cron work pinned. - expect(claudeEvent(state, SOURCE_PANE, { hook_event_name: 'Stop' })?.state).toBe('done') - }) - - it('only infers an omitted cron inventory is drained from modern lead Stop payloads', () => { - const createCronState = (): HookListenerState => { - const state = createHookListenerState() - claudeEvent(state, SOURCE_PANE, { - hook_event_name: 'Stop', - session_crons: [{ id: 'cron-1' }] - }) - return state - } - - const midTurnState = createCronState() - claudeEvent(midTurnState, SOURCE_PANE, { - hook_event_name: 'UserPromptSubmit', - prompt: 'continue', - background_tasks: [] - }) - expect(midTurnState.claudeActiveSessionCronPaneKeys.has(SOURCE_PANE)).toBe(true) - - const legacyStopState = createCronState() - expect(claudeEvent(legacyStopState, SOURCE_PANE, { hook_event_name: 'Stop' })?.state).toBe( - 'working' - ) - expect(legacyStopState.claudeActiveSessionCronPaneKeys.has(SOURCE_PANE)).toBe(true) - }) - it('drops interrupted state on a mid-turn lead event with no prompt submit', () => { const state = createHookListenerState() claudeEvent(state, SOURCE_PANE, { hook_event_name: 'Stop', is_interrupt: true }) @@ -275,40 +184,6 @@ describe('Claude background task status', () => { ).toMatchObject({ state: 'working', interrupted: undefined }) }) - it('ignores child-attributed inventories for lead-owned background work', () => { - const state = createHookListenerState() - claudeEvent(state, SOURCE_PANE, { - hook_event_name: 'Stop', - agent_id: 'child-1', - background_tasks: [RUNNING_SHELL], - session_crons: [{ id: 'cron-1' }] - }) - expect(state.claudeRunningNonAgentTaskPaneKeys.has(SOURCE_PANE)).toBe(false) - expect(state.claudeActiveSessionCronPaneKeys.has(SOURCE_PANE)).toBe(false) - - claudeEvent(state, SOURCE_PANE, { - hook_event_name: 'Stop', - background_tasks: [RUNNING_SHELL], - session_crons: [{ id: 'cron-1' }] - }) - claudeEvent(state, SOURCE_PANE, { - hook_event_name: 'Stop', - agent_id: 'child-1', - background_tasks: [], - session_crons: [] - }) - expect(state.claudeRunningNonAgentTaskPaneKeys.has(SOURCE_PANE)).toBe(true) - expect(state.claudeActiveSessionCronPaneKeys.has(SOURCE_PANE)).toBe(true) - - claudeEvent(state, SOURCE_PANE, { - hook_event_name: 'Stop', - background_tasks: [], - session_crons: [] - }) - expect(state.claudeRunningNonAgentTaskPaneKeys.has(SOURCE_PANE)).toBe(false) - expect(state.claudeActiveSessionCronPaneKeys.has(SOURCE_PANE)).toBe(false) - }) - it('retains live child rows when an agent inventory is partial or has a future status', () => { for (const backgroundTask of [ { id: 'child-1', type: 'subagent', status: 'in_progress' }, @@ -348,31 +223,6 @@ describe('Claude background task status', () => { ).toBeUndefined() }) - it('ignores TeammateIdle inventories for lead-owned background work', () => { - const state = createHookListenerState() - claudeEvent(state, SOURCE_PANE, { - hook_event_name: 'Stop', - background_tasks: [RUNNING_SHELL] - }) - - expect( - claudeEvent(state, SOURCE_PANE, { - hook_event_name: 'TeammateIdle', - teammate_name: 'reviewer', - background_tasks: [] - })?.state - ).toBe('working') - expect(state.claudeRunningNonAgentTaskPaneKeys.has(SOURCE_PANE)).toBe(true) - - claudeEvent(state, SOURCE_PANE, { hook_event_name: 'Stop', background_tasks: [] }) - claudeEvent(state, SOURCE_PANE, { - hook_event_name: 'TeammateIdle', - teammate_name: 'reviewer', - background_tasks: [RUNNING_SHELL] - }) - expect(state.claudeRunningNonAgentTaskPaneKeys.has(SOURCE_PANE)).toBe(false) - }) - it('shows a child permission wait after the lead turn is interrupted', () => { const state = createHookListenerState() claudeEvent(state, SOURCE_PANE, { @@ -390,56 +240,4 @@ describe('Claude background task status', () => { }) ).toMatchObject({ state: 'waiting', toolName: 'Bash' }) }) - - it('preserves authoritative work across partial inventories and clears it on teardown', () => { - const state = createHookListenerState() - claudeEvent(state, SOURCE_PANE, { - hook_event_name: 'Stop', - background_tasks: [RUNNING_SHELL] - }) - - expect(claudeEvent(state, SOURCE_PANE, { hook_event_name: 'Stop' })).toMatchObject({ - state: 'working', - workingMode: 'monitoring' - }) - expect(state.claudeRunningNonAgentTaskPaneKeys.has(SOURCE_PANE)).toBe(true) - clearPaneCacheState(state, SOURCE_PANE) - expect(state.claudeRunningNonAgentTaskPaneKeys.size).toBe(0) - - claudeEvent(state, TARGET_PANE, { - hook_event_name: 'Stop', - background_tasks: [RUNNING_SHELL] - }) - clearAllListenerCaches(state) - expect(state.claudeRunningNonAgentTaskPaneKeys.size).toBe(0) - }) - - it('keeps a live shell as a confirmed done-gate for child lifecycle events after a cancel', () => { - const state = createHookListenerState() - // A truncated inventory adds a shell without retiring the restored child seed. - seedClaudeSubagentRosterFromSnapshots(state, SOURCE_PANE, [ - { id: 'restored-1', state: 'working', startedAt: 1 } - ]) - claudeEvent(state, SOURCE_PANE, { hook_event_name: 'UserPromptSubmit', prompt: 'resume' }) - claudeEvent(state, SOURCE_PANE, { - hook_event_name: 'Stop', - background_tasks: [{ id: 'shell-x', status: 'running' }] - }) - expect(state.claudeRunningNonAgentTaskPaneKeys.has(SOURCE_PANE)).toBe(true) - markClaudeLeadTurnInterrupted(state, SOURCE_PANE) - - // A live shell confirms inventory regardless of lead verdict; a sibling’s end cannot make it restored-only. - const ended = normalizeHookPayload( - state, - 'claude', - { paneKey: SOURCE_PANE, payload: { hook_event_name: 'SubagentStop', agent_id: 'other' } }, - 'production' - ) - expect(ended?.restoredUnconfirmed).toBeUndefined() - expect(state.claudeUnconfirmedRestoredStatusPaneKeys.has(SOURCE_PANE)).toBe(false) - expect(ended?.payload).toMatchObject({ - state: 'working', - mainAgent: { state: 'done', outcome: 'cancellation' } - }) - }) }) diff --git a/src/shared/agent-hook-listener/normalization/normalize-hook-payload.ts b/src/shared/agent-hook-listener/normalization/normalize-hook-payload.ts index bd64f855b9a..ce89bf3ebab 100644 --- a/src/shared/agent-hook-listener/normalization/normalize-hook-payload.ts +++ b/src/shared/agent-hook-listener/normalization/normalize-hook-payload.ts @@ -8,7 +8,7 @@ import { canAcceptClaudeCompactCompletion, isClaudeCompactCompletionConsumed, markClaudeCompactCompletionConsumed -} from '../providers/claude-compact-completion' +} from '../providers/claude/lifecycle/claude-compact-completion' import { isProcessExitSessionEnd, parseHookEnvelope } from '../hook-envelope' import { readFirstString } from '../interactive-tool' import type { AgentHookEventPayload } from '../listener-event' @@ -17,19 +17,19 @@ import type { HookListenerState } from '../listener-state' import { extractPromptText } from '../prompt-fields' import { normalizeProviderEvent } from '../provider-dispatch' import { hasExplicitUserPrompt } from '../provider-event-routing' -import { hasExplicitAmpPrompt } from '../providers/amp-events' +import { hasExplicitAmpPrompt } from '../providers/amp/amp-events' import { isOpenCodeSharedServerPost, resolveOpenCodeSharedServerEnvelope, suppressOpenCodeSharedServerPost, trackOpenCodePaneLaunchToken } from '../opencode-session-registry' -import { claudeRowHasUnlistedLiveWork } from '../providers/claude-pane-hold-evidence' +import { claudeRowHasUnlistedLiveWork } from '../providers/claude/lifecycle/claude-pane-hold-evidence' import { readString } from '../tool-input-preview' import { readClaudeBatchSettledRequests } from '../claude-settled-permission-requests' import { withClaudeToolRequestId } from '../claude-tool-request-identity' import { hasSeparateHookSessionAdmission, readPaneSessionContext } from '../pane-provider-session' -import { routeJcodeHelperHook } from '../providers/jcode-helper-permission-wait' +import { routeJcodeHelperHook } from '../providers/jcode/jcode-helper-permission-wait' /** Canonical transport-agnostic normalization entry shared by main and relay listeners. */ export function normalizeHookPayload( diff --git a/src/shared/agent-hook-listener/pane-provider-session.ts b/src/shared/agent-hook-listener/pane-provider-session.ts index 9a66336b95d..c419c8d9b4d 100644 --- a/src/shared/agent-hook-listener/pane-provider-session.ts +++ b/src/shared/agent-hook-listener/pane-provider-session.ts @@ -9,8 +9,8 @@ import { import { readFirstString } from './interactive-tool' import type { HookListenerState } from './listener-state' import type { AgentHookEventPayload } from './listener-event' -import { isGrokSubagentEvent } from './providers/grok-tool-fields' -import { isHermesRootSessionEvent, withHermesPaneSessionId } from './providers/hermes-events' +import { isGrokSubagentEvent } from './providers/grok/grok-tool-fields' +import { isHermesRootSessionEvent, withHermesPaneSessionId } from './providers/hermes/hermes-events' import { readString } from './tool-input-preview' import { readFirstTranscriptRecord } from './transcript-reader' diff --git a/src/shared/agent-hook-listener/provider-dispatch.ts b/src/shared/agent-hook-listener/provider-dispatch.ts index 55af4fa0250..acc4e6efbfe 100644 --- a/src/shared/agent-hook-listener/provider-dispatch.ts +++ b/src/shared/agent-hook-listener/provider-dispatch.ts @@ -1,5 +1,5 @@ import { normalizeCompatibleLifecycleEvent } from './providers/compatible-lifecycle-events' -import { normalizeQoderEvent } from './providers/qoder-events' + import type { AgentMainAgentStatus } from '../agent-status/lifecycle/main-agent-status' import type { ParsedAgentStatusPayload } from '../agent-status/contracts/agent-status-types' import type { AgentHookSource } from './transport/agent-hook-relay' @@ -12,28 +12,29 @@ import type { HookListenerState } from './listener-state' import type { ExtractedPromptText } from './prompt-fields' import { isNewTurnEvent } from './provider-event-routing' import { readLastUserPromptFromTranscript } from './transcript-lines' -import { readJcodeTurnPrompt } from './providers/jcode-turn-prompt' -import { normalizeAntigravityEvent } from './providers/antigravity-events' -import { normalizeAmpEvent } from './providers/amp-events' -import { normalizeClaudeEvent } from './providers/claude-events' -import { codexHookEventForEndedTurn, normalizeCodexEvent } from './providers/codex-events' -import { codexHookEventForEphemeralThread } from './providers/codex-ephemeral-threads' -import { normalizeGeminiEvent } from './providers/gemini-events' -import { normalizeOpenCodeFamilyEvent } from './providers/opencode-family-events' -import { normalizeCursorEvent } from './providers/cursor-events' -import { normalizePiCompatibleEvent } from './providers/pi-family-events' -import { normalizeDroidEvent } from './providers/droid-events' -import { normalizeCommandCodeEvent } from './providers/command-code-events' -import { normalizeGrokEvent } from './providers/grok-events' -import { normalizeCopilotEvent } from './providers/copilot-events' -import { normalizeHermesEvent } from './providers/hermes-events' -import { normalizeDevinEvent } from './providers/devin-events' -import { normalizeKimiEvent } from './providers/kimi-events' -import { normalizeMuseEvent } from './providers/muse-events' -import { normalizeDshEvent } from './providers/dsh-events' -import { normalizeZCodeEvent } from './providers/zcode-events' -import { normalizeJcodeEvent } from './providers/jcode-events' -import { normalizeKiroEvent } from './providers/kiro-events' +import { readJcodeTurnPrompt } from './providers/jcode/jcode-turn-prompt' +import { normalizeAntigravityEvent } from './providers/antigravity/antigravity-events' +import { normalizeAmpEvent } from './providers/amp/amp-events' +import { normalizeClaudeEvent } from './providers/claude/claude-events' +import { normalizeCodexEvent } from './providers/codex/codex-events' +import { codexHookEventForEndedTurn } from './providers/codex/codex-ended-turn-event' +import { codexHookEventForEphemeralThread } from './providers/codex/codex-ephemeral-threads' +import { normalizeGeminiEvent } from './providers/gemini/gemini-events' +import { normalizeOpenCodeFamilyEvent } from './providers/opencode-family/opencode-family-events' +import { normalizeCursorEvent } from './providers/cursor/cursor-events' +import { normalizePiCompatibleEvent } from './providers/pi-family/pi-family-events' +import { normalizeDroidEvent } from './providers/droid/droid-events' +import { normalizeCommandCodeEvent } from './providers/command-code/command-code-events' +import { normalizeGrokEvent } from './providers/grok/grok-events' +import { normalizeCopilotEvent } from './providers/copilot/copilot-events' +import { normalizeHermesEvent } from './providers/hermes/hermes-events' +import { normalizeDevinEvent } from './providers/devin/devin-events' +import { normalizeKimiEvent } from './providers/kimi/kimi-events' +import { normalizeMuseEvent } from './providers/muse/muse-events' +import { normalizeDshEvent } from './providers/dsh/dsh-events' +import { normalizeZCodeEvent } from './providers/zcode/zcode-events' +import { normalizeJcodeEvent } from './providers/jcode/jcode-events' +import { normalizeKiroEvent } from './providers/kiro/kiro-events' export type ProviderDispatchResult = { payload: ParsedAgentStatusPayload | null @@ -209,7 +210,14 @@ export function normalizeProviderEvent(input: { break } case 'qoder': - payload = normalizeQoderEvent(state, eventName, promptText, paneKey, hookPayload) + payload = normalizeCompatibleLifecycleEvent( + 'qoder', + state, + eventName, + promptText, + paneKey, + hookPayload + ) break case 'kimi': payload = normalizeKimiEvent(state, eventName, promptText, paneKey, hookPayload) diff --git a/src/shared/agent-hook-listener/provider-event-routing.ts b/src/shared/agent-hook-listener/provider-event-routing.ts index 082e8662058..95c608dd75f 100644 --- a/src/shared/agent-hook-listener/provider-event-routing.ts +++ b/src/shared/agent-hook-listener/provider-event-routing.ts @@ -2,25 +2,25 @@ import type { AgentHookSource } from './transport/agent-hook-relay' import { isKnownHarnessInjectedUserTurnText } from '../harness-injected-user-turns' import type { ToolSnapshot } from './listener-event' import type { ExtractedPromptText } from './prompt-fields' -import { extractClaudeToolFields } from './providers/claude-tool-fields' -import { extractCodexToolFields } from './providers/codex-tool-fields' -import { extractGeminiToolFields } from './providers/gemini-tool-fields' -import { extractAntigravityToolFields } from './providers/antigravity-tool-fields' -import { extractAmpToolFields } from './providers/amp-tool-fields' -import { extractOpenCodeToolFields } from './providers/opencode-family-tool-fields' -import { extractCursorToolFields } from './providers/cursor-tool-fields' +import { extractClaudeToolFields } from './providers/claude/claude-tool-fields' +import { extractCodexToolFields } from './providers/codex/codex-tool-fields' +import { extractGeminiToolFields } from './providers/gemini/gemini-tool-fields' +import { extractAntigravityToolFields } from './providers/antigravity/antigravity-tool-fields' +import { extractAmpToolFields } from './providers/amp/amp-tool-fields' +import { extractOpenCodeToolFields } from './providers/opencode-family/opencode-family-tool-fields' +import { extractCursorToolFields } from './providers/cursor/cursor-tool-fields' import { extractCopilotToolFields, normalizeCopilotEventName -} from './providers/copilot-tool-fields' -import { extractPiToolFields } from './providers/pi-family-tool-fields' -import { extractDroidToolFields } from './providers/droid-tool-fields' -import { extractCommandCodeToolFields } from './providers/command-code-tool-fields' +} from './providers/copilot/copilot-tool-fields' +import { extractPiToolFields } from './providers/pi-family/pi-family-tool-fields' +import { extractDroidToolFields } from './providers/droid/droid-tool-fields' +import { extractCommandCodeToolFields } from './providers/command-code/command-code-tool-fields' import { isGrokEvent } from './provider-event-names' -import { extractGrokToolFields } from './providers/grok-tool-fields' -import { extractHermesToolFields } from './providers/hermes-tool-fields' -import { extractJcodeToolFields } from './providers/jcode-tool-fields' -import { extractKiroToolFields } from './providers/kiro-tool-fields' +import { extractGrokToolFields } from './providers/grok/grok-tool-fields' +import { extractHermesToolFields } from './providers/hermes/hermes-tool-fields' +import { extractJcodeToolFields } from './providers/jcode/jcode-tool-fields' +import { extractKiroToolFields } from './providers/kiro/kiro-tool-fields' /** Share provider boundaries with observation stamping; avoid a second event-name list. */ export function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boolean { diff --git a/src/shared/agent-hook-listener/providers/amp-events.ts b/src/shared/agent-hook-listener/providers/amp/amp-events.ts similarity index 92% rename from src/shared/agent-hook-listener/providers/amp-events.ts rename to src/shared/agent-hook-listener/providers/amp/amp-events.ts index ac45758dc4e..71e670a46f0 100644 --- a/src/shared/agent-hook-listener/providers/amp-events.ts +++ b/src/shared/agent-hook-listener/providers/amp/amp-events.ts @@ -1,13 +1,13 @@ import { normalizeAgentStatusPayload, type ParsedAgentStatusPayload -} from '../../agent-status/contracts/agent-status-types' -import { readFirstString } from '../interactive-tool' -import { readBoundedString } from '../grok-result-discovery' -import { clearPaneTurnCacheState, type HookListenerState } from '../listener-state' -import { resolvePrompt, resolveToolState } from '../prompt-fields' -import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' -import { AMP_MAX_SCOPED_THREAD_CACHE_KEYS, AMP_THREAD_ID_MAX_LENGTH } from '../listener-limits' +} from '../../../agent-status/contracts/agent-status-types' +import { readFirstString } from '../../interactive-tool' +import { readBoundedString } from '../../grok-result-discovery' +import { clearPaneTurnCacheState, type HookListenerState } from '../../listener-state' +import { resolvePrompt, resolveToolState } from '../../prompt-fields' +import { extractToolFields, isNewTurnEvent } from '../../provider-event-routing' +import { AMP_MAX_SCOPED_THREAD_CACHE_KEYS, AMP_THREAD_ID_MAX_LENGTH } from '../../listener-limits' export function normalizeAmpEvent( state: HookListenerState, diff --git a/src/shared/agent-hook-listener/providers/amp-tool-fields.ts b/src/shared/agent-hook-listener/providers/amp/amp-tool-fields.ts similarity index 90% rename from src/shared/agent-hook-listener/providers/amp-tool-fields.ts rename to src/shared/agent-hook-listener/providers/amp/amp-tool-fields.ts index 2bd8d3a8781..7a12ffc8c9a 100644 --- a/src/shared/agent-hook-listener/providers/amp-tool-fields.ts +++ b/src/shared/agent-hook-listener/providers/amp/amp-tool-fields.ts @@ -1,12 +1,12 @@ -import type { ToolSnapshot } from '../listener-event' +import type { ToolSnapshot } from '../../listener-event' import { deriveFallbackToolInputPreview, deriveToolInputPreview, hasAnyOwnField, readString, toolUpdate -} from '../tool-input-preview' -import { extractToolResponseText, readFirstString } from '../interactive-tool' +} from '../../tool-input-preview' +import { extractToolResponseText, readFirstString } from '../../interactive-tool' export function extractAmpToolFields( eventName: unknown, diff --git a/src/shared/agent-hook-listener/providers/antigravity-background-command.test.ts b/src/shared/agent-hook-listener/providers/antigravity/antigravity-background-command.test.ts similarity index 87% rename from src/shared/agent-hook-listener/providers/antigravity-background-command.test.ts rename to src/shared/agent-hook-listener/providers/antigravity/antigravity-background-command.test.ts index 3494b8ea693..2c20d8a30c4 100644 --- a/src/shared/agent-hook-listener/providers/antigravity-background-command.test.ts +++ b/src/shared/agent-hook-listener/providers/antigravity/antigravity-background-command.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' -import { createHookListenerState } from '../listener-state' -import { normalizeHookPayload } from '../normalization/normalize-hook-payload' -import { PANE_KEY } from '../normalization/agent-hook-listener-test-harness' +import { createHookListenerState } from '../../listener-state' +import { normalizeHookPayload } from '../../normalization/normalize-hook-payload' +import { PANE_KEY } from '../../normalization/agent-hook-listener-test-harness' // Hook order captured from agy 1.5.2 when the model backgrounds run_command (WaitMsBeforeAsync 500). const turn = { conversationId: 'parent', lastUserInput: 'start the job' } diff --git a/src/shared/agent-hook-listener/providers/antigravity-event-rules.ts b/src/shared/agent-hook-listener/providers/antigravity/antigravity-event-rules.ts similarity index 100% rename from src/shared/agent-hook-listener/providers/antigravity-event-rules.ts rename to src/shared/agent-hook-listener/providers/antigravity/antigravity-event-rules.ts diff --git a/src/shared/agent-hook-listener/providers/antigravity-events.ts b/src/shared/agent-hook-listener/providers/antigravity/antigravity-events.ts similarity index 93% rename from src/shared/agent-hook-listener/providers/antigravity-events.ts rename to src/shared/agent-hook-listener/providers/antigravity/antigravity-events.ts index 490f5554677..0042ba19054 100644 --- a/src/shared/agent-hook-listener/providers/antigravity-events.ts +++ b/src/shared/agent-hook-listener/providers/antigravity/antigravity-events.ts @@ -1,12 +1,12 @@ import { normalizeAgentStatusPayload, type ParsedAgentStatusPayload -} from '../../agent-status/contracts/agent-status-types' -import { readFirstString } from '../interactive-tool' -import type { HookListenerState } from '../listener-state' -import { resolvePrompt, resolveToolState } from '../prompt-fields' -import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' -import { readLastUserPromptFromTranscript } from '../transcript-lines' +} from '../../../agent-status/contracts/agent-status-types' +import { readFirstString } from '../../interactive-tool' +import type { HookListenerState } from '../../listener-state' +import { resolvePrompt, resolveToolState } from '../../prompt-fields' +import { extractToolFields, isNewTurnEvent } from '../../provider-event-routing' +import { readLastUserPromptFromTranscript } from '../../transcript-lines' import { readAntigravityToolCall } from './antigravity-tool-fields' import { isAntigravityStopStillBusy } from './antigravity-event-rules' diff --git a/src/shared/agent-hook-listener/providers/antigravity-tool-fields.ts b/src/shared/agent-hook-listener/providers/antigravity/antigravity-tool-fields.ts similarity index 87% rename from src/shared/agent-hook-listener/providers/antigravity-tool-fields.ts rename to src/shared/agent-hook-listener/providers/antigravity/antigravity-tool-fields.ts index ba57349f14b..fe84d450e49 100644 --- a/src/shared/agent-hook-listener/providers/antigravity-tool-fields.ts +++ b/src/shared/agent-hook-listener/providers/antigravity/antigravity-tool-fields.ts @@ -1,12 +1,12 @@ -import type { ToolSnapshot } from '../listener-event' +import type { ToolSnapshot } from '../../listener-event' import { deriveFallbackToolInputPreview, deriveToolInputPreview, readString, toolUpdate -} from '../tool-input-preview' -import { readFirstString } from '../interactive-tool' -import { readLastAssistantFromTranscript } from '../transcript-lines' +} from '../../tool-input-preview' +import { readFirstString } from '../../interactive-tool' +import { readLastAssistantFromTranscript } from '../../transcript-lines' import { isAntigravityStopStillBusy } from './antigravity-event-rules' export function readAntigravityToolCall(hookPayload: Record): { diff --git a/src/shared/agent-hook-listener/providers/claude-helper-kill.test-fixture.ts b/src/shared/agent-hook-listener/providers/claude-helper-kill.test-fixture.ts deleted file mode 100644 index f5b7993c56b..00000000000 --- a/src/shared/agent-hook-listener/providers/claude-helper-kill.test-fixture.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, vi } from 'vitest' -import { createHookListenerState } from '../listener-state' -import { seedLegacyAgentStatusForTests } from '../legacy-agent-status-test-seed' -import { normalizeHookPayload } from '../normalization/normalize-hook-payload' -import { ClaudeHelperKillPoller } from './claude-helper-kill-poller' -import { readClaudeTranscriptChunk } from './claude-transcript-cursor' -import type { AgentHookEventPayload } from '../listener-event' - -export let dir: string -export const now = Date.parse('2026-10-29T12:00:00Z') -export const stamp = (offset: number) => new Date(now + offset).toISOString() -export const kill = (offset = 1) => - `${JSON.stringify({ type: 'system', subtype: 'agents_killed', timestamp: stamp(offset) })}\n` -export const prompt = (offset: number) => - `${JSON.stringify({ - type: 'user', - timestamp: stamp(offset), - message: { role: 'user', content: 'Do the work' } - })}\n` - -beforeEach(async () => { - vi.spyOn(Date, 'now').mockReturnValue(now) - dir = await mkdtemp(join(tmpdir(), 'claude-helper-kill-')) -}) -afterEach(async () => { - vi.restoreAllMocks() - await rm(dir, { recursive: true, force: true }) -}) - -export async function pane(shell = false, reader = readClaudeTranscriptChunk, cron = false) { - const state = createHookListenerState() - const path = join(dir, 'session.jsonl') - await writeFile(path, prompt(-20)) - await mkdir(join(dir, 'session', 'subagents'), { recursive: true }) - const child = (id: string) => join(dir, 'session', 'subagents', `agent-${id}.jsonl`) - await writeFile(child('a123'), prompt(-10)) - let current: AgentHookEventPayload | undefined - const events: AgentHookEventPayload[] = [] - function post(payload: Record) { - const event = normalizeHookPayload( - state, - 'claude', - { - paneKey: 'test-tab:11111111-1111-4111-8111-111111111111', - env: 'production', - payload: { session_id: 'session', transcript_path: path, ...payload } - }, - 'production' - ) - if (!event) { - throw new Error('hook declined') - } - current = event - seedLegacyAgentStatusForTests(state, event) - return event - } - post({ hook_event_name: 'UserPromptSubmit', prompt: 'work' }) - post({ hook_event_name: 'SubagentStart', agent_id: 'a123' }) - post({ - hook_event_name: 'Stop', - last_assistant_message: 'READY', - background_tasks: [ - { id: 'a123', type: 'subagent', status: 'running' }, - ...(shell ? [{ id: 'shell', type: 'local_bash', status: 'running' }] : []) - ], - session_crons: cron ? [{ id: 'cron' }] : [] - }) - const publish = vi.fn((event: AgentHookEventPayload) => { - current = event - events.push(event) - seedLegacyAgentStatusForTests(state, event) - }) - let listening = true - const poller = new ClaudeHelperKillPoller( - state, - { current: () => current, publish, listening: () => listening }, - reader - ) - poller.schedule(current!) - return { - state, - listen: (value: boolean) => { - listening = value - if (value) { - poller.activate() - } - }, - path, - child, - post, - poller, - publish, - events, - row: () => current!, - replace: (row: AgentHookEventPayload) => { - current = row - poller.schedule(row) - } - } -} - -export async function drain(p: Awaited>) { - for (let i = 0; i < 8; i++) { - await p.poller.poll('test-tab:11111111-1111-4111-8111-111111111111') - } -} diff --git a/src/shared/agent-hook-listener/providers/claude-helper-kill.test.ts b/src/shared/agent-hook-listener/providers/claude-helper-kill.test.ts deleted file mode 100644 index 77134f72180..00000000000 --- a/src/shared/agent-hook-listener/providers/claude-helper-kill.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { writeFile, appendFile, rename } from 'node:fs/promises' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' -import { dir, kill, pane, drain, prompt, stamp } from './claude-helper-kill.test-fixture' - -describe('Claude helper provider evidence', () => { - it.each([false, true])( - 'ignores mixed server-result echoes after a kill; real prompt: %s', - async (realPrompt) => { - const p = await pane() - try { - await writeFile(p.child('a123'), prompt(1)) - await appendFile(p.path, kill(2)) - await appendFile( - p.child('a123'), - `${JSON.stringify({ - type: 'user', - timestamp: stamp(3), - message: { - role: 'user', - content: [ - { type: 'text', text: 'server context' }, - { - type: 'advisor_tool_result', - tool_use_id: 'server', - content: { type: 'advisor_redacted_result' } - } - ] - } - })}\n` - ) - if (realPrompt) { - await appendFile(p.child('a123'), prompt(4)) - } - await drain(p) - if (realPrompt) { - expect(p.row().payload.subagents?.[0]?.state).toBe('working') - } else { - expect(p.row().payload.subagents ?? []).toEqual([]) - } - } finally { - p.poller.clearAll() - } - } - ) - - it('forgets kills on parent file replacement', async () => { - const p = await pane() - try { - await appendFile(p.path, kill()) - await drain(p) - expect(p.row().payload.subagents ?? []).toEqual([]) - await writeFile(join(dir, 'replacement.jsonl'), kill()) - await rename(join(dir, 'replacement.jsonl'), p.path) - p.replace(p.post({ hook_event_name: 'SubagentStart', agent_id: 'a123' })) - await drain(p) - expect(p.row().payload.subagents?.[0]?.state).toBe('working') - } finally { - p.poller.clearAll() - } - }) - - it('does not apply an in-flight read after pane retirement or to its successor', async () => { - const p = await pane() - try { - await appendFile(p.path, kill()) - const reading = p.poller.poll('test-tab:11111111-1111-4111-8111-111111111111') - p.poller.clear('test-tab:11111111-1111-4111-8111-111111111111') - p.replace({ - ...p.row(), - providerSession: { key: 'session_id', id: 'replacement', transcriptPath: p.path } - }) - await reading - expect(p.publish).not.toHaveBeenCalled() - } finally { - p.poller.clearAll() - } - }) -}) diff --git a/src/shared/agent-hook-listener/providers/claude-transcript-semantics.test.ts b/src/shared/agent-hook-listener/providers/claude-transcript-semantics.test.ts deleted file mode 100644 index a32cae6d045..00000000000 --- a/src/shared/agent-hook-listener/providers/claude-transcript-semantics.test.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { mkdtemp, writeFile, appendFile, readFile, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createClaudeTranscriptCursor, readClaudeTranscriptChunk } from './claude-transcript-cursor' -import { - readClaudeHelperEvidence, - readClaudeChildPrompts, - type ClaudeChildTranscript, - type ClaudeHelperTranscriptEvidence -} from './claude-helper-transcript-evidence' - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -let dir: string -const now = Date.parse('2026-10-29T12:00:00Z') -const stamp = (offset: number) => new Date(now + offset).toISOString() -const kill = (offset = 1) => - `${JSON.stringify({ type: 'system', subtype: 'agents_killed', timestamp: stamp(offset) })}\n` -const prompt = (offset: number) => - `${JSON.stringify({ type: 'user', timestamp: stamp(offset), message: { content: 'Do the work' } })}\n` -beforeEach(async () => { - dir = await mkdtemp(join(tmpdir(), 'claude-transcript-semantics-')) -}) -afterEach(async () => { - vi.restoreAllMocks() - await rm(dir, { recursive: true, force: true }) -}) - -describe('forward transcript reading', () => { - it('treats missing files as unverifiable and replacement/shrink as a new baseline', async () => { - const path = join(dir, 'replace.jsonl') - const cursor = createClaudeTranscriptCursor(path) - expect(await readClaudeTranscriptChunk(cursor)).toBeUndefined() - await writeFile(path, `${prompt(0)}${' '.repeat(200)}`) - await readClaudeTranscriptChunk(cursor) - await writeFile(path, kill()) - expect((await readClaudeTranscriptChunk(cursor))?.records).toEqual([]) - await appendFile(path, kill(20)) - expect((await readClaudeTranscriptChunk(cursor))?.records).toHaveLength(1) - }) -}) - -it('consumes every settled launch correlation at N and 10N without inventing failed resumes', () => { - for (const count of [10, 100]) { - const evidence = { - observedAt: now, - resumedAt: new Map(), - launches: new Map() - } - for (let index = 0; index < count; index++) { - for (const outcome of ['sync', 'failed', 'async', 'missing-time']) { - const id = `${outcome}-${index}` - readClaudeHelperEvidence(evidence, [ - { - type: 'assistant', - timestamp: stamp(index), - message: { content: [{ type: 'tool_use', name: 'Agent', id }] } - }, - { - type: 'user', - ...(outcome === 'missing-time' ? {} : { timestamp: stamp(index + 1) }), - toolUseResult: { isAsync: outcome === 'async' || outcome === 'failed', agentId: id }, - message: { - content: [{ type: 'tool_result', tool_use_id: id, is_error: outcome === 'failed' }] - } - } - ]) - } - } - expect(evidence.launches.size).toBe(0) - expect([...evidence.resumedAt.keys()]).toEqual( - Array.from({ length: count }, (_, i) => `async-${i}`) - ) - } -}) - -it('matches complete JSON evidence on captured and adversarial final values without provisional facts', async () => { - const captured = await Promise.all( - ['parent', 'child'].map(async (stream) => - ( - await readFile( - new URL(`../../__fixtures__/claude-helper-kill-2.1.310-${stream}.jsonl`, import.meta.url), - 'utf8' - ) - ) - .trim() - .split('\n') - ) - ) - const base = `"type":"user","timestamp":${JSON.stringify(stamp(20))}` - const huge = ' '.repeat(300_000) - const raw = [ - ...captured.flat(), - `{${base},"message":{"content":"${huge}real é😀"}}`, - `{${base},"message":{"content":[{"text":"x","type":"text"},{"type":"tool_result"}]}}`, - `{${base},"message":{"content":[{"type":"text","text":"real"}]},"isMeta":true}`, - `{${base},"message":{"content":"real"},"isCompactSummary":true,"isCompactSummary":false}`, - `{${base},"message":{"content":"real"},"message":null}`, - `{${base},"message":{"content":[{"type":"text","text":"real"}],"content":false}}`, - `{${base},"message":{"content":"injected","content":[{"type":"text","text":"[Request interrupted by user]"}]}}`, - `{${base},"message":{"content":[{"type":"tool_result","type":"text","text":"real"}]}}`, - `{${base},"message":{"content":[{"type":"text","text":"real","text":null}]}}`, - `{${base},"message":{"content":"real"},"${'unknown'.repeat(60_000)}":[[[{"ignored":"${huge}"}]]]}`, - `{${base},"message":{"content":"\uFEFF\t\u00a0"}}`, - `{${base},"message":{"content":"\u0085"}}`, - `{${base},"message":{"content":"real"},"type":"system","subtype":"agents_killed"}`, - `{"type":"assistant","timestamp":${JSON.stringify(stamp(20))},"message":{"content":[{"type":"tool_use","name":"Task","id":"use","input":"${huge}"}]}}`, - `{"type":"user","toolUseResult":{"isAsync":true,"agentId":"a123"},"message":{"content":[{"type":"tool_result","tool_use_id":"use","is_error":false}]}}`, - `{"type":"user","toolUseResult":{"isAsync":true,"agentId":"a123"},"toolUseResult":3,"message":{"content":[{"type":"tool_result","tool_use_id":"use"}]}}`, - `{"type":"user","toolUseResult":{"isAsync":true,"agentId":"a123"},"message":{"content":[{"type":"tool_result","tool_use_id":"use","is_error":false,"is_error":true}]}}`, - `{"type":"user","toolUseResult":{"isAsync":true,"isAsync":false,"agentId":"a123"},"message":{"content":[{"type":"tool_result","tool_use_id":"use"}]}}`, - `{${base},"message":{"content":[{"type":"text","text":"real"}],"content":null},"message":{"content":"new final message"}}`, - `{${base},"message":{"content":"real"},"timestamp${'x'.repeat(300_000)}":"1900-01-01","ignored":1${'0'.repeat(300_000)}}`, - JSON.stringify({ - type: 'user', - timestamp: stamp(20), - message: { content: `${' '.repeat(65)}[Request interrupted by user]` } - }), - `{"type":"system","subtype":"agents_killed","timestamp":${JSON.stringify(stamp(30))}} trailing`, - `{${base},"message":{"content":"real"},"bad":[1,]}`, - '[]', - 'null', - 'true', - '"not an object"' - ] - const originals: Record[] = [] - for (const line of raw) { - try { - const value: unknown = JSON.parse(line) - if (isRecord(value)) { - originals.push(value) - } - } catch { - /* Intentionally invalid complete frames have no effects. */ - } - } - const path = join(dir, 'semantic-parity.jsonl') - const wire = `${raw.join('\r\n')}\r\n` - await writeFile(path, wire) - const cursor = createClaudeTranscriptCursor(path) - const actual: Record[] = [] - while (cursor.offset < Buffer.byteLength(wire)) { - const result = await readClaudeTranscriptChunk(cursor) - actual.push(...(result?.records ?? [])) - } - expect(actual).toHaveLength(originals.length) - const evidence = (): ClaudeHelperTranscriptEvidence => ({ - observedAt: now, - launches: new Map([['use', now + 20]]), - resumedAt: new Map() - }) - for (let index = 0; index < originals.length; index++) { - const expected = evidence(), - projected = evidence() - readClaudeHelperEvidence(expected, [originals[index]]) - readClaudeHelperEvidence(projected, [actual[index]]) - expect(projected).toEqual(expected) - const expectedChild: ClaudeChildTranscript = { cursor: createClaudeTranscriptCursor(path) } - const projectedChild: ClaudeChildTranscript = { cursor: expectedChild.cursor } - readClaudeChildPrompts(expectedChild, [originals[index]]) - readClaudeChildPrompts(projectedChild, [actual[index]]) - expect(projectedChild.promptedAt).toBe(expectedChild.promptedAt) - } - const expected = evidence(), - projected = evidence() - readClaudeHelperEvidence(expected, originals) - readClaudeHelperEvidence(projected, actual) - expect(projected).toEqual(expected) - // Selected scalars/escaped keys cross every byte boundary, including UTF-8 and surrogate escapes. - const split = `{"ty\\u0070e":"assistant","timestamp":${JSON.stringify(stamp(20))},"message":{"content":[{"type":"tool_use","name":"Agent","id":"é😀\\ud800"}]}}\n` - await writeFile(path, split) - const tiny = createClaudeTranscriptCursor(path) - const selected: Record[] = [] - while (tiny.offset < Buffer.byteLength(split)) { - const chunk = await readClaudeTranscriptChunk(tiny, 1) - selected.push(...(chunk?.records ?? [])) - if (tiny.offset < Buffer.byteLength(split)) { - expect(chunk?.records).toEqual([]) - expect(chunk?.caughtUp).toBe(false) - } - } - const final = evidence(), - oracle = evidence() - readClaudeHelperEvidence(final, selected) - readClaudeHelperEvidence(oracle, [JSON.parse(split)]) - expect(final).toEqual(oracle) -}) diff --git a/src/shared/agent-hook-listener/providers/claude-owed-notification-expiry-timers.ts b/src/shared/agent-hook-listener/providers/claude/background-tasks/claude-owed-notification-expiry-timers.ts similarity index 84% rename from src/shared/agent-hook-listener/providers/claude-owed-notification-expiry-timers.ts rename to src/shared/agent-hook-listener/providers/claude/background-tasks/claude-owed-notification-expiry-timers.ts index 03ecb922f01..eb31d8f9ae6 100644 --- a/src/shared/agent-hook-listener/providers/claude-owed-notification-expiry-timers.ts +++ b/src/shared/agent-hook-listener/providers/claude/background-tasks/claude-owed-notification-expiry-timers.ts @@ -1,13 +1,13 @@ -import type { AgentHookEventPayload } from '../listener-event' -import type { HookListenerState } from '../listener-state' -import { mainAgentIdleSince } from '../main-agent-turn-state' -import { claudeRowHasUnlistedLiveWork } from './claude-pane-hold-evidence' -import { buildClaudeCachedLeadStatusPayload } from './claude-lifecycle-events' +import type { AgentHookEventPayload } from '../../../listener-event' +import type { HookListenerState } from '../../../listener-state' +import { mainAgentIdleSince } from '../../../main-agent-turn-state' +import { claudeRowHasUnlistedLiveWork } from '../lifecycle/claude-pane-hold-evidence' +import { buildClaudeCachedLeadStatusPayload } from '../lifecycle/claude-lifecycle-events' import { CLAUDE_OWED_TASK_NOTIFICATION_LEASE_MS, claudeOwedTaskNotificationDeadline } from './claude-owed-task-notifications' -import { CodexSubagentPollScheduler } from '../transcripts/codex-subagent-poll-scheduler' +import { CodexSubagentPollScheduler } from '../../../transcripts/codex-subagent-poll-scheduler' type PublishRow = (row: AgentHookEventPayload) => void diff --git a/src/shared/agent-hook-listener/providers/claude-owed-task-notifications.ts b/src/shared/agent-hook-listener/providers/claude/background-tasks/claude-owed-task-notifications.ts similarity index 98% rename from src/shared/agent-hook-listener/providers/claude-owed-task-notifications.ts rename to src/shared/agent-hook-listener/providers/claude/background-tasks/claude-owed-task-notifications.ts index 877a2ac00a7..d1c5f38addf 100644 --- a/src/shared/agent-hook-listener/providers/claude-owed-task-notifications.ts +++ b/src/shared/agent-hook-listener/providers/claude/background-tasks/claude-owed-task-notifications.ts @@ -1,5 +1,5 @@ -import { isRecord } from '../../agent-status/child-work/agent-status-child-work-value-guards' -import { runsOrcaOrchestrationWait } from '../../orca-cli-invocations' +import { isRecord } from '../../../../agent-status/child-work/agent-status-child-work-value-guards' +import { runsOrcaOrchestrationWait } from '../../../../orca-cli-invocations' /** Main-agent background tasks keyed by launch/notification task ID. Notifications can arrive a turn after task end and an empty Stop; owed work keeps the pane active (#23942). Captures: __fixtures__/claude-task-notification-hooks.jsonl. */ export type ClaudeLaunchedBackgroundTasks = Map diff --git a/src/shared/agent-hook-listener/providers/claude-task-notification-delivery.ts b/src/shared/agent-hook-listener/providers/claude/background-tasks/claude-task-notification-delivery.ts similarity index 91% rename from src/shared/agent-hook-listener/providers/claude-task-notification-delivery.ts rename to src/shared/agent-hook-listener/providers/claude/background-tasks/claude-task-notification-delivery.ts index d76dbf044d2..dabe7b95769 100644 --- a/src/shared/agent-hook-listener/providers/claude-task-notification-delivery.ts +++ b/src/shared/agent-hook-listener/providers/claude/background-tasks/claude-task-notification-delivery.ts @@ -1,4 +1,4 @@ -import type { readClaudeBackgroundAgentTasks } from '../../claude-background-task-inventory' +import type { readClaudeBackgroundAgentTasks } from '../../../../claude-background-task-inventory' import { forgetStoppedClaudeShellTask, oweClaudeShellTaskNotifications, @@ -7,9 +7,9 @@ import { recordClaudeUnconfirmedAgentNotification, settleClaudeTaskNotification } from './claude-owed-task-notifications' -import { readClaudeTaskNotification } from '../../claude-task-notification-text' -import type { HookListenerState } from '../listener-state' -import { readString } from '../tool-input-preview' +import { readClaudeTaskNotification } from '../../../../claude-task-notification-text' +import type { HookListenerState } from '../../../listener-state' +import { readString } from '../../../tool-input-preview' /** Track main-agent launches only: child-launched task notifications wake that child, not this pane. */ export function trackClaudeTaskNotificationDelivery( diff --git a/src/shared/agent-hook-listener/providers/claude-events.ts b/src/shared/agent-hook-listener/providers/claude/claude-events.ts similarity index 92% rename from src/shared/agent-hook-listener/providers/claude-events.ts rename to src/shared/agent-hook-listener/providers/claude/claude-events.ts index 1ae22a8a04b..467d90e34a8 100644 --- a/src/shared/agent-hook-listener/providers/claude-events.ts +++ b/src/shared/agent-hook-listener/providers/claude/claude-events.ts @@ -1,11 +1,11 @@ -import type { ParsedAgentStatusPayload } from '../../agent-status/contracts/agent-status-types' -import { CLAUDE_NEW_SESSION_START_SOURCES } from './claude-session-start-sources' +import type { ParsedAgentStatusPayload } from '../../../agent-status/contracts/agent-status-types' +import { CLAUDE_NEW_SESSION_START_SOURCES } from './lifecycle/claude-session-start-sources' import { heldTurnCountsAsFinished, isAgentStatusHeldOpenByChildWork -} from '../../agent-status/lifecycle/agent-lead-status-fold' -import { isAskUserQuestionTool } from '../../agent-status/lifecycle/agent-question-answered-intent' -import { readClaudeBackgroundAgentTasks } from '../../claude-background-task-inventory' +} from '../../../agent-status/lifecycle/agent-lead-status-fold' +import { isAskUserQuestionTool } from '../../../agent-status/lifecycle/agent-question-answered-intent' +import { readClaudeBackgroundAgentTasks } from '../../../claude-background-task-inventory' import { claudeRosterHasRestoredSnapshotSubagent, claudeRosterHasRuntimeWorkingSubagent, @@ -13,31 +13,31 @@ import { isClaudeChildTurnEndEvent, reapUnconfirmedRestoredClaudeSubagents, upsertWorkingClaudeSubagent -} from '../../claude-subagent-roster' -import type { HookListenerState } from '../listener-state' -import { readFirstString } from '../interactive-tool' -import { shouldIgnoreCompactContinuationUserPromptSubmit } from '../prompt-fields' -import { readString } from '../tool-input-preview' +} from '../../../claude-subagent-roster' +import type { HookListenerState } from '../../listener-state' +import { readFirstString } from '../../interactive-tool' +import { shouldIgnoreCompactContinuationUserPromptSubmit } from '../../prompt-fields' +import { readString } from '../../tool-input-preview' import { buildClaudeCachedLeadStatusPayload, normalizeClaudeSubagentLifecycleEvent -} from './claude-lifecycle-events' +} from './lifecycle/claude-lifecycle-events' import { getOrCreateClaudeSubagentRoster, readClaudeLeadModel, setClaudeMainAgentTurnState, updateClaudeRunningNonAgentTask, voidClaimsOfReplacedClaudeSession -} from './claude-roster-state' +} from './lifecycle/claude-roster-state' import { resolveClaudePaneStatus, claudePaneHasRunningChildWork, claudeRowHasUnlistedLiveWork -} from './claude-pane-hold-evidence' -import { buildClaudeStatusPayload } from './claude-status-build' -import { claudeWaitRequest, settleClaudeToolBatch } from './claude-tool-request-wait' -import { trackClaudeTaskNotificationDelivery } from './claude-task-notification-delivery' -import { recordClaudeTeammateSpawn } from './claude-teammate-spawn' +} from './lifecycle/claude-pane-hold-evidence' +import { buildClaudeStatusPayload } from './lifecycle/claude-status-build' +import { claudeWaitRequest, settleClaudeToolBatch } from './lifecycle/claude-tool-request-wait' +import { trackClaudeTaskNotificationDelivery } from './background-tasks/claude-task-notification-delivery' +import { recordClaudeTeammateSpawn } from './lifecycle/claude-teammate-spawn' const CLAUDE_HOOK_AGENT_ID_PREFIX = 'hook-agent-' diff --git a/src/shared/agent-hook-listener/providers/claude-tool-fields.ts b/src/shared/agent-hook-listener/providers/claude/claude-tool-fields.ts similarity index 87% rename from src/shared/agent-hook-listener/providers/claude-tool-fields.ts rename to src/shared/agent-hook-listener/providers/claude/claude-tool-fields.ts index f0ce6fb4297..a28ab80e4e2 100644 --- a/src/shared/agent-hook-listener/providers/claude-tool-fields.ts +++ b/src/shared/agent-hook-listener/providers/claude/claude-tool-fields.ts @@ -1,11 +1,16 @@ -import type { ToolSnapshot } from '../listener-event' -import { deriveToolInputPreview, hasOwnField, readString, toolUpdate } from '../tool-input-preview' +import type { ToolSnapshot } from '../../listener-event' +import { + deriveToolInputPreview, + hasOwnField, + readString, + toolUpdate +} from '../../tool-input-preview' import { clearActiveToolFieldsUpdate, deriveInteractivePrompt, extractToolResponseText -} from '../interactive-tool' -import { readLastAssistantFromTranscript } from '../transcript-lines' +} from '../../interactive-tool' +import { readLastAssistantFromTranscript } from '../../transcript-lines' export function extractClaudeToolFields( eventName: unknown, diff --git a/src/shared/agent-hook-listener/providers/claude-compact-completion.ts b/src/shared/agent-hook-listener/providers/claude/lifecycle/claude-compact-completion.ts similarity index 95% rename from src/shared/agent-hook-listener/providers/claude-compact-completion.ts rename to src/shared/agent-hook-listener/providers/claude/lifecycle/claude-compact-completion.ts index fa8a1f5e784..36349230c4a 100644 --- a/src/shared/agent-hook-listener/providers/claude-compact-completion.ts +++ b/src/shared/agent-hook-listener/providers/claude/lifecycle/claude-compact-completion.ts @@ -3,8 +3,8 @@ import { agentProviderSessionsEqual, type AgentProviderSessionMetadata -} from '../../agent-session/lifecycle/agent-session-resume' -import type { AgentHookSource } from '../transport/agent-hook-relay' +} from '../../../../agent-session/lifecycle/agent-session-resume' +import type { AgentHookSource } from '../../../transport/agent-hook-relay' export type ClaudeCompactCompletionOwner = { source?: AgentHookSource diff --git a/src/shared/agent-hook-listener/providers/claude-lifecycle-events.ts b/src/shared/agent-hook-listener/providers/claude/lifecycle/claude-lifecycle-events.ts similarity index 95% rename from src/shared/agent-hook-listener/providers/claude-lifecycle-events.ts rename to src/shared/agent-hook-listener/providers/claude/lifecycle/claude-lifecycle-events.ts index ec77de80f3f..3a752d90860 100644 --- a/src/shared/agent-hook-listener/providers/claude-lifecycle-events.ts +++ b/src/shared/agent-hook-listener/providers/claude/lifecycle/claude-lifecycle-events.ts @@ -1,9 +1,9 @@ -import type { ParsedAgentStatusPayload } from '../../agent-status/contracts/agent-status-types' +import type { ParsedAgentStatusPayload } from '../../../../agent-status/contracts/agent-status-types' import { markClaudeBackgroundAgentRunning, oweClaudeAgentTaskNotification, recordClaudeUnconfirmedAgentEnd -} from './claude-owed-task-notifications' +} from '../background-tasks/claude-owed-task-notifications' import { claudeRosterHasRestoredSnapshotSubagent, claudeRosterHasRuntimeWorkingSubagent, @@ -11,10 +11,10 @@ import { idleClaudeTeammateByName, stopClaudeSubagent, upsertWorkingClaudeSubagent -} from '../../claude-subagent-roster' -import type { HookListenerState } from '../listener-state' +} from '../../../../claude-subagent-roster' +import type { HookListenerState } from '../../../listener-state' import { claudeRowHasUnlistedLiveWork, resolveClaudePaneStatus } from './claude-pane-hold-evidence' -import { readString } from '../tool-input-preview' +import { readString } from '../../../tool-input-preview' import { clearClaudePendingWaitForAgent, getOrCreateClaudeSubagentRoster diff --git a/src/shared/agent-hook-listener/providers/claude-pane-hold-evidence.ts b/src/shared/agent-hook-listener/providers/claude/lifecycle/claude-pane-hold-evidence.ts similarity index 88% rename from src/shared/agent-hook-listener/providers/claude-pane-hold-evidence.ts rename to src/shared/agent-hook-listener/providers/claude/lifecycle/claude-pane-hold-evidence.ts index 74b5a91c958..2746ecaae3f 100644 --- a/src/shared/agent-hook-listener/providers/claude-pane-hold-evidence.ts +++ b/src/shared/agent-hook-listener/providers/claude/lifecycle/claude-pane-hold-evidence.ts @@ -1,15 +1,15 @@ import { foldAgentLeadStatus, type AgentLeadStatusResolution -} from '../../agent-status/lifecycle/agent-lead-status-fold' -import { agentChildWorkLivenessFromEvidence } from '../../agent-status/child-work/agent-status-child-work-liveness' +} from '../../../../agent-status/lifecycle/agent-lead-status-fold' +import { agentChildWorkLivenessFromEvidence } from '../../../../agent-status/child-work/agent-status-child-work-liveness' import { claudeLiveOwedTaskNotificationKinds, claudeTasksAwaitAgents -} from './claude-owed-task-notifications' -import { claudeRosterHasWorkingSubagent } from '../../claude-subagent-roster' -import type { HookListenerState } from '../listener-state' -import { mainAgentIdleSince, type ClaudeLeadTurnState } from '../main-agent-turn-state' +} from '../background-tasks/claude-owed-task-notifications' +import { claudeRosterHasWorkingSubagent } from '../../../../claude-subagent-roster' +import type { HookListenerState } from '../../../listener-state' +import { mainAgentIdleSince, type ClaudeLeadTurnState } from '../../../main-agent-turn-state' /** Everything that can hold a pane whose main agent's own turn is over. */ type ClaudePaneHoldEvidence = { diff --git a/src/shared/agent-hook-listener/providers/claude-roster-state.ts b/src/shared/agent-hook-listener/providers/claude/lifecycle/claude-roster-state.ts similarity index 93% rename from src/shared/agent-hook-listener/providers/claude-roster-state.ts rename to src/shared/agent-hook-listener/providers/claude/lifecycle/claude-roster-state.ts index 2dfbf769611..cbaf4cf4e73 100644 --- a/src/shared/agent-hook-listener/providers/claude-roster-state.ts +++ b/src/shared/agent-hook-listener/providers/claude/lifecycle/claude-roster-state.ts @@ -1,22 +1,22 @@ import { AGENT_MODEL_MAX_LENGTH, normalizeOptionalField -} from '../../agent-status/contracts/agent-status-field-normalization' -import type { AgentMainAgentStatus } from '../../agent-status/lifecycle/main-agent-status' +} from '../../../../agent-status/contracts/agent-status-field-normalization' +import type { AgentMainAgentStatus } from '../../../../agent-status/lifecycle/main-agent-status' import type { AgentStatusState, AgentWorkingMode -} from '../../agent-status/contracts/agent-status-types' -import type { AgentSubagentSnapshot } from '../../agent-status/contracts/agent-status-subagent-snapshot' +} from '../../../../agent-status/contracts/agent-status-types' +import type { AgentSubagentSnapshot } from '../../../../agent-status/contracts/agent-status-subagent-snapshot' import { continueMainAgentStatus, mainAgentTurnInterrupted -} from '../../agent-status/lifecycle/agent-lead-status-fold' -import type { ClaudeSubagentRoster } from '../../claude-subagent-roster' -import type { AgentHookEventPayload } from '../listener-event' -import type { HookListenerState } from '../listener-state' -import type { ClaudeLeadTurnState } from '../main-agent-turn-state' -import { readString } from '../tool-input-preview' +} from '../../../../agent-status/lifecycle/agent-lead-status-fold' +import type { ClaudeSubagentRoster } from '../../../../claude-subagent-roster' +import type { AgentHookEventPayload } from '../../../listener-event' +import type { HookListenerState } from '../../../listener-state' +import type { ClaudeLeadTurnState } from '../../../main-agent-turn-state' +import { readString } from '../../../tool-input-preview' import { CLAUDE_NEW_SESSION_START_SOURCES } from './claude-session-start-sources' import { resolveClaudePaneStatus } from './claude-pane-hold-evidence' diff --git a/src/shared/agent-hook-listener/providers/claude-session-start-sources.ts b/src/shared/agent-hook-listener/providers/claude/lifecycle/claude-session-start-sources.ts similarity index 100% rename from src/shared/agent-hook-listener/providers/claude-session-start-sources.ts rename to src/shared/agent-hook-listener/providers/claude/lifecycle/claude-session-start-sources.ts diff --git a/src/shared/agent-hook-listener/providers/claude-status-build.ts b/src/shared/agent-hook-listener/providers/claude/lifecycle/claude-status-build.ts similarity index 83% rename from src/shared/agent-hook-listener/providers/claude-status-build.ts rename to src/shared/agent-hook-listener/providers/claude/lifecycle/claude-status-build.ts index 8957e6cb192..b22799f9243 100644 --- a/src/shared/agent-hook-listener/providers/claude-status-build.ts +++ b/src/shared/agent-hook-listener/providers/claude/lifecycle/claude-status-build.ts @@ -3,12 +3,12 @@ import { type AgentStatusState, type AgentWorkingMode, type ParsedAgentStatusPayload -} from '../../agent-status/contracts/agent-status-types' -import { claudeRosterToSnapshots } from '../../claude-subagent-roster' -import { resolvePrompt, resolveToolState } from '../prompt-fields' -import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' -import type { HookListenerState } from '../listener-state' -import { mainAgentTurnInterrupted } from '../../agent-status/lifecycle/agent-lead-status-fold' +} from '../../../../agent-status/contracts/agent-status-types' +import { claudeRosterToSnapshots } from '../../../../claude-subagent-roster' +import { resolvePrompt, resolveToolState } from '../../../prompt-fields' +import { extractToolFields, isNewTurnEvent } from '../../../provider-event-routing' +import type { HookListenerState } from '../../../listener-state' +import { mainAgentTurnInterrupted } from '../../../../agent-status/lifecycle/agent-lead-status-fold' import { claudeMainAgentStatusForPayload } from './claude-roster-state' export function buildClaudeStatusPayload( diff --git a/src/shared/agent-hook-listener/providers/claude-teammate-spawn.ts b/src/shared/agent-hook-listener/providers/claude/lifecycle/claude-teammate-spawn.ts similarity index 88% rename from src/shared/agent-hook-listener/providers/claude-teammate-spawn.ts rename to src/shared/agent-hook-listener/providers/claude/lifecycle/claude-teammate-spawn.ts index 65926f6037b..ccf3a6b707f 100644 --- a/src/shared/agent-hook-listener/providers/claude-teammate-spawn.ts +++ b/src/shared/agent-hook-listener/providers/claude/lifecycle/claude-teammate-spawn.ts @@ -1,9 +1,9 @@ import { isClaudeTeammateLifecycleId, recordClaudeTeammateTaskDescription -} from '../../claude-subagent-roster' -import type { HookListenerState } from '../listener-state' -import { readString } from '../tool-input-preview' +} from '../../../../claude-subagent-roster' +import type { HookListenerState } from '../../../listener-state' +import { readString } from '../../../tool-input-preview' import { getOrCreateClaudeSubagentRoster } from './claude-roster-state' // Captured Claude 2.1.308 names background_tasks entries by the lead Agent call’s spawn description. diff --git a/src/shared/agent-hook-listener/providers/claude-tool-request-wait.ts b/src/shared/agent-hook-listener/providers/claude/lifecycle/claude-tool-request-wait.ts similarity index 85% rename from src/shared/agent-hook-listener/providers/claude-tool-request-wait.ts rename to src/shared/agent-hook-listener/providers/claude/lifecycle/claude-tool-request-wait.ts index 9c98ed3729d..336e59cc31a 100644 --- a/src/shared/agent-hook-listener/providers/claude-tool-request-wait.ts +++ b/src/shared/agent-hook-listener/providers/claude/lifecycle/claude-tool-request-wait.ts @@ -1,8 +1,8 @@ -import type { ParsedAgentStatusPayload } from '../../agent-status/contracts/agent-status-types' -import { readClaudeBatchSettledRequests } from '../claude-settled-permission-requests' -import { claudeHasRequestAwaitingUser } from '../claude-tool-request-identity' -import type { HookListenerState } from '../listener-state' -import type { ClaudeLeadTurnState } from '../main-agent-turn-state' +import type { ParsedAgentStatusPayload } from '../../../../agent-status/contracts/agent-status-types' +import { readClaudeBatchSettledRequests } from '../../../claude-settled-permission-requests' +import { claudeHasRequestAwaitingUser } from '../../../claude-tool-request-identity' +import type { HookListenerState } from '../../../listener-state' +import type { ClaudeLeadTurnState } from '../../../main-agent-turn-state' import { buildClaudeCachedLeadStatusPayload } from './claude-lifecycle-events' import { resolveClaudePaneStatus } from './claude-pane-hold-evidence' import { setClaudeMainAgentTurnState } from './claude-roster-state' diff --git a/src/shared/agent-hook-listener/providers/claude-helper-kill-poller.ts b/src/shared/agent-hook-listener/providers/claude/transcripts/claude-helper-kill-poller.ts similarity index 92% rename from src/shared/agent-hook-listener/providers/claude-helper-kill-poller.ts rename to src/shared/agent-hook-listener/providers/claude/transcripts/claude-helper-kill-poller.ts index f4d6861d0e0..1a0761459b6 100644 --- a/src/shared/agent-hook-listener/providers/claude-helper-kill-poller.ts +++ b/src/shared/agent-hook-listener/providers/claude/transcripts/claude-helper-kill-poller.ts @@ -1,15 +1,15 @@ import { dirname, basename, join, posix, win32 } from 'node:path' -import { AgentTranscriptPollScheduler } from '../transcripts/agent-transcript-poll-scheduler' +import { AgentTranscriptPollScheduler } from '../../../transcripts/agent-transcript-poll-scheduler' import { claudeRosterHasRuntimeWorkingSubagent, stopClaudeSubagent -} from '../../claude-subagent-roster' -import { stopOwingClaudeAgentTaskNotification } from './claude-owed-task-notifications' -import type { HookListenerState } from '../listener-state' -import type { AgentHookEventPayload } from '../listener-event' -import { buildClaudeCachedLeadStatusPayload } from './claude-lifecycle-events' -import { clearClaudePendingWaitForAgent } from './claude-roster-state' -import { claudeRowHasUnlistedLiveWork } from './claude-pane-hold-evidence' +} from '../../../../claude-subagent-roster' +import { stopOwingClaudeAgentTaskNotification } from '../background-tasks/claude-owed-task-notifications' +import type { HookListenerState } from '../../../listener-state' +import type { AgentHookEventPayload } from '../../../listener-event' +import { buildClaudeCachedLeadStatusPayload } from '../lifecycle/claude-lifecycle-events' +import { clearClaudePendingWaitForAgent } from '../lifecycle/claude-roster-state' +import { claudeRowHasUnlistedLiveWork } from '../lifecycle/claude-pane-hold-evidence' import { createClaudeTranscriptCursor, clearClaudeTranscriptCursor, diff --git a/src/shared/agent-hook-listener/providers/claude-helper-transcript-evidence.ts b/src/shared/agent-hook-listener/providers/claude/transcripts/claude-helper-transcript-evidence.ts similarity index 97% rename from src/shared/agent-hook-listener/providers/claude-helper-transcript-evidence.ts rename to src/shared/agent-hook-listener/providers/claude/transcripts/claude-helper-transcript-evidence.ts index 24c750aa1a7..deb7ff71a46 100644 --- a/src/shared/agent-hook-listener/providers/claude-helper-transcript-evidence.ts +++ b/src/shared/agent-hook-listener/providers/claude/transcripts/claude-helper-transcript-evidence.ts @@ -1,10 +1,10 @@ -import { isClaudeToolResultContent } from '../../claude-tool-content' +import { isClaudeToolResultContent } from '../../../../claude-tool-content' import { setImmediate as yieldToEventLoop } from 'node:timers/promises' import type { ClaudeTranscriptCursor, ClaudeTranscriptApplication } from './claude-transcript-cursor' -import { isKnownHarnessInjectedUserTurnText } from '../../harness-injected-user-turns' +import { isKnownHarnessInjectedUserTurnText } from '../../../../harness-injected-user-turns' export type ClaudeHelperTranscriptEvidence = { observedAt: number diff --git a/src/shared/agent-hook-listener/providers/claude-transcript-cursor.ts b/src/shared/agent-hook-listener/providers/claude/transcripts/claude-transcript-cursor.ts similarity index 100% rename from src/shared/agent-hook-listener/providers/claude-transcript-cursor.ts rename to src/shared/agent-hook-listener/providers/claude/transcripts/claude-transcript-cursor.ts diff --git a/src/shared/agent-hook-listener/providers/claude-transcript-decoder.ts b/src/shared/agent-hook-listener/providers/claude/transcripts/claude-transcript-decoder.ts similarity index 97% rename from src/shared/agent-hook-listener/providers/claude-transcript-decoder.ts rename to src/shared/agent-hook-listener/providers/claude/transcripts/claude-transcript-decoder.ts index e2dfdc3f91b..45134956778 100644 --- a/src/shared/agent-hook-listener/providers/claude-transcript-decoder.ts +++ b/src/shared/agent-hook-listener/providers/claude/transcripts/claude-transcript-decoder.ts @@ -1,8 +1,8 @@ import parser, { type Token } from 'stream-json/core/parser.js' import exec from 'stream-chain/exec.js' import { none } from 'stream-chain/defs.js' -import { isKnownHarnessInjectedUserTurnText } from '../../harness-injected-user-turns' -import { isClaudeToolResultContent } from '../../claude-tool-content' +import { isKnownHarnessInjectedUserTurnText } from '../../../../harness-injected-user-turns' +import { isClaudeToolResultContent } from '../../../../claude-tool-content' type Scope = 'root' | 'message' | 'content' | 'block' | 'result' | 'discard' type Frame = { diff --git a/src/shared/agent-hook-listener/providers/claude/transcripts/claude-transcript-semantics.test.ts b/src/shared/agent-hook-listener/providers/claude/transcripts/claude-transcript-semantics.test.ts new file mode 100644 index 00000000000..26d788d0b27 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/claude/transcripts/claude-transcript-semantics.test.ts @@ -0,0 +1,34 @@ +import { mkdtemp, writeFile, appendFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createClaudeTranscriptCursor, readClaudeTranscriptChunk } from './claude-transcript-cursor' + +let dir: string +const now = Date.parse('2026-10-29T12:00:00Z') +const stamp = (offset: number) => new Date(now + offset).toISOString() +const kill = (offset = 1) => + `${JSON.stringify({ type: 'system', subtype: 'agents_killed', timestamp: stamp(offset) })}\n` +const prompt = (offset: number) => + `${JSON.stringify({ type: 'user', timestamp: stamp(offset), message: { content: 'Do the work' } })}\n` +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'claude-transcript-semantics-')) +}) +afterEach(async () => { + vi.restoreAllMocks() + await rm(dir, { recursive: true, force: true }) +}) + +describe('forward transcript reading', () => { + it('treats missing files as unverifiable and replacement/shrink as a new baseline', async () => { + const path = join(dir, 'replace.jsonl') + const cursor = createClaudeTranscriptCursor(path) + expect(await readClaudeTranscriptChunk(cursor)).toBeUndefined() + await writeFile(path, `${prompt(0)}${' '.repeat(200)}`) + await readClaudeTranscriptChunk(cursor) + await writeFile(path, kill()) + expect((await readClaudeTranscriptChunk(cursor))?.records).toEqual([]) + await appendFile(path, kill(20)) + expect((await readClaudeTranscriptChunk(cursor))?.records).toHaveLength(1) + }) +}) diff --git a/src/shared/agent-hook-listener/normalization/agent-hook-listener-codex-main-agent.test.ts b/src/shared/agent-hook-listener/providers/codex/agent-hook-listener-codex-main-agent.test.ts similarity index 92% rename from src/shared/agent-hook-listener/normalization/agent-hook-listener-codex-main-agent.test.ts rename to src/shared/agent-hook-listener/providers/codex/agent-hook-listener-codex-main-agent.test.ts index ee52fc1fe24..d4947b0e9a0 100644 --- a/src/shared/agent-hook-listener/normalization/agent-hook-listener-codex-main-agent.test.ts +++ b/src/shared/agent-hook-listener/providers/codex/agent-hook-listener-codex-main-agent.test.ts @@ -1,11 +1,11 @@ import { beforeEach, describe, expect, it } from 'vitest' -import { createHookListenerState, type HookListenerState } from '../listener-state' +import { createHookListenerState, type HookListenerState } from '../../listener-state' import { markCodexLeadTurnInterrupted, reconcileRemoteCodexState, seedCodexStateFromSnapshot -} from '../providers/codex-state' -import { PANE_KEY } from './agent-hook-listener-test-harness' +} from './codex-state' +import { PANE_KEY } from '../../normalization/agent-hook-listener-test-harness' describe('the Codex root record seeded from a durable row', () => { let state: HookListenerState diff --git a/src/shared/agent-hook-listener/providers/codex-async-questions.ts b/src/shared/agent-hook-listener/providers/codex/codex-async-questions.ts similarity index 96% rename from src/shared/agent-hook-listener/providers/codex-async-questions.ts rename to src/shared/agent-hook-listener/providers/codex/codex-async-questions.ts index 4f0e47921f9..ed117fd04ae 100644 --- a/src/shared/agent-hook-listener/providers/codex-async-questions.ts +++ b/src/shared/agent-hook-listener/providers/codex/codex-async-questions.ts @@ -1,9 +1,9 @@ import { CODEX_ASYNC_QUESTION_TOOL, parseCodexAsyncQuestionsInput -} from '../../codex-async-question-input' -import type { HookListenerState } from '../listener-state' -import { readString } from '../tool-input-preview' +} from '../../../codex-async-question-input' +import type { HookListenerState } from '../../listener-state' +import { readString } from '../../tool-input-preview' /** One `request_user_input_async` call and the question indexes it still waits on. */ type CodexAsyncQuestionCall = { diff --git a/src/shared/agent-hook-listener/providers/codex-ended-turn-event.ts b/src/shared/agent-hook-listener/providers/codex/codex-ended-turn-event.ts similarity index 79% rename from src/shared/agent-hook-listener/providers/codex-ended-turn-event.ts rename to src/shared/agent-hook-listener/providers/codex/codex-ended-turn-event.ts index 3ef0a57c3fe..11d15729ad7 100644 --- a/src/shared/agent-hook-listener/providers/codex-ended-turn-event.ts +++ b/src/shared/agent-hook-listener/providers/codex/codex-ended-turn-event.ts @@ -1,6 +1,6 @@ -import { codexTranscriptTurnEnd } from '../../codex-subagent-transcript' -import type { HookListenerState } from '../listener-state' -import { readString } from '../tool-input-preview' +import { codexTranscriptTurnEnd } from '../../../codex-subagent-transcript' +import type { HookListenerState } from '../../listener-state' +import { readString } from '../../tool-input-preview' /** Replay rollout-confirmed root ends as missing Stops, including /review; drop delayed hooks once the lead moved to another turn. */ export function codexHookEventForEndedTurn( diff --git a/src/shared/agent-hook-listener/providers/codex-ephemeral-threads.ts b/src/shared/agent-hook-listener/providers/codex/codex-ephemeral-threads.ts similarity index 94% rename from src/shared/agent-hook-listener/providers/codex-ephemeral-threads.ts rename to src/shared/agent-hook-listener/providers/codex/codex-ephemeral-threads.ts index f4f1daf6b66..ef0ba3dd290 100644 --- a/src/shared/agent-hook-listener/providers/codex-ephemeral-threads.ts +++ b/src/shared/agent-hook-listener/providers/codex/codex-ephemeral-threads.ts @@ -1,6 +1,6 @@ -import type { AgentHookEventPayload } from '../listener-event' -import type { HookListenerState } from '../listener-state' -import { readString } from '../tool-input-preview' +import type { AgentHookEventPayload } from '../../listener-event' +import type { HookListenerState } from '../../listener-state' +import { readString } from '../../tool-input-preview' // Enough for any run of /side chats in one pane; the oldest ended long ago. const MAX_FORKS_PER_PANE = 16 diff --git a/src/shared/agent-hook-listener/providers/codex-events.ts b/src/shared/agent-hook-listener/providers/codex/codex-events.ts similarity index 93% rename from src/shared/agent-hook-listener/providers/codex-events.ts rename to src/shared/agent-hook-listener/providers/codex/codex-events.ts index aaff6d57a3d..def9de822a5 100644 --- a/src/shared/agent-hook-listener/providers/codex-events.ts +++ b/src/shared/agent-hook-listener/providers/codex/codex-events.ts @@ -1,35 +1,35 @@ import { AGENT_MODEL_MAX_LENGTH, normalizeOptionalField -} from '../../agent-status/contracts/agent-status-field-normalization' +} from '../../../agent-status/contracts/agent-status-field-normalization' import { normalizeAgentStatusPayload, type ParsedAgentStatusPayload -} from '../../agent-status/contracts/agent-status-types' +} from '../../../agent-status/contracts/agent-status-types' import { mainAgentTurnInterrupted, type AgentLeadStatusResolution -} from '../../agent-status/lifecycle/agent-lead-status-fold' +} from '../../../agent-status/lifecycle/agent-lead-status-fold' import { codexRosterToSnapshots, enrichCodexSubagentIdentity, finishCodexSubagent, upsertCodexSubagent -} from '../../codex-subagent-roster' +} from '../../../codex-subagent-roster' import { codexTranscriptTurnEnd, reconcileCodexSubagentTranscript -} from '../../codex-subagent-transcript' +} from '../../../codex-subagent-transcript' import { codexTurnApprovalsAreAutoReviewed, reconcileCodexSubagentReviewer -} from '../../codex-subagent-reviewer' -import { readFirstString } from '../interactive-tool' -import type { HookListenerState } from '../listener-state' -import { resolvePrompt, resolveToolState } from '../prompt-fields' -import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' -import { readString } from '../tool-input-preview' +} from '../../../codex-subagent-reviewer' +import { readFirstString } from '../../interactive-tool' +import type { HookListenerState } from '../../listener-state' +import { resolvePrompt, resolveToolState } from '../../prompt-fields' +import { extractToolFields, isNewTurnEvent } from '../../provider-event-routing' +import { readString } from '../../tool-input-preview' import { applyCodexSessionStart } from './codex-session-start' import { codexHookEventState, @@ -302,5 +302,3 @@ export function normalizeCodexEvent( updateLead: true }) } - -export { codexHookEventForEndedTurn } from './codex-ended-turn-event' diff --git a/src/shared/agent-hook-listener/providers/codex-question-skip.ts b/src/shared/agent-hook-listener/providers/codex/codex-question-skip.ts similarity index 92% rename from src/shared/agent-hook-listener/providers/codex-question-skip.ts rename to src/shared/agent-hook-listener/providers/codex/codex-question-skip.ts index cce40781eb0..ef6f3c716e9 100644 --- a/src/shared/agent-hook-listener/providers/codex-question-skip.ts +++ b/src/shared/agent-hook-listener/providers/codex/codex-question-skip.ts @@ -1,5 +1,5 @@ -import type { AgentHookEventPayload } from '../listener-event' -import type { HookListenerState } from '../listener-state' +import type { AgentHookEventPayload } from '../../listener-event' +import type { HookListenerState } from '../../listener-state' import { pendingCodexAsyncQuestion, skipCodexAsyncQuestion } from './codex-async-questions' import { buildCodexStatusPayload } from './codex-events' import { resolveCodexPaneStatus, setCodexMainAgentTurnState } from './codex-state' diff --git a/src/shared/agent-hook-listener/providers/codex-session-start.ts b/src/shared/agent-hook-listener/providers/codex/codex-session-start.ts similarity index 95% rename from src/shared/agent-hook-listener/providers/codex-session-start.ts rename to src/shared/agent-hook-listener/providers/codex/codex-session-start.ts index 9dc8ad63ce2..6d829ecae83 100644 --- a/src/shared/agent-hook-listener/providers/codex-session-start.ts +++ b/src/shared/agent-hook-listener/providers/codex/codex-session-start.ts @@ -1,15 +1,15 @@ import { normalizeAgentStatusPayload, type ParsedAgentStatusPayload -} from '../../agent-status/contracts/agent-status-types' +} from '../../../agent-status/contracts/agent-status-types' import { sameAgentStatusSessionOwner, sameAgentStatusTerminalOwner -} from '../../agent-status/identity/agent-status-terminal-owner' -import type { AgentLeadStatusResolution } from '../../agent-status/lifecycle/agent-lead-status-fold' -import { codexRosterToSnapshots, type CodexSubagentRoster } from '../../codex-subagent-roster' -import type { AgentHookEventPayload } from '../listener-event' -import type { HookListenerState } from '../listener-state' +} from '../../../agent-status/identity/agent-status-terminal-owner' +import type { AgentLeadStatusResolution } from '../../../agent-status/lifecycle/agent-lead-status-fold' +import { codexRosterToSnapshots, type CodexSubagentRoster } from '../../../codex-subagent-roster' +import type { AgentHookEventPayload } from '../../listener-event' +import type { HookListenerState } from '../../listener-state' import { codexMainAgentStatusForPayload, reconcileRemoteCodexState, diff --git a/src/shared/agent-hook-listener/providers/codex-state.ts b/src/shared/agent-hook-listener/providers/codex/codex-state.ts similarity index 94% rename from src/shared/agent-hook-listener/providers/codex-state.ts rename to src/shared/agent-hook-listener/providers/codex/codex-state.ts index 39e9d88f518..96ad622aa0c 100644 --- a/src/shared/agent-hook-listener/providers/codex-state.ts +++ b/src/shared/agent-hook-listener/providers/codex/codex-state.ts @@ -1,27 +1,27 @@ -import type { AgentMainAgentStatus } from '../../agent-status/lifecycle/main-agent-status' -import type { ParsedAgentStatusPayload } from '../../agent-status/contracts/agent-status-types' -import { isAskUserQuestionTool } from '../../agent-status/lifecycle/agent-question-answered-intent' -import { CODEX_ASYNC_QUESTION_TOOL } from '../../codex-async-question-input' +import type { AgentMainAgentStatus } from '../../../agent-status/lifecycle/main-agent-status' +import type { ParsedAgentStatusPayload } from '../../../agent-status/contracts/agent-status-types' +import { isAskUserQuestionTool } from '../../../agent-status/lifecycle/agent-question-answered-intent' +import { CODEX_ASYNC_QUESTION_TOOL } from '../../../codex-async-question-input' import { continueMainAgentStatus, foldAgentLeadStatus, mainAgentTurnInterrupted, type AgentLeadStatusResolution -} from '../../agent-status/lifecycle/agent-lead-status-fold' +} from '../../../agent-status/lifecycle/agent-lead-status-fold' import { codexRosterChildWorkLiveness, codexRosterToSnapshots, finishCodexSubagent, seedCodexSubagentRoster, type CodexSubagentRoster -} from '../../codex-subagent-roster' +} from '../../../codex-subagent-roster' import { createCodexSubagentTranscriptState, hasTrackedCodexTranscriptSubagents, type CodexSubagentTranscriptState -} from '../../codex-subagent-transcript' -import type { HookListenerState } from '../listener-state' -import type { CodexLeadTurnState } from '../main-agent-turn-state' +} from '../../../codex-subagent-transcript' +import type { HookListenerState } from '../../listener-state' +import type { CodexLeadTurnState } from '../../main-agent-turn-state' export function getOrCreateCodexSubagentRoster( state: HookListenerState, diff --git a/src/shared/agent-hook-listener/providers/codex-tool-fields.ts b/src/shared/agent-hook-listener/providers/codex/codex-tool-fields.ts similarity index 88% rename from src/shared/agent-hook-listener/providers/codex-tool-fields.ts rename to src/shared/agent-hook-listener/providers/codex/codex-tool-fields.ts index 09b87c794a5..a3271f2f9c1 100644 --- a/src/shared/agent-hook-listener/providers/codex-tool-fields.ts +++ b/src/shared/agent-hook-listener/providers/codex/codex-tool-fields.ts @@ -1,11 +1,11 @@ -import type { ToolSnapshot } from '../listener-event' +import type { ToolSnapshot } from '../../listener-event' import { deriveToolInputPreview, hasAnyOwnField, readString, toolUpdate -} from '../tool-input-preview' -import { deriveInteractivePrompt } from '../interactive-tool' +} from '../../tool-input-preview' +import { deriveInteractivePrompt } from '../../interactive-tool' export function extractCodexToolFields( eventName: unknown, diff --git a/src/shared/agent-hook-listener/providers/codex-transcript-poll.ts b/src/shared/agent-hook-listener/providers/codex/codex-transcript-poll.ts similarity index 87% rename from src/shared/agent-hook-listener/providers/codex-transcript-poll.ts rename to src/shared/agent-hook-listener/providers/codex/codex-transcript-poll.ts index 608994e21d5..d7cc2f32371 100644 --- a/src/shared/agent-hook-listener/providers/codex-transcript-poll.ts +++ b/src/shared/agent-hook-listener/providers/codex/codex-transcript-poll.ts @@ -1,15 +1,15 @@ -import { record } from '../../codex-rollout-jsonl-cursor' +import { record } from '../../../codex-rollout-jsonl-cursor' import { hasPendingCodexSubagentIdentity, retryPendingCodexSubagentIdentities -} from '../../codex-subagent-roster' +} from '../../../codex-subagent-roster' import { codexTranscriptTurnEnd, reconcileCodexSubagentTranscript -} from '../../codex-subagent-transcript' -import { normalizeHookPayload } from '../normalization/normalize-hook-payload' -import type { AgentHookEventPayload } from '../listener-event' -import type { HookListenerState } from '../listener-state' +} from '../../../codex-subagent-transcript' +import { normalizeHookPayload } from '../../normalization/normalize-hook-payload' +import type { AgentHookEventPayload } from '../../listener-event' +import type { HookListenerState } from '../../listener-state' import { buildCodexChildDrivenStatusPayload } from './codex-events' import { getOrCreateCodexSubagentRoster, markCodexLeadTurnInterrupted } from './codex-state' diff --git a/src/shared/agent-hook-listener/providers/command-code-events.ts b/src/shared/agent-hook-listener/providers/command-code/command-code-events.ts similarity index 80% rename from src/shared/agent-hook-listener/providers/command-code-events.ts rename to src/shared/agent-hook-listener/providers/command-code/command-code-events.ts index a86d56deeda..dc762a7443b 100644 --- a/src/shared/agent-hook-listener/providers/command-code-events.ts +++ b/src/shared/agent-hook-listener/providers/command-code/command-code-events.ts @@ -1,10 +1,10 @@ import { normalizeAgentStatusPayload, type ParsedAgentStatusPayload -} from '../../agent-status/contracts/agent-status-types' -import type { HookListenerState } from '../listener-state' -import { resolvePrompt, resolveToolState } from '../prompt-fields' -import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' +} from '../../../agent-status/contracts/agent-status-types' +import type { HookListenerState } from '../../listener-state' +import { resolvePrompt, resolveToolState } from '../../prompt-fields' +import { extractToolFields, isNewTurnEvent } from '../../provider-event-routing' export function normalizeCommandCodeEvent( state: HookListenerState, diff --git a/src/shared/agent-hook-listener/providers/command-code-tool-fields.ts b/src/shared/agent-hook-listener/providers/command-code/command-code-tool-fields.ts similarity index 86% rename from src/shared/agent-hook-listener/providers/command-code-tool-fields.ts rename to src/shared/agent-hook-listener/providers/command-code/command-code-tool-fields.ts index 5d785d832c3..c92fdc0a3c6 100644 --- a/src/shared/agent-hook-listener/providers/command-code-tool-fields.ts +++ b/src/shared/agent-hook-listener/providers/command-code/command-code-tool-fields.ts @@ -1,13 +1,13 @@ -import type { ToolSnapshot } from '../listener-event' +import type { ToolSnapshot } from '../../listener-event' import { deriveFallbackToolInputPreview, deriveToolInputPreview, hasOwnField, readString, toolUpdate -} from '../tool-input-preview' -import { extractToolResponseText } from '../interactive-tool' -import { readLastCommandCodeAssistantFromTranscript } from '../command-code-transcript' +} from '../../tool-input-preview' +import { extractToolResponseText } from '../../interactive-tool' +import { readLastCommandCodeAssistantFromTranscript } from '../../command-code-transcript' export function extractCommandCodeToolFields( eventName: unknown, diff --git a/src/shared/agent-hook-listener/providers/copilot-events.ts b/src/shared/agent-hook-listener/providers/copilot/copilot-events.ts similarity index 88% rename from src/shared/agent-hook-listener/providers/copilot-events.ts rename to src/shared/agent-hook-listener/providers/copilot/copilot-events.ts index 9410202769a..9cb2b009f49 100644 --- a/src/shared/agent-hook-listener/providers/copilot-events.ts +++ b/src/shared/agent-hook-listener/providers/copilot/copilot-events.ts @@ -1,11 +1,11 @@ import { normalizeAgentStatusPayload, type ParsedAgentStatusPayload -} from '../../agent-status/contracts/agent-status-types' -import { readFirstString } from '../interactive-tool' -import type { HookListenerState } from '../listener-state' -import { resolvePrompt, resolveToolState } from '../prompt-fields' -import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' +} from '../../../agent-status/contracts/agent-status-types' +import { readFirstString } from '../../interactive-tool' +import type { HookListenerState } from '../../listener-state' +import { resolvePrompt, resolveToolState } from '../../prompt-fields' +import { extractToolFields, isNewTurnEvent } from '../../provider-event-routing' import { isAskUserTool, normalizeCopilotEventName, diff --git a/src/shared/agent-hook-listener/providers/copilot-tool-fields.ts b/src/shared/agent-hook-listener/providers/copilot/copilot-tool-fields.ts similarity index 97% rename from src/shared/agent-hook-listener/providers/copilot-tool-fields.ts rename to src/shared/agent-hook-listener/providers/copilot/copilot-tool-fields.ts index 491be19a30f..f5b99ec90c9 100644 --- a/src/shared/agent-hook-listener/providers/copilot-tool-fields.ts +++ b/src/shared/agent-hook-listener/providers/copilot/copilot-tool-fields.ts @@ -1,17 +1,17 @@ -import type { ToolSnapshot } from '../listener-event' +import type { ToolSnapshot } from '../../listener-event' import { deriveToolInputPreview, hasAnyOwnField, readString, toolUpdate -} from '../tool-input-preview' +} from '../../tool-input-preview' import { clearActiveToolFieldsUpdate, extractToolResponseText, parseJsonObjectString, readFirstString -} from '../interactive-tool' -import { readLastAssistantFromTranscript } from '../transcript-lines' +} from '../../interactive-tool' +import { readLastAssistantFromTranscript } from '../../transcript-lines' export function normalizeCopilotEventName(eventName: unknown): unknown { if (typeof eventName !== 'string') { diff --git a/src/shared/agent-hook-listener/providers/cursor-events.ts b/src/shared/agent-hook-listener/providers/cursor/cursor-events.ts similarity index 88% rename from src/shared/agent-hook-listener/providers/cursor-events.ts rename to src/shared/agent-hook-listener/providers/cursor/cursor-events.ts index e45fff8b06b..28ecfcb04ad 100644 --- a/src/shared/agent-hook-listener/providers/cursor-events.ts +++ b/src/shared/agent-hook-listener/providers/cursor/cursor-events.ts @@ -1,10 +1,10 @@ import { normalizeAgentStatusPayload, type ParsedAgentStatusPayload -} from '../../agent-status/contracts/agent-status-types' -import type { HookListenerState } from '../listener-state' -import { resolvePrompt, resolveToolState } from '../prompt-fields' -import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' +} from '../../../agent-status/contracts/agent-status-types' +import type { HookListenerState } from '../../listener-state' +import { resolvePrompt, resolveToolState } from '../../prompt-fields' +import { extractToolFields, isNewTurnEvent } from '../../provider-event-routing' export function normalizeCursorEvent( state: HookListenerState, diff --git a/src/shared/agent-hook-listener/providers/cursor-tool-fields.ts b/src/shared/agent-hook-listener/providers/cursor/cursor-tool-fields.ts similarity index 95% rename from src/shared/agent-hook-listener/providers/cursor-tool-fields.ts rename to src/shared/agent-hook-listener/providers/cursor/cursor-tool-fields.ts index 7e0ab173c18..ed47925fa75 100644 --- a/src/shared/agent-hook-listener/providers/cursor-tool-fields.ts +++ b/src/shared/agent-hook-listener/providers/cursor/cursor-tool-fields.ts @@ -1,12 +1,12 @@ -import type { ToolSnapshot } from '../listener-event' +import type { ToolSnapshot } from '../../listener-event' import { deriveToolInputPreview, hasAnyOwnField, hasOwnField, readString, toolUpdate -} from '../tool-input-preview' -import { clearActiveToolFieldsUpdate, extractToolResponseText } from '../interactive-tool' +} from '../../tool-input-preview' +import { clearActiveToolFieldsUpdate, extractToolResponseText } from '../../interactive-tool' export function extractCursorToolFields( eventName: unknown, diff --git a/src/shared/agent-hook-listener/providers/devin-events.ts b/src/shared/agent-hook-listener/providers/devin/devin-events.ts similarity index 89% rename from src/shared/agent-hook-listener/providers/devin-events.ts rename to src/shared/agent-hook-listener/providers/devin/devin-events.ts index 89c203ceb5f..8e23302dba2 100644 --- a/src/shared/agent-hook-listener/providers/devin-events.ts +++ b/src/shared/agent-hook-listener/providers/devin/devin-events.ts @@ -1,10 +1,10 @@ import { normalizeAgentStatusPayload, type ParsedAgentStatusPayload -} from '../../agent-status/contracts/agent-status-types' -import { clearPaneTurnCacheState, type HookListenerState } from '../listener-state' -import { resolvePrompt, resolveToolState } from '../prompt-fields' -import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' +} from '../../../agent-status/contracts/agent-status-types' +import { clearPaneTurnCacheState, type HookListenerState } from '../../listener-state' +import { resolvePrompt, resolveToolState } from '../../prompt-fields' +import { extractToolFields, isNewTurnEvent } from '../../provider-event-routing' // Why: Devin uses Claude-compatible payloads but its own lifecycle event set; normalize those event names while keeping Devin attribution. export function normalizeDevinEvent( diff --git a/src/shared/agent-hook-listener/providers/droid-events.ts b/src/shared/agent-hook-listener/providers/droid/droid-events.ts similarity index 90% rename from src/shared/agent-hook-listener/providers/droid-events.ts rename to src/shared/agent-hook-listener/providers/droid/droid-events.ts index 8a7de57105a..61e56171f2d 100644 --- a/src/shared/agent-hook-listener/providers/droid-events.ts +++ b/src/shared/agent-hook-listener/providers/droid/droid-events.ts @@ -1,11 +1,11 @@ import { normalizeAgentStatusPayload, type ParsedAgentStatusPayload -} from '../../agent-status/contracts/agent-status-types' -import { clearPaneTurnCacheState, type HookListenerState } from '../listener-state' -import { resolvePrompt, resolveToolState } from '../prompt-fields' -import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' -import { readString } from '../tool-input-preview' +} from '../../../agent-status/contracts/agent-status-types' +import { clearPaneTurnCacheState, type HookListenerState } from '../../listener-state' +import { resolvePrompt, resolveToolState } from '../../prompt-fields' +import { extractToolFields, isNewTurnEvent } from '../../provider-event-routing' +import { readString } from '../../tool-input-preview' import { isDroidAskUserTool, isDroidHighRiskToolUse, diff --git a/src/shared/agent-hook-listener/providers/droid-tool-fields.ts b/src/shared/agent-hook-listener/providers/droid/droid-tool-fields.ts similarity index 93% rename from src/shared/agent-hook-listener/providers/droid-tool-fields.ts rename to src/shared/agent-hook-listener/providers/droid/droid-tool-fields.ts index 1f88f2da439..52561f4f2bd 100644 --- a/src/shared/agent-hook-listener/providers/droid-tool-fields.ts +++ b/src/shared/agent-hook-listener/providers/droid/droid-tool-fields.ts @@ -1,12 +1,12 @@ -import type { ToolSnapshot } from '../listener-event' +import type { ToolSnapshot } from '../../listener-event' import { deriveToolInputPreview, hasAnyOwnField, readString, toolUpdate -} from '../tool-input-preview' -import { extractToolResponseText } from '../interactive-tool' -import { readLastAssistantFromTranscript } from '../transcript-lines' +} from '../../tool-input-preview' +import { extractToolResponseText } from '../../interactive-tool' +import { readLastAssistantFromTranscript } from '../../transcript-lines' export function isDroidPermissionNotification(message: string | undefined): boolean { if (!message) { diff --git a/src/shared/agent-hook-listener/providers/dsh-events.test.ts b/src/shared/agent-hook-listener/providers/dsh/dsh-events.test.ts similarity index 93% rename from src/shared/agent-hook-listener/providers/dsh-events.test.ts rename to src/shared/agent-hook-listener/providers/dsh/dsh-events.test.ts index c64cce02b74..5d2503b6819 100644 --- a/src/shared/agent-hook-listener/providers/dsh-events.test.ts +++ b/src/shared/agent-hook-listener/providers/dsh/dsh-events.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it } from 'vitest' -import { createHookListenerState, type HookListenerState } from '../listener-state' -import { normalizeAndAccept } from '../normalization/agent-hook-listener-test-harness' +import { createHookListenerState, type HookListenerState } from '../../listener-state' +import { normalizeAndAccept } from '../../normalization/agent-hook-listener-test-harness' // Real @deepseek-ai/dsh-hooks-claude-code0.1.5-rc.3 payloads: session/cwd/event fields and empty transcript_path, mounted through DSH_HOME/cordis.patch.yml. const SESSION_ID = '9478e2d8-29bc-4009-ab32-657efa2bd763' diff --git a/src/shared/agent-hook-listener/providers/dsh-events.ts b/src/shared/agent-hook-listener/providers/dsh/dsh-events.ts similarity index 84% rename from src/shared/agent-hook-listener/providers/dsh-events.ts rename to src/shared/agent-hook-listener/providers/dsh/dsh-events.ts index 74675e79feb..997151d3a49 100644 --- a/src/shared/agent-hook-listener/providers/dsh-events.ts +++ b/src/shared/agent-hook-listener/providers/dsh/dsh-events.ts @@ -1,16 +1,16 @@ -import { isAskUserQuestionTool } from '../../agent-status/lifecycle/agent-question-answered-intent' +import { isAskUserQuestionTool } from '../../../agent-status/lifecycle/agent-question-answered-intent' import { normalizeAgentStatusPayload, type ParsedAgentStatusPayload -} from '../../agent-status/contracts/agent-status-types' -import type { HookListenerState } from '../listener-state' +} from '../../../agent-status/contracts/agent-status-types' +import type { HookListenerState } from '../../listener-state' import { resolvePrompt, resolveToolState, shouldIgnoreCompactContinuationUserPromptSubmit -} from '../prompt-fields' -import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' -import { readString } from '../tool-input-preview' +} from '../../prompt-fields' +import { extractToolFields, isNewTurnEvent } from '../../provider-event-routing' +import { readString } from '../../tool-input-preview' /** DSH's Claude-shaped bridge lacks Notification/PermissionRequest. ask_user_question blocks between Pre/PostToolUse; unobservable approval pauses remain working. */ export function normalizeDshEvent( diff --git a/src/shared/agent-hook-listener/providers/gemini-events.ts b/src/shared/agent-hook-listener/providers/gemini/gemini-events.ts similarity index 83% rename from src/shared/agent-hook-listener/providers/gemini-events.ts rename to src/shared/agent-hook-listener/providers/gemini/gemini-events.ts index 894135d9aeb..940119a8a03 100644 --- a/src/shared/agent-hook-listener/providers/gemini-events.ts +++ b/src/shared/agent-hook-listener/providers/gemini/gemini-events.ts @@ -1,10 +1,10 @@ import { normalizeAgentStatusPayload, type ParsedAgentStatusPayload -} from '../../agent-status/contracts/agent-status-types' -import type { HookListenerState } from '../listener-state' -import { resolvePrompt, resolveToolState } from '../prompt-fields' -import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' +} from '../../../agent-status/contracts/agent-status-types' +import type { HookListenerState } from '../../listener-state' +import { resolvePrompt, resolveToolState } from '../../prompt-fields' +import { extractToolFields, isNewTurnEvent } from '../../provider-event-routing' export function normalizeGeminiEvent( state: HookListenerState, diff --git a/src/shared/agent-hook-listener/providers/gemini-tool-fields.ts b/src/shared/agent-hook-listener/providers/gemini/gemini-tool-fields.ts similarity index 91% rename from src/shared/agent-hook-listener/providers/gemini-tool-fields.ts rename to src/shared/agent-hook-listener/providers/gemini/gemini-tool-fields.ts index 9fadfea6e3c..40bbe9b960f 100644 --- a/src/shared/agent-hook-listener/providers/gemini-tool-fields.ts +++ b/src/shared/agent-hook-listener/providers/gemini/gemini-tool-fields.ts @@ -1,10 +1,10 @@ -import type { ToolSnapshot } from '../listener-event' +import type { ToolSnapshot } from '../../listener-event' import { deriveToolInputPreview, hasAnyOwnField, readString, toolUpdate -} from '../tool-input-preview' +} from '../../tool-input-preview' export function extractGeminiToolFields( eventName: unknown, diff --git a/src/shared/agent-hook-listener/providers/grok-background-tasks.ts b/src/shared/agent-hook-listener/providers/grok/grok-background-tasks.ts similarity index 89% rename from src/shared/agent-hook-listener/providers/grok-background-tasks.ts rename to src/shared/agent-hook-listener/providers/grok/grok-background-tasks.ts index 558f8d0bf2f..c503aa9d3ad 100644 --- a/src/shared/agent-hook-listener/providers/grok-background-tasks.ts +++ b/src/shared/agent-hook-listener/providers/grok/grok-background-tasks.ts @@ -1,12 +1,12 @@ -import type { AgentChildWorkKind } from '../../agent-status/child-work/agent-status-child-work' +import type { AgentChildWorkKind } from '../../../agent-status/child-work/agent-status-child-work' import { agentChildWorkLiveness, type AgentChildWorkLiveness -} from '../../agent-status/child-work/agent-status-child-work-liveness' -import { isRecord } from '../../agent-status/child-work/agent-status-child-work-value-guards' -import type { HookListenerState } from '../listener-state' -import { isGrokEvent } from '../provider-event-names' -import { readString } from '../tool-input-preview' +} from '../../../agent-status/child-work/agent-status-child-work-liveness' +import { isRecord } from '../../../agent-status/child-work/agent-status-child-work-value-guards' +import type { HookListenerState } from '../../listener-state' +import { isGrokEvent } from '../../provider-event-names' +import { readString } from '../../tool-input-preview' import { getGrokNotificationType } from './grok-tool-fields' // Grok's own task ids per pane, since StopCancelled carries no inventory. A child session's id is its diff --git a/src/shared/agent-hook-listener/providers/grok-events.ts b/src/shared/agent-hook-listener/providers/grok/grok-events.ts similarity index 93% rename from src/shared/agent-hook-listener/providers/grok-events.ts rename to src/shared/agent-hook-listener/providers/grok/grok-events.ts index a2ac1ce621f..9ba5f9f2e68 100644 --- a/src/shared/agent-hook-listener/providers/grok-events.ts +++ b/src/shared/agent-hook-listener/providers/grok/grok-events.ts @@ -1,23 +1,23 @@ import { normalizeAgentStatusPayload, type ParsedAgentStatusPayload -} from '../../agent-status/contracts/agent-status-types' -import { isAskUserQuestionTool } from '../../agent-status/lifecycle/agent-question-answered-intent' +} from '../../../agent-status/contracts/agent-status-types' +import { isAskUserQuestionTool } from '../../../agent-status/lifecycle/agent-question-answered-intent' import { continueMainAgentStatus, foldAgentLeadStatus -} from '../../agent-status/lifecycle/agent-lead-status-fold' +} from '../../../agent-status/lifecycle/agent-lead-status-fold' import { agentChildWorkLiveness, type AgentChildWorkLiveness, type AgentChildWorkLivenessCandidate -} from '../../agent-status/child-work/agent-status-child-work-liveness' -import { clearPaneTurnCacheState, type HookListenerState } from '../listener-state' -import { normalizeGrokPromptId } from '../listener-limits' -import { resolvePrompt, resolveToolState, stripGrokUserQueryWrapper } from '../prompt-fields' -import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' -import { readString } from '../tool-input-preview' -import { isGrokEvent } from '../provider-event-names' +} from '../../../agent-status/child-work/agent-status-child-work-liveness' +import { clearPaneTurnCacheState, type HookListenerState } from '../../listener-state' +import { normalizeGrokPromptId } from '../../listener-limits' +import { resolvePrompt, resolveToolState, stripGrokUserQueryWrapper } from '../../prompt-fields' +import { extractToolFields, isNewTurnEvent } from '../../provider-event-routing' +import { readString } from '../../tool-input-preview' +import { isGrokEvent } from '../../provider-event-names' import { grokBackgroundTaskLiveness, grokFiniteTaskKind, diff --git a/src/shared/agent-hook-listener/providers/grok-tool-fields.ts b/src/shared/agent-hook-listener/providers/grok/grok-tool-fields.ts similarity index 92% rename from src/shared/agent-hook-listener/providers/grok-tool-fields.ts rename to src/shared/agent-hook-listener/providers/grok/grok-tool-fields.ts index fab7c0b962c..d3e9b118800 100644 --- a/src/shared/agent-hook-listener/providers/grok-tool-fields.ts +++ b/src/shared/agent-hook-listener/providers/grok/grok-tool-fields.ts @@ -1,19 +1,19 @@ -import type { ToolSnapshot } from '../listener-event' +import type { ToolSnapshot } from '../../listener-event' import { deriveFallbackToolInputPreview, deriveToolInputPreview, hasAnyOwnField, readString, toolUpdate -} from '../tool-input-preview' +} from '../../tool-input-preview' import { clearActiveToolFieldsUpdate, deriveInteractivePrompt, extractToolResponseText -} from '../interactive-tool' -import { readLastAssistantFromTranscript } from '../transcript-lines' -import { readLastAssistantFromGrokChatHistory } from '../grok-result-discovery' -import { isGrokEvent } from '../provider-event-names' +} from '../../interactive-tool' +import { readLastAssistantFromTranscript } from '../../transcript-lines' +import { readLastAssistantFromGrokChatHistory } from '../../grok-result-discovery' +import { isGrokEvent } from '../../provider-event-names' export function extractGrokToolFields( eventName: unknown, diff --git a/src/shared/agent-hook-listener/providers/hermes-events.test.ts b/src/shared/agent-hook-listener/providers/hermes/hermes-events.test.ts similarity index 99% rename from src/shared/agent-hook-listener/providers/hermes-events.test.ts rename to src/shared/agent-hook-listener/providers/hermes/hermes-events.test.ts index 27f40b21947..00dfc756d09 100644 --- a/src/shared/agent-hook-listener/providers/hermes-events.test.ts +++ b/src/shared/agent-hook-listener/providers/hermes/hermes-events.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { createHookListenerState, type HookListenerState } from '../listener-state' +import { createHookListenerState, type HookListenerState } from '../../listener-state' import { normalizeHermesEvent } from './hermes-events' describe('normalizeHermesEvent', () => { diff --git a/src/shared/agent-hook-listener/providers/hermes-events.ts b/src/shared/agent-hook-listener/providers/hermes/hermes-events.ts similarity index 93% rename from src/shared/agent-hook-listener/providers/hermes-events.ts rename to src/shared/agent-hook-listener/providers/hermes/hermes-events.ts index 8572bc025a9..bf01a2e2d12 100644 --- a/src/shared/agent-hook-listener/providers/hermes-events.ts +++ b/src/shared/agent-hook-listener/providers/hermes/hermes-events.ts @@ -1,10 +1,10 @@ import { normalizeAgentStatusPayload, type ParsedAgentStatusPayload -} from '../../agent-status/contracts/agent-status-types' -import type { HookListenerState } from '../listener-state' -import { resolvePrompt, resolveToolState } from '../prompt-fields' -import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' +} from '../../../agent-status/contracts/agent-status-types' +import type { HookListenerState } from '../../listener-state' +import { resolvePrompt, resolveToolState } from '../../prompt-fields' +import { extractToolFields, isNewTurnEvent } from '../../provider-event-routing' /** Session open/switch/reset is idle, not a turn. done+sessionBoundary avoids phantom work and 30-minute tui-idle vetoes. */ const HERMES_EVENT_STATES: Record = { diff --git a/src/shared/agent-hook-listener/providers/hermes-tool-fields.ts b/src/shared/agent-hook-listener/providers/hermes/hermes-tool-fields.ts similarity index 93% rename from src/shared/agent-hook-listener/providers/hermes-tool-fields.ts rename to src/shared/agent-hook-listener/providers/hermes/hermes-tool-fields.ts index 865e92b20ff..45e82d2dd68 100644 --- a/src/shared/agent-hook-listener/providers/hermes-tool-fields.ts +++ b/src/shared/agent-hook-listener/providers/hermes/hermes-tool-fields.ts @@ -1,12 +1,12 @@ -import type { ToolSnapshot } from '../listener-event' +import type { ToolSnapshot } from '../../listener-event' import { deriveFallbackToolInputPreview, deriveToolInputPreview, hasAnyOwnField, readString, toolUpdate -} from '../tool-input-preview' -import { extractToolResponseText } from '../interactive-tool' +} from '../../tool-input-preview' +import { extractToolResponseText } from '../../interactive-tool' export function extractHermesToolFields( eventName: unknown, diff --git a/src/shared/agent-hook-listener/providers/jcode-turn-prompt.test.ts b/src/shared/agent-hook-listener/providers/jcode-turn-prompt.test.ts deleted file mode 100644 index a1ec6b923b6..00000000000 --- a/src/shared/agent-hook-listener/providers/jcode-turn-prompt.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { JcodeUserPromptEvidence } from '../../jcode-session-files' - -const { readMock } = vi.hoisted(() => ({ - readMock: vi.fn<() => JcodeUserPromptEvidence | null>() -})) -vi.mock('../../jcode-session-files', () => ({ - readLastJcodeUserPromptFromHookPayload: readMock -})) - -import { createHookListenerState, type HookListenerState } from '../listener-state' -import { readJcodeTurnPrompt } from './jcode-turn-prompt' - -const PAYLOAD = { session_id: 'session_jc_9' } - -// jcode blocks on pre_tool; repeated synchronous transcript scans add latency to every tool. -describe('jcode turn prompt cache', () => { - let state: HookListenerState - - beforeEach(() => { - state = createHookListenerState() - readMock.mockReset() - readMock.mockReturnValue({ text: 'ship it', interactionKey: 'k1' }) - }) - - it('re-reads on the next turn, so a new prompt is not served from the old turn', () => { - const first = readJcodeTurnPrompt(state, 'turn_start', 'pane-a', PAYLOAD) - expect(first).toEqual({ text: 'ship it', interactionKey: 'k1' }) - const next = { text: 'review the release', interactionKey: 'k2' } - readMock.mockReturnValue(next) - expect(readJcodeTurnPrompt(state, 'pre_tool', 'pane-a', PAYLOAD)).toEqual(first) - expect(readJcodeTurnPrompt(state, 'turn_start', 'pane-a', PAYLOAD)).toEqual(next) - expect(readJcodeTurnPrompt(state, 'post_tool', 'pane-a', PAYLOAD)).toEqual(next) - expect(readMock).toHaveBeenCalledTimes(2) - }) - - it('caches per pane, so one pane cannot answer for another', () => { - const paneA = { text: 'ship it', interactionKey: 'k1' } - const paneB = { text: 'review the release', interactionKey: 'k2' } - const payloadB = { session_id: 'session_jc_10' } - readMock.mockReturnValueOnce(paneA).mockReturnValueOnce(paneB) - expect(readJcodeTurnPrompt(state, 'pre_tool', 'pane-a', PAYLOAD)).toEqual(paneA) - expect(readJcodeTurnPrompt(state, 'pre_tool', 'pane-b', payloadB)).toEqual(paneB) - expect(readJcodeTurnPrompt(state, 'post_tool', 'pane-a', PAYLOAD)).toEqual(paneA) - expect(readJcodeTurnPrompt(state, 'post_tool', 'pane-b', payloadB)).toEqual(paneB) - expect(readMock).toHaveBeenCalledTimes(2) - }) -}) diff --git a/src/shared/agent-hook-listener/providers/jcode-events.ts b/src/shared/agent-hook-listener/providers/jcode/jcode-events.ts similarity index 89% rename from src/shared/agent-hook-listener/providers/jcode-events.ts rename to src/shared/agent-hook-listener/providers/jcode/jcode-events.ts index 3a111fa2691..8321bc6d428 100644 --- a/src/shared/agent-hook-listener/providers/jcode-events.ts +++ b/src/shared/agent-hook-listener/providers/jcode/jcode-events.ts @@ -2,11 +2,11 @@ import { normalizeAgentStatusPayload, type AgentStatusState, type ParsedAgentStatusPayload -} from '../../agent-status/contracts/agent-status-types' -import { clearPaneTurnCacheState, type HookListenerState } from '../listener-state' -import { resolvePrompt, resolveToolState } from '../prompt-fields' -import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' -import { readString } from '../tool-input-preview' +} from '../../../agent-status/contracts/agent-status-types' +import { clearPaneTurnCacheState, type HookListenerState } from '../../listener-state' +import { resolvePrompt, resolveToolState } from '../../prompt-fields' +import { extractToolFields, isNewTurnEvent } from '../../provider-event-routing' +import { readString } from '../../tool-input-preview' import { isJcodeUserInputTool } from './jcode-tool-fields' // Lifecycle mapping follows docs/reference/jcode-hook-events.md; idle session_start takes the early path. diff --git a/src/shared/agent-hook-listener/providers/jcode-helper-permission-wait.ts b/src/shared/agent-hook-listener/providers/jcode/jcode-helper-permission-wait.ts similarity index 90% rename from src/shared/agent-hook-listener/providers/jcode-helper-permission-wait.ts rename to src/shared/agent-hook-listener/providers/jcode/jcode-helper-permission-wait.ts index 3dbe62cb420..89cb8610105 100644 --- a/src/shared/agent-hook-listener/providers/jcode-helper-permission-wait.ts +++ b/src/shared/agent-hook-listener/providers/jcode/jcode-helper-permission-wait.ts @@ -1,9 +1,9 @@ -import { isCachedJcodeHelperSession } from '../../jcode-helper-sessions' -import type { ParsedHookEnvelope } from '../hook-envelope' -import type { AgentHookEventPayload } from '../listener-event' -import type { HookListenerState } from '../listener-state' -import { readString } from '../tool-input-preview' -import type { AgentHookSource } from '../transport/agent-hook-relay' +import { isCachedJcodeHelperSession } from '../../../jcode-helper-sessions' +import type { ParsedHookEnvelope } from '../../hook-envelope' +import type { AgentHookEventPayload } from '../../listener-event' +import type { HookListenerState } from '../../listener-state' +import { readString } from '../../tool-input-preview' +import type { AgentHookSource } from '../../transport/agent-hook-relay' import { extractJcodeToolFields, isJcodeUserInputTool } from './jcode-tool-fields' type HelperRequest = { toolInput?: string; interactivePrompt?: string } diff --git a/src/shared/agent-hook-listener/providers/jcode-tool-fields.ts b/src/shared/agent-hook-listener/providers/jcode/jcode-tool-fields.ts similarity index 96% rename from src/shared/agent-hook-listener/providers/jcode-tool-fields.ts rename to src/shared/agent-hook-listener/providers/jcode/jcode-tool-fields.ts index 78a72ff4263..63030e70075 100644 --- a/src/shared/agent-hook-listener/providers/jcode-tool-fields.ts +++ b/src/shared/agent-hook-listener/providers/jcode/jcode-tool-fields.ts @@ -1,11 +1,11 @@ -import type { ToolSnapshot } from '../listener-event' +import type { ToolSnapshot } from '../../listener-event' import { deriveFallbackToolInputPreview, deriveToolInputPreview, hasOwnField, readString, toolUpdate -} from '../tool-input-preview' +} from '../../tool-input-preview' /** Only request_permission waits on a person (resolved via jcode permissions); other safety decisions stay in-model/tool. Match exactly to avoid widening human waits. */ export function isJcodeUserInputTool(toolName: string | undefined): boolean { diff --git a/src/shared/agent-hook-listener/providers/jcode-turn-prompt.ts b/src/shared/agent-hook-listener/providers/jcode/jcode-turn-prompt.ts similarity index 81% rename from src/shared/agent-hook-listener/providers/jcode-turn-prompt.ts rename to src/shared/agent-hook-listener/providers/jcode/jcode-turn-prompt.ts index 7b6459bdacd..b7fa7e50440 100644 --- a/src/shared/agent-hook-listener/providers/jcode-turn-prompt.ts +++ b/src/shared/agent-hook-listener/providers/jcode/jcode-turn-prompt.ts @@ -1,9 +1,9 @@ import { readLastJcodeUserPromptFromHookPayload, type JcodeUserPromptEvidence -} from '../../jcode-session-files' -import { isNewTurnEvent } from '../provider-event-routing' -import type { HookListenerState } from '../listener-state' +} from '../../../jcode-session-files' +import { isNewTurnEvent } from '../../provider-event-routing' +import type { HookListenerState } from '../../listener-state' /** Cache one bounded synchronous journal read per turn: jcode blocks on pre_tool. Without a pane key, read directly. */ export function readJcodeTurnPrompt( diff --git a/src/shared/agent-hook-listener/providers/kimi-events.ts b/src/shared/agent-hook-listener/providers/kimi/kimi-events.ts similarity index 90% rename from src/shared/agent-hook-listener/providers/kimi-events.ts rename to src/shared/agent-hook-listener/providers/kimi/kimi-events.ts index 96cc084b19e..667d6a84c72 100644 --- a/src/shared/agent-hook-listener/providers/kimi-events.ts +++ b/src/shared/agent-hook-listener/providers/kimi/kimi-events.ts @@ -1,15 +1,15 @@ import { normalizeAgentStatusPayload, type ParsedAgentStatusPayload -} from '../../agent-status/contracts/agent-status-types' -import type { HookListenerState } from '../listener-state' +} from '../../../agent-status/contracts/agent-status-types' +import type { HookListenerState } from '../../listener-state' import { resolvePrompt, resolveToolState, shouldIgnoreCompactContinuationUserPromptSubmit -} from '../prompt-fields' -import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' -import { readString } from '../tool-input-preview' +} from '../../prompt-fields' +import { extractToolFields, isNewTurnEvent } from '../../provider-event-routing' +import { readString } from '../../tool-input-preview' // Auto-allowed AskUserQuestion still blocks a human on PreToolUse. export function isKimiUserInputTool(toolName: string | undefined): boolean { diff --git a/src/shared/agent-hook-listener/providers/kiro-events.ts b/src/shared/agent-hook-listener/providers/kiro/kiro-events.ts similarity index 84% rename from src/shared/agent-hook-listener/providers/kiro-events.ts rename to src/shared/agent-hook-listener/providers/kiro/kiro-events.ts index 942150f7062..b3bcab5a35e 100644 --- a/src/shared/agent-hook-listener/providers/kiro-events.ts +++ b/src/shared/agent-hook-listener/providers/kiro/kiro-events.ts @@ -1,10 +1,10 @@ import { normalizeAgentStatusPayload, type ParsedAgentStatusPayload -} from '../../agent-status/contracts/agent-status-types' -import type { HookListenerState } from '../listener-state' -import { resolvePrompt, resolveToolState } from '../prompt-fields' -import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' +} from '../../../agent-status/contracts/agent-status-types' +import type { HookListenerState } from '../../listener-state' +import { resolvePrompt, resolveToolState } from '../../prompt-fields' +import { extractToolFields, isNewTurnEvent } from '../../provider-event-routing' /** Agent-config Kiro hooks use Claude-shaped fields without permission/notification events; approval pauses remain working. Subagents have their own config/hooks, not the lead's. */ export function normalizeKiroEvent( diff --git a/src/shared/agent-hook-listener/providers/kiro-tool-fields.ts b/src/shared/agent-hook-listener/providers/kiro/kiro-tool-fields.ts similarity index 84% rename from src/shared/agent-hook-listener/providers/kiro-tool-fields.ts rename to src/shared/agent-hook-listener/providers/kiro/kiro-tool-fields.ts index 7d5fe87711e..514a49312df 100644 --- a/src/shared/agent-hook-listener/providers/kiro-tool-fields.ts +++ b/src/shared/agent-hook-listener/providers/kiro/kiro-tool-fields.ts @@ -1,6 +1,6 @@ -import type { ToolSnapshot } from '../listener-event' -import { readString } from '../tool-input-preview' -import { extractClaudeToolFields } from './claude-tool-fields' +import type { ToolSnapshot } from '../../listener-event' +import { readString } from '../../tool-input-preview' +import { extractClaudeToolFields } from '../claude/claude-tool-fields' /** Kiro V2 camelCase hook names mapped onto Claude's; V3 already sends Claude's names. */ export const KIRO_CLAUDE_EVENT_NAMES: Readonly> = Object.freeze({ diff --git a/src/shared/agent-hook-listener/providers/muse-events.test.ts b/src/shared/agent-hook-listener/providers/muse-events.test.ts deleted file mode 100644 index c0496d2aef0..00000000000 --- a/src/shared/agent-hook-listener/providers/muse-events.test.ts +++ /dev/null @@ -1,229 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { appendFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { createHookListenerState, type HookListenerState } from '../listener-state' -import { normalizeAndAccept, PANE_KEY } from '../normalization/agent-hook-listener-test-harness' - -const SESSION_ID = '01a0caa3-0e77-7d41-bad7-46283a45633d' -const TURN_ID = 'e495d1a5-59aa-47b4-8efb-a1bd75509afc' -const CHILD_ID = '1471f5e6-bd60-41a2-bfcf-c9086dbd4092' -const PROMPT_ID = '01a0caa3-a25a-7810-8229-4de04b2e7ca3' -const QUESTION = { - id: 'fav_color', - header: 'Color', - question: 'What is your favorite color?', - options: [{ label: 'Blue' }, { label: 'Green' }, { label: 'Red' }] -} - -const envelope = { - cwd: '/tmp/ws', - transcript_path: null, - model: 'muse-spark-1.3', - permission_mode: 'default', - model_provider: 'meta' -} -const main = { ...envelope, session_id: SESSION_ID, turn_id: TURN_ID } -const child = { ...envelope, session_id: CHILD_ID, turn_id: CHILD_ID } -const reminderInput = { decision: 'none', skill_id: 'bundled:grill' } - -function sessionLogLine(event: Record): string { - return `${JSON.stringify({ - schema_version: 1, - stream: { kind: 'session', id: SESSION_ID }, - record_type: 'event', - payload_type: 'runtime.session', - payload: { kind: 'run', run_id: TURN_ID, event } - })}\n` -} - -function childReminderHooks(): Record[] { - return [ - { - ...child, - hook_event_name: 'PreToolUse', - tool_name: 'submit_reminder_decision', - tool_input: reminderInput, - tool_use_id: 'call_child' - }, - { - ...child, - hook_event_name: 'PermissionRequest', - tool_name: 'submit_reminder_decision', - tool_input: reminderInput - }, - { - ...child, - hook_event_name: 'PostToolUseFailure', - tool_name: 'submit_reminder_decision', - tool_input: reminderInput, - error: - 'tool failed: invalid reminder decision payload: unexpected field `additionalProperties`' - }, - { ...child, hook_event_name: 'SubagentStop', subagent_id: 'skill-reminder' } - ] -} - -describe('Muse hook events', () => { - let state: HookListenerState - let dataHome: string - - beforeEach(() => { - state = createHookListenerState() - dataHome = mkdtempSync(join(tmpdir(), 'muse-events-')) - vi.stubEnv('XDG_DATA_HOME', dataHome) - }) - - afterEach(() => { - vi.unstubAllEnvs() - rmSync(dataHome, { recursive: true, force: true }) - }) - - function sessionLogPath(): string { - const date = new Date(Number.parseInt(SESSION_ID.replace(/-/g, '').slice(0, 12), 16)) - const dir = join( - dataHome, - 'muse', - 'sessions', - String(date.getFullYear()), - String(date.getMonth() + 1).padStart(2, '0'), - String(date.getDate()).padStart(2, '0'), - SESSION_ID - ) - mkdirSync(dir, { recursive: true }) - return join(dir, 'session.jsonl') - } - - it('drops reminder-subagent hooks announced by SubagentStart, even after Stop', () => { - normalizeAndAccept(state, 'muse', { - ...main, - hook_event_name: 'UserPromptSubmit', - prompt: 'list files' - }) - const start = normalizeAndAccept(state, 'muse', { - ...child, - hook_event_name: 'SubagentStart', - subagent_id: 'skill-reminder', - child_session_id: CHILD_ID - }) - expect(start).toBeNull() - for (const hook of childReminderHooks()) { - expect(normalizeAndAccept(state, 'muse', hook)).toBeNull() - } - - const stopped = normalizeAndAccept(state, 'muse', { - ...main, - hook_event_name: 'Stop', - stop_hook_active: false, - last_assistant_message: 'Here are the files.' - }) - expect(stopped?.payload.state).toBe('done') - for (const hook of childReminderHooks()) { - expect(normalizeAndAccept(state, 'muse', hook)).toBeNull() - } - expect(state.lastStatusByPaneKey.get(PANE_KEY)?.payload).toMatchObject({ - state: 'done', - lastAssistantMessage: 'Here are the files.' - }) - }) - - // Restart/reconnect can lose the child's SubagentStart. - it('drops child-session hooks whose turn id equals their session id without SubagentStart', () => { - normalizeAndAccept(state, 'muse', { - ...main, - hook_event_name: 'Stop', - stop_hook_active: false, - last_assistant_message: 'done' - }) - for (const hook of childReminderHooks()) { - expect(normalizeAndAccept(state, 'muse', hook)).toBeNull() - } - expect(state.lastStatusByPaneKey.get(PANE_KEY)?.payload.state).toBe('done') - }) - - // Muse 1.4.14 subagent_spawn trace: the child starts before the lead's Stop and ends long after it. - const SPAWNED_ID = '01a1afae-e716-7a93-8ecf-67cb69c7e7aa' - const spawned = { ...envelope, session_id: CHILD_ID, turn_id: CHILD_ID } - const leadStop = { - ...main, - hook_event_name: 'Stop', - stop_hook_active: false, - last_assistant_message: 'SPAWNED' - } - - function holdLeadStopForSpawnedChild(): void { - normalizeAndAccept(state, 'muse', { - ...main, - hook_event_name: 'UserPromptSubmit', - prompt: 'go' - }) - normalizeAndAccept(state, 'muse', { - ...spawned, - hook_event_name: 'SubagentStart', - subagent_id: SPAWNED_ID, - child_session_id: CHILD_ID - }) - expect(normalizeAndAccept(state, 'muse', leadStop)?.payload).toMatchObject({ - state: 'working', - mainAgent: { state: 'done' } - }) - } - - it('holds the finished turn working until the spawned subagent stops', () => { - holdLeadStopForSpawnedChild() - expect( - normalizeAndAccept(state, 'muse', { - ...spawned, - hook_event_name: 'SubagentStop', - subagent_id: SPAWNED_ID, - child_session_id: CHILD_ID - }) - ).toBeNull() - // The transcript poll replays the held Stop. - expect(normalizeAndAccept(state, 'muse', leadStop)?.payload.state).toBe('done') - }) - - it('releases a held turn when the user interrupts Muse', () => { - holdLeadStopForSpawnedChild() - expect(normalizeAndAccept(state, 'muse', { ...main, hook_event_name: 'Interrupt' })).toBeNull() - expect(normalizeAndAccept(state, 'muse', leadStop)?.payload.state).toBe('done') - }) - - it('reports a request_user_input question from the session log until it settles', () => { - const logPath = sessionLogPath() - const body = { ...main, hook_event_name: 'UserPromptSubmit', prompt: 'ask my favorite color' } - const working = normalizeAndAccept(state, 'muse', body) - expect(working?.payload.state).toBe('working') - - writeFileSync( - logPath, - sessionLogLine({ - kind: 'user_input_prompt_requested', - prompt_id: PROMPT_ID, - tool_name: 'request_user_input', - questions: [QUESTION] - }) - ) - const waiting = normalizeAndAccept(state, 'muse', body) - expect(waiting?.payload).toMatchObject({ - agentType: 'muse', - state: 'waiting', - toolName: 'request_user_input', - prompt: 'ask my favorite color', - interactivePrompt: JSON.stringify({ questions: [QUESTION] }) - }) - - appendFileSync( - logPath, - sessionLogLine({ - kind: 'user_input_prompt_settled', - prompt_id: PROMPT_ID, - outcome: 'answered', - answers: [{ id: 'fav_color', selected_label: 'Blue' }] - }) - ) - const answered = normalizeAndAccept(state, 'muse', body) - expect(answered?.payload.state).toBe('working') - expect(answered?.payload.interactivePrompt).toBeUndefined() - }) -}) diff --git a/src/shared/agent-hook-listener/providers/muse/muse-events.test.ts b/src/shared/agent-hook-listener/providers/muse/muse-events.test.ts new file mode 100644 index 00000000000..b204ef7b561 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/muse/muse-events.test.ts @@ -0,0 +1,85 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createHookListenerState, type HookListenerState } from '../../listener-state' +import { normalizeAndAccept } from '../../normalization/agent-hook-listener-test-harness' + +const SESSION_ID = '01a0caa3-0e77-7d41-bad7-46283a45633d' +const TURN_ID = 'e495d1a5-59aa-47b4-8efb-a1bd75509afc' +const CHILD_ID = '1471f5e6-bd60-41a2-bfcf-c9086dbd4092' + +const envelope = { + cwd: '/tmp/ws', + transcript_path: null, + model: 'muse-spark-1.3', + permission_mode: 'default', + model_provider: 'meta' +} +const main = { ...envelope, session_id: SESSION_ID, turn_id: TURN_ID } + +describe('Muse hook events', () => { + let state: HookListenerState + let dataHome: string + + beforeEach(() => { + state = createHookListenerState() + dataHome = mkdtempSync(join(tmpdir(), 'muse-events-')) + vi.stubEnv('XDG_DATA_HOME', dataHome) + }) + + afterEach(() => { + vi.unstubAllEnvs() + rmSync(dataHome, { recursive: true, force: true }) + }) + + // Restart/reconnect can lose the child's SubagentStart. + + // Muse 1.4.14 subagent_spawn trace: the child starts before the lead's Stop and ends long after it. + const SPAWNED_ID = '01a1afae-e716-7a93-8ecf-67cb69c7e7aa' + const spawned = { ...envelope, session_id: CHILD_ID, turn_id: CHILD_ID } + const leadStop = { + ...main, + hook_event_name: 'Stop', + stop_hook_active: false, + last_assistant_message: 'SPAWNED' + } + + function holdLeadStopForSpawnedChild(): void { + normalizeAndAccept(state, 'muse', { + ...main, + hook_event_name: 'UserPromptSubmit', + prompt: 'go' + }) + normalizeAndAccept(state, 'muse', { + ...spawned, + hook_event_name: 'SubagentStart', + subagent_id: SPAWNED_ID, + child_session_id: CHILD_ID + }) + expect(normalizeAndAccept(state, 'muse', leadStop)?.payload).toMatchObject({ + state: 'working', + mainAgent: { state: 'done' } + }) + } + + it('holds the finished turn working until the spawned subagent stops', () => { + holdLeadStopForSpawnedChild() + expect( + normalizeAndAccept(state, 'muse', { + ...spawned, + hook_event_name: 'SubagentStop', + subagent_id: SPAWNED_ID, + child_session_id: CHILD_ID + }) + ).toBeNull() + // The transcript poll replays the held Stop. + expect(normalizeAndAccept(state, 'muse', leadStop)?.payload.state).toBe('done') + }) + + it('releases a held turn when the user interrupts Muse', () => { + holdLeadStopForSpawnedChild() + expect(normalizeAndAccept(state, 'muse', { ...main, hook_event_name: 'Interrupt' })).toBeNull() + expect(normalizeAndAccept(state, 'muse', leadStop)?.payload.state).toBe('done') + }) +}) diff --git a/src/shared/agent-hook-listener/providers/muse-events.ts b/src/shared/agent-hook-listener/providers/muse/muse-events.ts similarity index 92% rename from src/shared/agent-hook-listener/providers/muse-events.ts rename to src/shared/agent-hook-listener/providers/muse/muse-events.ts index 58f1741d728..a63dcbb000c 100644 --- a/src/shared/agent-hook-listener/providers/muse-events.ts +++ b/src/shared/agent-hook-listener/providers/muse/muse-events.ts @@ -1,25 +1,25 @@ -import { isAskUserQuestionTool } from '../../agent-status/lifecycle/agent-question-answered-intent' +import { isAskUserQuestionTool } from '../../../agent-status/lifecycle/agent-question-answered-intent' import { normalizeAgentStatusPayload, type ParsedAgentStatusPayload -} from '../../agent-status/contracts/agent-status-types' +} from '../../../agent-status/contracts/agent-status-types' import { agentChildWorkLiveness, type AgentChildWorkLiveness -} from '../../agent-status/child-work/agent-status-child-work-liveness' +} from '../../../agent-status/child-work/agent-status-child-work-liveness' import { continueMainAgentStatus, foldAgentLeadStatus -} from '../../agent-status/lifecycle/agent-lead-status-fold' -import { createMuseSessionLogState, readMusePendingUserInput } from '../../muse-session-log' -import type { HookListenerState, MusePaneState } from '../listener-state' +} from '../../../agent-status/lifecycle/agent-lead-status-fold' +import { createMuseSessionLogState, readMusePendingUserInput } from '../../../muse-session-log' +import type { HookListenerState, MusePaneState } from '../../listener-state' import { resolvePrompt, resolveToolState, shouldIgnoreCompactContinuationUserPromptSubmit -} from '../prompt-fields' -import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' -import { readString } from '../tool-input-preview' +} from '../../prompt-fields' +import { extractToolFields, isNewTurnEvent } from '../../provider-event-routing' +import { readString } from '../../tool-input-preview' // Reminders use role names (skill-reminder); spawned children use UUIDs. const BACKGROUND_SUBAGENT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i diff --git a/src/shared/agent-hook-listener/providers/opencode-family-events.ts b/src/shared/agent-hook-listener/providers/opencode-family/opencode-family-events.ts similarity index 85% rename from src/shared/agent-hook-listener/providers/opencode-family-events.ts rename to src/shared/agent-hook-listener/providers/opencode-family/opencode-family-events.ts index f5b4df3fef5..8f98e2e6ea3 100644 --- a/src/shared/agent-hook-listener/providers/opencode-family-events.ts +++ b/src/shared/agent-hook-listener/providers/opencode-family/opencode-family-events.ts @@ -1,12 +1,12 @@ -import { continueMainAgentStatus } from '../../agent-status/lifecycle/agent-lead-status-fold' +import { continueMainAgentStatus } from '../../../agent-status/lifecycle/agent-lead-status-fold' import { normalizeAgentStatusPayload, type ParsedAgentStatusPayload -} from '../../agent-status/contracts/agent-status-types' -import type { AgentMainAgentStatus } from '../../agent-status/lifecycle/main-agent-status' -import type { HookListenerState } from '../listener-state' -import { resolvePrompt, resolveToolState } from '../prompt-fields' -import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' +} from '../../../agent-status/contracts/agent-status-types' +import type { AgentMainAgentStatus } from '../../../agent-status/lifecycle/main-agent-status' +import type { HookListenerState } from '../../listener-state' +import { resolvePrompt, resolveToolState } from '../../prompt-fields' +import { extractToolFields, isNewTurnEvent } from '../../provider-event-routing' export function normalizeOpenCodeFamilyEvent( source: 'opencode' | 'opencode2' | 'mimo-code', diff --git a/src/shared/agent-hook-listener/providers/opencode-family-tool-fields.ts b/src/shared/agent-hook-listener/providers/opencode-family/opencode-family-tool-fields.ts similarity index 92% rename from src/shared/agent-hook-listener/providers/opencode-family-tool-fields.ts rename to src/shared/agent-hook-listener/providers/opencode-family/opencode-family-tool-fields.ts index 1afec20f4ff..a7fec2f2b47 100644 --- a/src/shared/agent-hook-listener/providers/opencode-family-tool-fields.ts +++ b/src/shared/agent-hook-listener/providers/opencode-family/opencode-family-tool-fields.ts @@ -1,11 +1,11 @@ -import { capOpenCodeHookText } from '../listener-limits' -import type { ToolSnapshot } from '../listener-event' -import { hasAnyOwnField, hasOwnField, readString, toolUpdate } from '../tool-input-preview' +import { capOpenCodeHookText } from '../../listener-limits' +import type { ToolSnapshot } from '../../listener-event' +import { hasAnyOwnField, hasOwnField, readString, toolUpdate } from '../../tool-input-preview' import { deriveInteractivePrompt, readFirstString, stripHookEnvelopeKeys -} from '../interactive-tool' +} from '../../interactive-tool' /** Resumed sessions emit no SessionStart; explicitly retire permission fields or answered cards persist on later frames. */ const OPENCODE_TOOL_FIELDS_RETIRED: ToolSnapshot = { diff --git a/src/shared/agent-hook-listener/providers/pi-family-events.ts b/src/shared/agent-hook-listener/providers/pi-family/pi-family-events.ts similarity index 93% rename from src/shared/agent-hook-listener/providers/pi-family-events.ts rename to src/shared/agent-hook-listener/providers/pi-family/pi-family-events.ts index 850eb031221..faa8d3567c9 100644 --- a/src/shared/agent-hook-listener/providers/pi-family-events.ts +++ b/src/shared/agent-hook-listener/providers/pi-family/pi-family-events.ts @@ -1,13 +1,13 @@ import { normalizeAgentStatusPayload, type ParsedAgentStatusPayload -} from '../../agent-status/contracts/agent-status-types' -import { isAskUserQuestionTool } from '../../agent-status/lifecycle/agent-question-answered-intent' -import { clearPaneTurnCacheState, type HookListenerState } from '../listener-state' -import { resolvePrompt, resolveToolState } from '../prompt-fields' -import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' -import { readString } from '../tool-input-preview' -import { parseOmpPlanReviewPrompt } from '../../omp-plan-review-prompt' +} from '../../../agent-status/contracts/agent-status-types' +import { isAskUserQuestionTool } from '../../../agent-status/lifecycle/agent-question-answered-intent' +import { clearPaneTurnCacheState, type HookListenerState } from '../../listener-state' +import { resolvePrompt, resolveToolState } from '../../prompt-fields' +import { extractToolFields, isNewTurnEvent } from '../../provider-event-routing' +import { readString } from '../../tool-input-preview' +import { parseOmpPlanReviewPrompt } from '../../../omp-plan-review-prompt' /** Normalize Pi/OMP/Prime lifecycle, question waits and live roster/model evidence; ignore events with no status. */ export function normalizePiCompatibleEvent( diff --git a/src/shared/agent-hook-listener/providers/pi-family-tool-fields.ts b/src/shared/agent-hook-listener/providers/pi-family/pi-family-tool-fields.ts similarity index 90% rename from src/shared/agent-hook-listener/providers/pi-family-tool-fields.ts rename to src/shared/agent-hook-listener/providers/pi-family/pi-family-tool-fields.ts index 38821a3f8f3..649c37fcaa5 100644 --- a/src/shared/agent-hook-listener/providers/pi-family-tool-fields.ts +++ b/src/shared/agent-hook-listener/providers/pi-family/pi-family-tool-fields.ts @@ -1,8 +1,13 @@ -import type { ToolSnapshot } from '../listener-event' -import { isAskUserQuestionTool } from '../../agent-status/lifecycle/agent-question-answered-intent' -import { deriveToolInputPreview, hasOwnField, readString, toolUpdate } from '../tool-input-preview' -import { clearActiveToolFieldsUpdate, deriveInteractivePrompt } from '../interactive-tool' -import { serializeOmpPlanReviewPrompt } from '../../omp-plan-review-prompt' +import type { ToolSnapshot } from '../../listener-event' +import { isAskUserQuestionTool } from '../../../agent-status/lifecycle/agent-question-answered-intent' +import { + deriveToolInputPreview, + hasOwnField, + readString, + toolUpdate +} from '../../tool-input-preview' +import { clearActiveToolFieldsUpdate, deriveInteractivePrompt } from '../../interactive-tool' +import { serializeOmpPlanReviewPrompt } from '../../../omp-plan-review-prompt' /** OMP's `ask` carries the same questions/options payload as Pi's question tool. */ function serializeQuestionPrompt(toolInput: unknown): string | undefined { diff --git a/src/shared/agent-hook-listener/providers/qoder-events.ts b/src/shared/agent-hook-listener/providers/qoder-events.ts deleted file mode 100644 index c83923d58f7..00000000000 --- a/src/shared/agent-hook-listener/providers/qoder-events.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { normalizeCompatibleLifecycleEvent } from './compatible-lifecycle-events' -import type { HookListenerState } from '../listener-state' - -export function normalizeQoderEvent( - state: HookListenerState, - eventName: unknown, - promptText: string, - paneKey: string, - hookPayload: Record -) { - return normalizeCompatibleLifecycleEvent( - 'qoder', - state, - eventName, - promptText, - paneKey, - hookPayload - ) -} diff --git a/src/shared/agent-hook-listener/providers/zcode-events.test.ts b/src/shared/agent-hook-listener/providers/zcode/zcode-events.test.ts similarity index 94% rename from src/shared/agent-hook-listener/providers/zcode-events.test.ts rename to src/shared/agent-hook-listener/providers/zcode/zcode-events.test.ts index 43feebb3bc7..cc04c44b1fa 100644 --- a/src/shared/agent-hook-listener/providers/zcode-events.test.ts +++ b/src/shared/agent-hook-listener/providers/zcode/zcode-events.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it } from 'vitest' -import { createHookListenerState, type HookListenerState } from '../listener-state' -import { normalizeAndAccept } from '../normalization/agent-hook-listener-test-harness' +import { createHookListenerState, type HookListenerState } from '../../listener-state' +import { normalizeAndAccept } from '../../normalization/agent-hook-listener-test-harness' const SESSION_ID = 'sess_01a0caa30e777d41bad746283a45633d' const TURN_ID = 'turn_e495d1a559aa478efba1bd75509afc' diff --git a/src/shared/agent-hook-listener/providers/zcode-events.ts b/src/shared/agent-hook-listener/providers/zcode/zcode-events.ts similarity index 87% rename from src/shared/agent-hook-listener/providers/zcode-events.ts rename to src/shared/agent-hook-listener/providers/zcode/zcode-events.ts index 72bdf3239dc..bc52b65909b 100644 --- a/src/shared/agent-hook-listener/providers/zcode-events.ts +++ b/src/shared/agent-hook-listener/providers/zcode/zcode-events.ts @@ -1,16 +1,16 @@ -import { isAskUserQuestionTool } from '../../agent-status/lifecycle/agent-question-answered-intent' +import { isAskUserQuestionTool } from '../../../agent-status/lifecycle/agent-question-answered-intent' import { normalizeAgentStatusPayload, type ParsedAgentStatusPayload -} from '../../agent-status/contracts/agent-status-types' -import type { HookListenerState } from '../listener-state' +} from '../../../agent-status/contracts/agent-status-types' +import type { HookListenerState } from '../../listener-state' import { resolvePrompt, resolveToolState, shouldIgnoreCompactContinuationUserPromptSubmit -} from '../prompt-fields' -import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' -import { readString } from '../tool-input-preview' +} from '../../prompt-fields' +import { extractToolFields, isNewTurnEvent } from '../../provider-event-routing' +import { readString } from '../../tool-input-preview' // ZCode configured-runner-input emits Claude-compatible aliases beside native camelCase; reuse Claude tool fields with ZCode identity. const ZCODE_IDLE_SESSION_START_SOURCES: ReadonlySet = new Set([ diff --git a/src/shared/agent-hook-listener/transcript-poll-policy.ts b/src/shared/agent-hook-listener/transcript-poll-policy.ts index c18f2aad283..7cec0e32f7a 100644 --- a/src/shared/agent-hook-listener/transcript-poll-policy.ts +++ b/src/shared/agent-hook-listener/transcript-poll-policy.ts @@ -1,8 +1,8 @@ import type { AgentHookSource } from './transport/agent-hook-relay' import type { AgentHookEventPayload } from './listener-event' import type { HookListenerState } from './listener-state' -import { hasCodexTranscriptSubagents } from './providers/codex-state' -import { hasMuseSessionLog } from './providers/muse-events' +import { hasCodexTranscriptSubagents } from './providers/codex/codex-state' +import { hasMuseSessionLog } from './providers/muse/muse-events' import { hasPendingCodexSubagentIdentity } from '../codex-subagent-roster' import { parseHookEnvelope } from './hook-envelope' diff --git a/src/wsl-guest/agent-hooks/results/agent-hook-result-retry-scheduler.ts b/src/wsl-guest/agent-hooks/results/agent-hook-result-retry-scheduler.ts index fd52f7bdf72..0f1b5743d2b 100644 --- a/src/wsl-guest/agent-hooks/results/agent-hook-result-retry-scheduler.ts +++ b/src/wsl-guest/agent-hooks/results/agent-hook-result-retry-scheduler.ts @@ -1,5 +1,5 @@ -import { ClaudeHelperKillPoller } from '../../../shared/agent-hook-listener/providers/claude-helper-kill-poller' -import { pollCodexTranscriptStatus } from '../../../shared/agent-hook-listener/providers/codex-transcript-poll' +import { ClaudeHelperKillPoller } from '../../../shared/agent-hook-listener/providers/claude/transcripts/claude-helper-kill-poller' +import { pollCodexTranscriptStatus } from '../../../shared/agent-hook-listener/providers/codex/codex-transcript-poll' // Retry late transcript results against the same cached event; one owner cancels both timer families before cache teardown. import { hasPendingAgentResultText, @@ -19,7 +19,7 @@ import { transcriptPollUpdate } from '../../../shared/agent-hook-listener/transcript-poll-policy' import { AgentTranscriptPollScheduler } from '../../../shared/agent-hook-listener/transcripts/agent-transcript-poll-scheduler' -import { ClaudeOwedNotificationExpiryTimers } from '../../../shared/agent-hook-listener/providers/claude-owed-notification-expiry-timers' +import { ClaudeOwedNotificationExpiryTimers } from '../../../shared/agent-hook-listener/providers/claude/background-tasks/claude-owed-notification-expiry-timers' const ASSISTANT_MESSAGE_RETRY_ATTEMPTS = 5 const ASSISTANT_MESSAGE_RETRY_MS = 50 diff --git a/src/wsl-guest/agent-hooks/results/agent-hook-server-claude-owed-notification-expiry.test.ts b/src/wsl-guest/agent-hooks/results/agent-hook-server-claude-owed-notification-expiry.test.ts deleted file mode 100644 index da65dec02e0..00000000000 --- a/src/wsl-guest/agent-hooks/results/agent-hook-server-claude-owed-notification-expiry.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -// The relay owns remote Claude records and must restate status when an owed task notification never arrives. -import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentHookRelayEnvelope } from '../../../shared/agent-hook-listener/transport/agent-hook-relay' -import { CLAUDE_OWED_TASK_NOTIFICATION_LEASE_MS } from '../../../shared/agent-hook-listener/providers/claude-owed-task-notifications' -import { makePaneKey } from '../../../shared/workspace-layout/stable-pane-id' -import { RelayAgentHookServer } from '../server/agent-hook-server' - -const PANE_KEY = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') -const SHELL = { id: 'b1', type: 'shell', status: 'running' } - -describe('Claude owed task notification expiry on the relay', () => { - let dir: string - beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'relay-hook-expiry-')) - // Why shouldAdvanceTime: the hooks are real loopback POSTs, which need the clock to move. - vi.useFakeTimers({ - shouldAdvanceTime: true, - toFake: ['setTimeout', 'clearTimeout', 'Date', 'performance'] - }) - }) - afterEach(() => { - vi.useRealTimers() - rmSync(dir, { recursive: true, force: true }) - }) - - it.each(['Stop', 'Escape'])( - 'forwards the settled row after %s when a launched shell vanished without a notification', - async (ending) => { - const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() - const server = new RelayAgentHookServer({ endpointDir: dir, forward }) - await server.start() - const { port, token } = server.getCoordinates() - const post = (payload: Record) => - fetch(`http://127.0.0.1:${port}/hook/claude`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-Orca-Agent-Hook-Token': token }, - body: JSON.stringify({ - paneKey: PANE_KEY, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { session_id: 'session-1', ...payload } - }) - }) - try { - await post({ hook_event_name: 'UserPromptSubmit', prompt: 'start the dev server' }) - await post({ - hook_event_name: 'PostToolUse', - tool_name: 'Bash', - tool_response: { backgroundTaskId: SHELL.id } - }) - await post({ hook_event_name: 'Stop', background_tasks: [SHELL] }) - if (ending === 'Escape') { - await post({ hook_event_name: 'Stop', background_tasks: [] }) - } - await post({ hook_event_name: 'UserPromptSubmit', prompt: 'thanks' }) - if (ending === 'Stop') { - await post({ hook_event_name: 'Stop', background_tasks: [] }) - } else { - server.claudeTerminalInterrupts.observe(PANE_KEY, { kind: 'title', title: '◐ Thanks' }) - server.claudeTerminalInterrupts.observe(PANE_KEY, { kind: 'input', data: '\x1b[27u' }) - server.claudeTerminalInterrupts.observe(PANE_KEY, { kind: 'title', title: '✳ Thanks' }) - expect(forward.mock.lastCall?.[0].payload).toMatchObject({ - mainAgent: { state: 'done', outcome: 'cancellation' }, - claudeTaskWakeupPending: 'notification' - }) - } - expect(forward.mock.lastCall?.[0].payload.state).toBe('working') - - vi.advanceTimersByTime(CLAUDE_OWED_TASK_NOTIFICATION_LEASE_MS) - - expect(forward.mock.lastCall?.[0]).toMatchObject({ - paneKey: PANE_KEY, - claudeRunningNonAgentTask: false, - payload: { state: 'done' } - }) - } finally { - server.stop() - } - } - ) -}) diff --git a/src/wsl-guest/agent-hooks/status/agent-hook-event-admission.ts b/src/wsl-guest/agent-hooks/status/agent-hook-event-admission.ts index 984efd78cbd..8db4f3f9b2a 100644 --- a/src/wsl-guest/agent-hooks/status/agent-hook-event-admission.ts +++ b/src/wsl-guest/agent-hooks/status/agent-hook-event-admission.ts @@ -16,7 +16,7 @@ import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/ import type { AgentHookSource } from '../../../shared/agent-hook-listener/transport/agent-hook-relay' import { retainClaudeSettledRequests } from '../../../shared/agent-hook-listener/claude-settled-permission-requests' import { reduceHostTurnEnd } from '../../../shared/agent-hook-listener/state/agent-hook-turn-end-timing' -import { previousCodexTurnContext } from '../../../shared/agent-hook-listener/providers/codex-session-start' +import { previousCodexTurnContext } from '../../../shared/agent-hook-listener/providers/codex/codex-session-start' import { buildRelayHookEnvelope } from '../publication/agent-hook-envelope-build' import type { RelayHookForward } from '../server/agent-hook-server-contract' import { MAX_CACHED_PANES, type CachedPaneEnvelopeMeta } from './agent-hook-cached-pane-status' diff --git a/src/wsl-guest/agent-hooks/status/agent-hook-interrupt-reconciliation.ts b/src/wsl-guest/agent-hooks/status/agent-hook-interrupt-reconciliation.ts index 94a1bdb7b89..fe6201aa322 100644 --- a/src/wsl-guest/agent-hooks/status/agent-hook-interrupt-reconciliation.ts +++ b/src/wsl-guest/agent-hooks/status/agent-hook-interrupt-reconciliation.ts @@ -12,8 +12,8 @@ import { import { markClaudeLeadTurnInterrupted, setClaudeMainAgentTurnState -} from '../../../shared/agent-hook-listener/providers/claude-roster-state' -import { claudeRowHasUnlistedLiveWork } from '../../../shared/agent-hook-listener/providers/claude-pane-hold-evidence' +} from '../../../shared/agent-hook-listener/providers/claude/lifecycle/claude-roster-state' +import { claudeRowHasUnlistedLiveWork } from '../../../shared/agent-hook-listener/providers/claude/lifecycle/claude-pane-hold-evidence' import type { HookListenerState } from '../../../shared/agent-hook-listener/listener-state' import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' import type { AgentHookSource } from '../../../shared/agent-hook-listener/transport/agent-hook-relay' From 8c9aff20a34184b998a7dde1347b5e23fb63eadd Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 13 Nov 2026 03:43:34 -0800 Subject: [PATCH 15/55] test: remove filesystem scale and telemetry schema replays (#41988) Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- config/flaky-test-pattern-baseline.txt | 1 - .../scripts/ci/sharding/ci-shard-timings.json | 1 - ...telemetry-events-feature-education.spec.ts | 38 ------ .../telemetry-feature-wall-events.spec.ts | 27 ---- .../telemetry-orca-cli-feature-tip.spec.ts | 20 --- .../telemetry-payload-contracts.test.ts | 3 - .../workspace-space-entry-traversal.test.ts | 120 ------------------ .../workspace-space-scan-budget.test.ts | 33 ----- 8 files changed, 243 deletions(-) delete mode 100644 src/shared/telemetry-events-feature-education.spec.ts delete mode 100644 src/shared/telemetry-feature-wall-events.spec.ts delete mode 100644 src/shared/telemetry-orca-cli-feature-tip.spec.ts delete mode 100644 src/shared/telemetry-payload-contracts.test.ts diff --git a/config/flaky-test-pattern-baseline.txt b/config/flaky-test-pattern-baseline.txt index a965c78d283..c4db40f9a5b 100644 --- a/config/flaky-test-pattern-baseline.txt +++ b/config/flaky-test-pattern-baseline.txt @@ -630,7 +630,6 @@ 1 test-timeout-needs-reason:src/renderer/src/lib/source-control/checks/check-run-details-log-normalization-performance.test.ts 1 test-timeout-needs-reason:src/shared/__tests__/process-host/windows-tree-kill-late-taskkill.win32.test.ts 1 test-timeout-needs-reason:src/shared/dsh-real-cli.test.ts -1 test-timeout-needs-reason:src/shared/workspace-disk-space/workspace-space-entry-traversal.test.ts 1 test-timeout-needs-reason:src/shared/wsl-login-shell-command.test.ts 1 test-timeout-needs-reason:tests/e2e/active-view-restart-restore.spec.ts 1 test-timeout-needs-reason:tests/e2e/agent-send-picker-reveal.spec.ts diff --git a/config/scripts/ci/sharding/ci-shard-timings.json b/config/scripts/ci/sharding/ci-shard-timings.json index ca81603760a..eef8f4ab3db 100644 --- a/config/scripts/ci/sharding/ci-shard-timings.json +++ b/config/scripts/ci/sharding/ci-shard-timings.json @@ -6688,7 +6688,6 @@ "src/shared/tab-agent-from-signals-pi-identity.test.ts": 103, "src/shared/task-query.test.ts": 55, "src/shared/task-source-context.test.ts": 103, - "src/shared/telemetry-payload-contracts.test.ts": 282, "src/shared/program-status/terminal-agent-status-processor.test.ts": 74, "src/shared/terminal/output/terminal-bell-detector.test.ts": 40, "src/shared/terminal/protocol/queries/terminal-color-scheme-protocol.test.ts": 39, diff --git a/src/shared/telemetry-events-feature-education.spec.ts b/src/shared/telemetry-events-feature-education.spec.ts deleted file mode 100644 index c8def20c9d5..00000000000 --- a/src/shared/telemetry-events-feature-education.spec.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { eventSchemas } from './telemetry-events' - -describe('feature education telemetry event schemas', () => { - it('accepts contextual tour outcome payloads with bounded step counts', () => { - const parsed = eventSchemas.contextual_tour_outcome.safeParse({ - tour_id: 'tasks', - source: 'tasks_open', - outcome: 'completed', - steps_seen: 2, - total_steps: 3 - }) - - expect(parsed.success).toBe(true) - }) - - it('rejects contextual tour outcome payloads with impossible progress', () => { - const parsed = eventSchemas.contextual_tour_outcome.safeParse({ - tour_id: 'tasks', - source: 'tasks_open', - outcome: 'completed', - steps_seen: 4, - total_steps: 3 - }) - - expect(parsed.success).toBe(false) - }) - - it('rejects raw contextual tour sources', () => { - const parsed = eventSchemas.contextual_tour_shown.safeParse({ - tour_id: 'browser', - source: 'http://localhost:3000/private', - was_feature_previously_interacted: false - }) - - expect(parsed.success).toBe(false) - }) -}) diff --git a/src/shared/telemetry-feature-wall-events.spec.ts b/src/shared/telemetry-feature-wall-events.spec.ts deleted file mode 100644 index 9935070b4af..00000000000 --- a/src/shared/telemetry-feature-wall-events.spec.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { eventSchemas } from './telemetry-events' - -describe('feature wall schemas', () => { - it('rejects invalid close depth fields', () => { - expect( - eventSchemas.feature_wall_closed.safeParse({ - dwell_ms: 1200, - source: 'help_menu', - exit_action: 'left', - furthest_step: 'review_raw_url' - }).success - ).toBe(false) - expect( - eventSchemas.feature_wall_closed.safeParse({ - dwell_ms: 1200, - visited_workflow_count: 6 - }).success - ).toBe(false) - expect( - eventSchemas.feature_wall_closed.safeParse({ - dwell_ms: 1200, - repo_path: '/Users/alice/project' - }).success - ).toBe(false) - }) -}) diff --git a/src/shared/telemetry-orca-cli-feature-tip.spec.ts b/src/shared/telemetry-orca-cli-feature-tip.spec.ts deleted file mode 100644 index 51cb5bed010..00000000000 --- a/src/shared/telemetry-orca-cli-feature-tip.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { eventSchemas } from './telemetry-events' - -describe('orca cli feature tip schemas', () => { - it('rejects raw CLI details and unknown result values', () => { - expect( - eventSchemas.orca_cli_feature_tip_setup_result.safeParse({ - source: 'app_open', - result: 'installed', - command_path: '/Users/alice/bin/orca' - }).success - ).toBe(false) - expect( - eventSchemas.orca_cli_feature_tip_setup_result.safeParse({ - source: 'app_open', - result: 'installed_after_retry' - }).success - ).toBe(false) - }) -}) diff --git a/src/shared/telemetry-payload-contracts.test.ts b/src/shared/telemetry-payload-contracts.test.ts deleted file mode 100644 index c83bd727095..00000000000 --- a/src/shared/telemetry-payload-contracts.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import './telemetry-orca-cli-feature-tip.spec' -import './telemetry-events-feature-education.spec' -import './telemetry-feature-wall-events.spec' diff --git a/src/shared/workspace-disk-space/workspace-space-entry-traversal.test.ts b/src/shared/workspace-disk-space/workspace-space-entry-traversal.test.ts index 2e81ae91584..f2eb7b823f8 100644 --- a/src/shared/workspace-disk-space/workspace-space-entry-traversal.test.ts +++ b/src/shared/workspace-disk-space/workspace-space-entry-traversal.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from 'vitest' import { scanWorkspaceSpaceEntryTree } from './workspace-space-entry-traversal' -import { WorkspaceSpaceScanCapacityError } from './workspace-space-scan-budget' type Entry = { name: string } @@ -85,29 +84,6 @@ describe('scanWorkspaceSpaceEntryTree', () => { expect(result.sizeBytes).toBe(3) }) - it('aggregates a deep chain exactly at the entry cap without recursive unwinding', async () => { - const depth = 256 - const directories = new Map() - let path = '/root' - for (let index = 0; index < depth; index += 1) { - const name = `directory-${index}` - directories.set(path, [{ name }]) - path = `${path}/${name}` - } - directories.set(path, []) - - const result = await makeTraversal( - directories, - async () => ({ kind: 'directory', sizeBytes: 1 }), - { maxEntries: depth } - ) - - expect(result.sizeBytes).toBe(depth + 1) - expect(result.children).toEqual([ - expect.objectContaining({ name: 'directory-0', sizeBytes: depth }) - ]) - }) - it('scans a chain far longer than the cap because listings are released', async () => { const depth = 50 const directories = new Map() @@ -127,100 +103,4 @@ describe('scanWorkspaceSpaceEntryTree', () => { expect(result.sizeBytes).toBe(depth + 1) }) - - // Per-worker entry caps make capacity depend on concurrency (docs/workspace-space-scan-resource-bounds.md). - describe('capacity depends on directory shape, not tree size or concurrency', () => { - function buildWideTree(dirCount: number, filesPerDir: number) { - const directories = new Map() - directories.set( - '/root', - Array.from({ length: dirCount }, (_, index) => ({ name: `dir-${index}` })) - ) - for (let index = 0; index < dirCount; index += 1) { - directories.set( - `/root/dir-${index}`, - Array.from({ length: filesPerDir }, (_, file) => ({ name: `file-${file}` })) - ) - } - return directories - } - - function scanWideTree(dirCount: number, filesPerDir: number, concurrency: number) { - const directories = buildWideTree(dirCount, filesPerDir) - return makeTraversal( - directories, - async (path) => ({ - kind: directories.has(path) ? 'directory' : 'file', - sizeBytes: 1 - }), - { maxEntries: 100_000, maxRetainedBytes: Number.MAX_SAFE_INTEGER }, - concurrency - ) - } - - it('still rejects a single directory above the cap', async () => { - await expect(scanWideTree(1, 100_001, 48)).rejects.toBeInstanceOf( - WorkspaceSpaceScanCapacityError - ) - }, 30_000) - }) - - // Unlike the tests above, these exercise the production byte cap across checkout depths. - describe('capacity at the production default limits', () => { - // The regression appeared for checkout paths longer than about 58 characters. - const DEEP_ROOT = '/Users/octocat/projects/orca/.claude/worktrees/wf_d39acf3c-e7d-2' - - function scanAtDefaults( - rootPath: string, - dirCount: number, - filesPerDir: number, - concurrency: number - ) { - const directories = new Map() - directories.set( - rootPath, - Array.from({ length: dirCount }, (_, index) => ({ name: `dir-${index}` })) - ) - for (let index = 0; index < dirCount; index += 1) { - directories.set( - `${rootPath}/dir-${index}`, - Array.from({ length: filesPerDir }, (_, file) => ({ name: `file-${file}.ts` })) - ) - } - return scanWorkspaceSpaceEntryTree({ - rootPath, - rootName: 'root', - concurrency, - entryName: (entry: Entry) => entry.name, - joinPath: (parent, child) => `${parent}/${child}`, - classifyEntry: async (path) => ({ - kind: directories.has(path) ? ('directory' as const) : ('file' as const), - sizeBytes: 1 - }), - readDirectory: async (path) => { - const entries = directories.get(path) - if (!entries) { - throw new Error(`unreadable ${path}`) - } - return entries - }, - checkCancelled: () => undefined, - createCancellationError: () => new Error('cancelled'), - isCancellationError: (error) => error instanceof Error && error.message === 'cancelled' - }) - } - - it.each([ - { dirCount: 48, filesPerDir: 2_100, concurrency: 48 }, - { dirCount: 10, filesPerDir: 10_001, concurrency: 10 }, - { dirCount: 40, filesPerDir: 2_500, concurrency: 48 } - ])( - 'scans $dirCount x $filesPerDir at concurrency $concurrency under a deep root', - async ({ dirCount, filesPerDir, concurrency }) => { - const result = await scanAtDefaults(DEEP_ROOT, dirCount, filesPerDir, concurrency) - expect(result.sizeBytes).toBe(dirCount * filesPerDir + dirCount + 1) - }, - 30_000 - ) - }) }) diff --git a/src/shared/workspace-disk-space/workspace-space-scan-budget.test.ts b/src/shared/workspace-disk-space/workspace-space-scan-budget.test.ts index 8cb82b8d327..5651c3366bd 100644 --- a/src/shared/workspace-disk-space/workspace-space-scan-budget.test.ts +++ b/src/shared/workspace-disk-space/workspace-space-scan-budget.test.ts @@ -2,9 +2,6 @@ import { describe, expect, it } from 'vitest' import { collectWorkspaceSpaceDirectoryEntries, createWorkspaceSpaceScanBudget, - estimateWorkspaceSpaceEntryRetainedBytes, - estimateWorkspaceSpaceListingRetainedBytes, - releaseWorkspaceSpaceScanEntries, WorkspaceSpaceScanCapacityError } from './workspace-space-scan-budget' @@ -28,36 +25,6 @@ describe('workspace space scan budget', () => { expect(budget).toMatchObject({ retainedBytes: 0 }) }) - it('frees capacity for later directories once a listing is released', async () => { - const parentPath = '/workspace' - const entries = [{ name: 'first' }, { name: 'second' }] - const exactBytes = entries.reduce( - (total, entry) => total + estimateWorkspaceSpaceEntryRetainedBytes(entry.name), - estimateWorkspaceSpaceListingRetainedBytes(parentPath) - ) - const budget = createWorkspaceSpaceScanBudget({ maxRetainedBytes: exactBytes }) - - const first = await collectWorkspaceSpaceDirectoryEntries( - entries, - parentPath, - (entry) => entry.name, - budget, - () => undefined - ) - // Why: a cumulative counter would reject the identical second listing here. - releaseWorkspaceSpaceScanEntries(budget, first.retainedBytes) - - await expect( - collectWorkspaceSpaceDirectoryEntries( - entries, - parentPath, - (entry) => entry.name, - budget, - () => undefined - ) - ).resolves.toMatchObject({ retainedBytes: exactBytes }) - }) - it('closes an async directory iterator when the next entry exceeds the budget', async () => { let closed = false async function* directory() { From 59b88a117662e0c5b9812906f9a73f641a4c7e79 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 13 Nov 2026 03:52:49 -0800 Subject: [PATCH 16/55] refactor: group GitHub task-page modules by domain (#41995) --- config/react-compiler-bailout-baseline.txt | 26 +- .../scripts/ci/sharding/ci-shard-timings.json | 3 - .../check-changed-code-quality.mjs | 2 +- package.json | 2 +- .../gh-edit-close-as-duplicate.ts | 2 +- .../gh-edit-section-horizontal.tsx | 2 +- .../gh-edit-section-mutations.ts | 4 +- .../gh-edit-section-status-popover.tsx | 2 +- .../gh-edit-section-top-columns.tsx | 2 +- ...gh-edit-section.assignee-identity.test.tsx | 304 ------------------ .../edit-item-fields/gh-edit-section.tsx | 2 +- .../land-pull-request/pr-actions-panel.tsx | 2 +- .../github/work-item-state-presentation.tsx | 2 +- .../src/components/task-page/Content.tsx | 2 +- .../src/components/task-page/ListChrome.tsx | 2 +- .../components/task-page/ProviderFilters.tsx | 2 +- .../src/components/task-page/Surface.tsx | 2 +- .../src/components/task-page/TaskPage.tsx | 18 +- .../task-page-github-resume-cache.ts | 2 +- ...e-task-page-github-cache-reconciliation.ts | 8 +- ...se-task-page-work-items-cache-selection.ts | 6 +- .../{ => issue-creation}/IssueDialog.tsx | 4 +- .../{ => issue-creation}/IssueSelectors.tsx | 4 +- .../use-task-page-github-issue-creation.ts | 4 +- .../use-task-page-github-issue-draft.ts | 6 +- .../{ => issue-status}/DuplicatePicker.tsx | 2 +- .../github/{ => issue-status}/StatusCell.tsx | 20 +- .../task-page-github-status-actions.ts | 4 +- .../task-page-github-status-state.ts | 2 +- .../github/{ => listing}/AssigneesCell.tsx | 14 +- .../github/{ => listing}/Avatars.tsx | 4 +- .../github/{ => listing}/Filters.tsx | 4 +- .../task-page/github/{ => listing}/List.tsx | 6 +- .../github/{ => listing}/ModeControls.tsx | 2 +- .../task-page/github/{ => listing}/Rows.tsx | 20 +- .../task-page-github-list-scroll-restore.ts | 0 ...ask-page-github-work-item-status-badge.tsx | 2 +- .../task-page-github-work-item-status.ts | 2 +- .../{ => listing}/task-page-pr-chips.test.ts | 0 .../{ => listing}/task-page-pr-chips.ts | 8 +- .../task-page-preset-highlight.ts | 6 +- .../use-task-page-github-collection-state.ts | 12 +- .../use-task-page-github-list-pending.ts | 2 +- .../use-task-page-github-list-projection.ts | 12 +- .../use-task-page-github-list-state.ts | 8 +- .../use-task-page-github-search-pagination.ts | 12 +- ...ask-page-github-confirmed-client-values.ts | 4 +- ...task-page-github-dialog-state-authority.ts | 8 +- ...page-github-work-item-filter-membership.ts | 6 +- ...e-github-work-item-mutation-composition.ts | 10 +- ...ask-page-github-work-item-mutation-keys.ts | 0 ...age-github-work-item-mutation-lifecycle.ts | 8 +- ...sk-page-github-work-item-mutation-pages.ts | 8 +- ...-page-github-work-item-mutation-patches.ts | 6 +- ...page-github-work-item-mutation-registry.ts | 18 +- ...sk-page-github-work-item-mutation-types.ts | 8 +- .../task-page-github-work-item-mutations.ts | 20 +- ...sk-page-github-work-item-registry-types.ts | 4 +- .../use-task-page-github-mutation-state.ts | 18 +- .../github/pr-check-hydration.test.tsx | 195 ----------- .../github/{ => pull-requests}/ChecksCell.tsx | 6 +- .../github/{ => pull-requests}/MergeCell.tsx | 12 +- .../github/{ => pull-requests}/ReviewCell.tsx | 14 +- .../{ => pull-requests}/ReviewerPicker.tsx | 4 +- .../task-page-github-review-model.tsx | 10 +- .../task-page-github-reviewer-actions.ts | 18 +- .../use-task-page-pr-check-hydration.ts | 14 +- .../task-page-github-landing-refresh-model.ts | 2 +- .../task-page-github-landing-refresh-run.tsx | 22 +- .../task-page-github-quiet-refresh-run.tsx | 22 +- ...page-github-work-item-authority-refresh.ts | 6 +- .../task-page-github-work-item-quiet-adopt.ts | 21 +- ...-page-github-work-item-quiet-revalidate.ts | 10 +- .../task-page-github-work-item-quiet-state.ts | 2 +- .../use-task-page-github-landing-refresh.ts | 2 +- ...e-task-page-github-quiet-refresh-effect.ts | 0 .../use-task-page-github-quiet-refresh.ts | 4 +- ...sk-page-github-list-scroll-restore.test.ts | 112 ------- .../github/use-task-page-github-detail.ts | 6 +- .../jira/use-task-page-jira-creation-state.ts | 2 +- .../task-page-linear-jira-list-model.tsx | 2 +- .../use-task-page-linear-project-creation.ts | 2 +- .../task-page-github-pr-chips.test.tsx | 63 ---- .../task-page/task-page-localized-options.tsx | 2 +- .../task-page/use-task-page-detail-routing.ts | 2 +- .../task-page/use-task-page-search-actions.ts | 4 +- ...-task-page-source-display.test-fixture.tsx | 197 ------------ .../useTaskPageGitHubWorkItemMutation.ts | 10 +- .../sources/github-source-lifetime.test.tsx | 167 ---------- .../github/sources/source-collections.ts | 2 +- .../task-records/github-task-records.test.ts | 186 ----------- .../github/task-records/task-record-owners.ts | 4 +- 92 files changed, 274 insertions(+), 1526 deletions(-) delete mode 100644 src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section.assignee-identity.test.tsx rename src/renderer/src/components/task-page/github/{ => cache}/task-page-github-resume-cache.ts (96%) rename src/renderer/src/components/task-page/github/{ => cache}/use-task-page-github-cache-reconciliation.ts (93%) rename src/renderer/src/components/task-page/github/{ => cache}/use-task-page-work-items-cache-selection.ts (92%) rename src/renderer/src/components/task-page/github/{ => issue-creation}/IssueDialog.tsx (98%) rename src/renderer/src/components/task-page/github/{ => issue-creation}/IssueSelectors.tsx (97%) rename src/renderer/src/components/task-page/github/{ => issue-creation}/use-task-page-github-issue-creation.ts (97%) rename src/renderer/src/components/task-page/github/{ => issue-creation}/use-task-page-github-issue-draft.ts (94%) rename src/renderer/src/components/task-page/github/{ => issue-status}/DuplicatePicker.tsx (97%) rename src/renderer/src/components/task-page/github/{ => issue-status}/StatusCell.tsx (93%) rename src/renderer/src/components/task-page/github/{ => issue-status}/task-page-github-status-actions.ts (94%) rename src/renderer/src/components/task-page/github/{ => issue-status}/task-page-github-status-state.ts (91%) rename src/renderer/src/components/task-page/github/{ => listing}/AssigneesCell.tsx (93%) rename src/renderer/src/components/task-page/github/{ => listing}/Avatars.tsx (84%) rename src/renderer/src/components/task-page/github/{ => listing}/Filters.tsx (99%) rename src/renderer/src/components/task-page/github/{ => listing}/List.tsx (98%) rename src/renderer/src/components/task-page/github/{ => listing}/ModeControls.tsx (98%) rename src/renderer/src/components/task-page/github/{ => listing}/Rows.tsx (96%) rename src/renderer/src/components/task-page/github/{ => listing}/task-page-github-list-scroll-restore.ts (100%) rename src/renderer/src/components/task-page/github/{ => listing}/task-page-github-work-item-status-badge.tsx (89%) rename src/renderer/src/components/task-page/github/{ => listing}/task-page-github-work-item-status.ts (96%) rename src/renderer/src/components/task-page/github/{ => listing}/task-page-pr-chips.test.ts (100%) rename src/renderer/src/components/task-page/github/{ => listing}/task-page-pr-chips.ts (91%) rename src/renderer/src/components/task-page/github/{ => listing}/task-page-preset-highlight.ts (71%) rename src/renderer/src/components/task-page/github/{ => listing}/use-task-page-github-collection-state.ts (86%) rename src/renderer/src/components/task-page/github/{ => listing}/use-task-page-github-list-pending.ts (95%) rename src/renderer/src/components/task-page/github/{ => listing}/use-task-page-github-list-projection.ts (90%) rename src/renderer/src/components/task-page/github/{ => listing}/use-task-page-github-list-state.ts (97%) rename src/renderer/src/components/task-page/github/{ => listing}/use-task-page-github-search-pagination.ts (93%) rename src/renderer/src/components/task-page/github/{ => mutations}/task-page-github-confirmed-client-values.ts (90%) rename src/renderer/src/components/task-page/github/{ => mutations}/task-page-github-dialog-state-authority.ts (88%) rename src/renderer/src/components/task-page/github/{ => mutations}/task-page-github-work-item-filter-membership.ts (91%) rename src/renderer/src/components/task-page/github/{ => mutations}/task-page-github-work-item-mutation-composition.ts (94%) rename src/renderer/src/components/task-page/github/{ => mutations}/task-page-github-work-item-mutation-keys.ts (100%) rename src/renderer/src/components/task-page/github/{ => mutations}/task-page-github-work-item-mutation-lifecycle.ts (95%) rename src/renderer/src/components/task-page/github/{ => mutations}/task-page-github-work-item-mutation-pages.ts (93%) rename src/renderer/src/components/task-page/github/{ => mutations}/task-page-github-work-item-mutation-patches.ts (95%) rename src/renderer/src/components/task-page/github/{ => mutations}/task-page-github-work-item-mutation-registry.ts (93%) rename src/renderer/src/components/task-page/github/{ => mutations}/task-page-github-work-item-mutation-types.ts (71%) rename src/renderer/src/components/task-page/github/{ => mutations}/task-page-github-work-item-mutations.ts (88%) rename src/renderer/src/components/task-page/github/{ => mutations}/task-page-github-work-item-registry-types.ts (78%) rename src/renderer/src/components/task-page/github/{ => mutations}/use-task-page-github-mutation-state.ts (87%) delete mode 100644 src/renderer/src/components/task-page/github/pr-check-hydration.test.tsx rename src/renderer/src/components/task-page/github/{ => pull-requests}/ChecksCell.tsx (89%) rename src/renderer/src/components/task-page/github/{ => pull-requests}/MergeCell.tsx (93%) rename src/renderer/src/components/task-page/github/{ => pull-requests}/ReviewCell.tsx (95%) rename src/renderer/src/components/task-page/github/{ => pull-requests}/ReviewerPicker.tsx (97%) rename src/renderer/src/components/task-page/github/{ => pull-requests}/task-page-github-review-model.tsx (83%) rename src/renderer/src/components/task-page/github/{ => pull-requests}/task-page-github-reviewer-actions.ts (90%) rename src/renderer/src/components/task-page/github/{ => pull-requests}/use-task-page-pr-check-hydration.ts (93%) rename src/renderer/src/components/task-page/github/{ => refresh}/task-page-github-landing-refresh-model.ts (92%) rename src/renderer/src/components/task-page/github/{ => refresh}/task-page-github-landing-refresh-run.tsx (93%) rename src/renderer/src/components/task-page/github/{ => refresh}/task-page-github-quiet-refresh-run.tsx (92%) rename src/renderer/src/components/task-page/github/{ => refresh}/task-page-github-work-item-authority-refresh.ts (94%) rename src/renderer/src/components/task-page/github/{ => refresh}/task-page-github-work-item-quiet-adopt.ts (87%) rename src/renderer/src/components/task-page/github/{ => refresh}/task-page-github-work-item-quiet-revalidate.ts (92%) rename src/renderer/src/components/task-page/github/{ => refresh}/task-page-github-work-item-quiet-state.ts (96%) rename src/renderer/src/components/task-page/github/{ => refresh}/use-task-page-github-landing-refresh.ts (90%) rename src/renderer/src/components/task-page/github/{ => refresh}/use-task-page-github-quiet-refresh-effect.ts (100%) rename src/renderer/src/components/task-page/github/{ => refresh}/use-task-page-github-quiet-refresh.ts (94%) delete mode 100644 src/renderer/src/components/task-page/github/task-page-github-list-scroll-restore.test.ts delete mode 100644 src/renderer/src/components/task-page/task-page-github-pr-chips.test.tsx delete mode 100644 src/renderer/src/components/task-page/use-task-page-source-display.test-fixture.tsx delete mode 100644 src/renderer/src/store/github/sources/github-source-lifetime.test.tsx delete mode 100644 src/renderer/src/store/github/task-records/github-task-records.test.ts diff --git a/config/react-compiler-bailout-baseline.txt b/config/react-compiler-bailout-baseline.txt index 643bcde57d2..a52ba10321a 100644 --- a/config/react-compiler-bailout-baseline.txt +++ b/config/react-compiler-bailout-baseline.txt @@ -577,20 +577,20 @@ 1 src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts 2 src/renderer/src/components/tab-bar/use-tab-bar-item-actions.ts 1 src/renderer/src/components/tab-group/useTabDragSplit.ts -1 src/renderer/src/components/task-page/github/AssigneesCell.tsx -2 src/renderer/src/components/task-page/github/MergeCell.tsx -1 src/renderer/src/components/task-page/github/StatusCell.tsx -1 src/renderer/src/components/task-page/github/use-task-page-github-cache-reconciliation.ts +1 src/renderer/src/components/task-page/github/listing/AssigneesCell.tsx +2 src/renderer/src/components/task-page/github/pull-requests/MergeCell.tsx +1 src/renderer/src/components/task-page/github/issue-status/StatusCell.tsx +1 src/renderer/src/components/task-page/github/cache/use-task-page-github-cache-reconciliation.ts 2 src/renderer/src/components/task-page/github/use-task-page-github-detail.ts -2 src/renderer/src/components/task-page/github/use-task-page-github-issue-creation.ts -21 src/renderer/src/components/task-page/github/use-task-page-github-issue-draft.ts -1 src/renderer/src/components/task-page/github/use-task-page-github-landing-refresh.ts -3 src/renderer/src/components/task-page/github/use-task-page-github-list-state.ts -7 src/renderer/src/components/task-page/github/use-task-page-github-mutation-state.ts -1 src/renderer/src/components/task-page/github/use-task-page-github-quiet-refresh-effect.ts -5 src/renderer/src/components/task-page/github/use-task-page-github-quiet-refresh.ts -3 src/renderer/src/components/task-page/github/use-task-page-github-search-pagination.ts -4 src/renderer/src/components/task-page/github/use-task-page-work-items-cache-selection.ts +2 src/renderer/src/components/task-page/github/issue-creation/use-task-page-github-issue-creation.ts +21 src/renderer/src/components/task-page/github/issue-creation/use-task-page-github-issue-draft.ts +1 src/renderer/src/components/task-page/github/refresh/use-task-page-github-landing-refresh.ts +3 src/renderer/src/components/task-page/github/listing/use-task-page-github-list-state.ts +7 src/renderer/src/components/task-page/github/mutations/use-task-page-github-mutation-state.ts +1 src/renderer/src/components/task-page/github/refresh/use-task-page-github-quiet-refresh-effect.ts +5 src/renderer/src/components/task-page/github/refresh/use-task-page-github-quiet-refresh.ts +3 src/renderer/src/components/task-page/github/listing/use-task-page-github-search-pagination.ts +4 src/renderer/src/components/task-page/github/cache/use-task-page-work-items-cache-selection.ts 1 src/renderer/src/components/task-page/gitlab/use-task-page-gitlab-loading.ts 2 src/renderer/src/components/task-page/jira/task-page-jira-board-view.tsx 1 src/renderer/src/components/task-page/jira/use-task-page-jira-creation-projects.ts diff --git a/config/scripts/ci/sharding/ci-shard-timings.json b/config/scripts/ci/sharding/ci-shard-timings.json index eef8f4ab3db..081ff049122 100644 --- a/config/scripts/ci/sharding/ci-shard-timings.json +++ b/config/scripts/ci/sharding/ci-shard-timings.json @@ -4530,7 +4530,6 @@ "src/renderer/src/components/feature-wall/setup/FullDiskAccessSetupPrompt.test.ts": 466, "src/renderer/src/components/floating-terminal/floating-workspace-tab-reorder.test.ts": 2520, "src/renderer/src/components/github-checks-tab-state.test.ts": 52, - "src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section.assignee-identity.test.tsx": 461, "src/renderer/src/components/github-item-dialog/land-pull-request/pr-reviewers-request-actions.test.ts": 1976, "src/renderer/src/components/github-item-dialog/load-item-details/use-github-item-dialog-details.test.tsx": 2850, "src/renderer/src/components/github-item-dialog/load-item-details/use-github-task-refresh.test.tsx": 1070, @@ -5088,7 +5087,6 @@ "src/renderer/src/components/tab-group/useTabGroupCloseScopeCommands.test.tsx": 2023, "src/renderer/src/components/tab-group/workspace-tab-close-commands.frame-flush.test.ts": 1604, "src/renderer/src/components/task-page/github/task-page-github-landing-refresh-record.test.tsx": 971, - "src/renderer/src/components/task-page/github/task-page-github-list-scroll-restore.test.ts": 197, "src/renderer/src/components/task-page/github/task-page-github-work-item-mutation-regressions.test.ts": 140, "src/renderer/src/components/task-page/github/task-page-github-work-item-mutations.test.ts": 169, "src/renderer/src/components/task-page/github/use-task-page-github-issue-creation.test.tsx": 1769, @@ -5831,7 +5829,6 @@ "src/renderer/src/store/github/repositories/github-repo-lookup-index.test.ts": 51, "src/renderer/src/store/github/review-threads/github-review-thread-rollback.test.ts": 663, "src/renderer/src/store/github/task-records/github-task-record-retries.test.ts": 758, - "src/renderer/src/store/github/task-records/github-task-records.test.ts": 807, "src/renderer/src/store/github/work-items/github-work-items-error-envelope.test.ts": 1746, "src/renderer/src/store/github/work-items/github-work-items-pagination.test.ts": 1806, "src/renderer/src/store/github/work-items/github-work-items-runtime-routing.test.ts": 1891, diff --git a/config/scripts/preflight/changed-checks/check-changed-code-quality.mjs b/config/scripts/preflight/changed-checks/check-changed-code-quality.mjs index 8cd2d6cf97d..00753773695 100644 --- a/config/scripts/preflight/changed-checks/check-changed-code-quality.mjs +++ b/config/scripts/preflight/changed-checks/check-changed-code-quality.mjs @@ -76,7 +76,7 @@ const SUPPRESSED_REACT_DOCTOR_DIAGNOSTICS = new Map([ [ 'react-doctor(no-adjust-state-on-prop-change)', new Set([ - 'src/renderer/src/components/task-page/github/use-task-page-github-issue-draft.ts', + 'src/renderer/src/components/task-page/github/issue-creation/use-task-page-github-issue-draft.ts', 'src/renderer/src/components/task-page/jira/use-task-page-jira-creation-state.ts' ]) ], diff --git a/package.json b/package.json index a15fcdc3edb..cf4dd2f8e49 100644 --- a/package.json +++ b/package.json @@ -440,7 +440,7 @@ }, { "files": [ - "src/renderer/src/components/task-page/github/use-task-page-github-issue-draft.ts", + "src/renderer/src/components/task-page/github/issue-creation/use-task-page-github-issue-draft.ts", "src/renderer/src/components/task-page/jira/use-task-page-jira-creation-state.ts" ], "rules": [ diff --git a/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-close-as-duplicate.ts b/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-close-as-duplicate.ts index 5a1ff81006b..011be2c853b 100644 --- a/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-close-as-duplicate.ts +++ b/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-close-as-duplicate.ts @@ -2,7 +2,7 @@ import { getTaskPageGitHubDuplicateTargetErrorMessage, validateTaskPageGitHubDuplicateTarget, type TaskPageGitHubCloseAction -} from '@/components/task-page/github/task-page-github-status-actions' +} from '@/components/task-page/github/issue-status/task-page-github-status-actions' import { translate } from '@/i18n/i18n' export function closeGHEditAsDuplicate({ diff --git a/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-horizontal.tsx b/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-horizontal.tsx index 644262111fa..a9a543cf62d 100644 --- a/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-horizontal.tsx +++ b/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-horizontal.tsx @@ -10,7 +10,7 @@ import { } from '@/components/ui/dropdown-menu' import type { GitHubAssignableUser } from '../../../../../shared/github/pull-requests/pull-request-types' import type { GitHubWorkItem } from '../../../../../shared/github/work-items/work-item-types' -import type { TaskPageGitHubCloseAction } from '@/components/task-page/github/task-page-github-status-actions' +import type { TaskPageGitHubCloseAction } from '@/components/task-page/github/issue-status/task-page-github-status-actions' import { useTranslate } from '@/i18n/use-translate' import { GHEditSectionStatusPopover } from './gh-edit-section-status-popover' import { GHEditSectionAssigneesPill } from './gh-edit-section-assignees' diff --git a/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-mutations.ts b/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-mutations.ts index a1ac4285b77..d2cf514ad78 100644 --- a/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-mutations.ts +++ b/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-mutations.ts @@ -4,9 +4,9 @@ import type { useImmediateMutation } from '@/hooks/useIssueMetadata' import { buildTaskPageGitHubCloseUpdate, type TaskPageGitHubCloseAction -} from '@/components/task-page/github/task-page-github-status-actions' +} from '@/components/task-page/github/issue-status/task-page-github-status-actions' import { beginEdit, isTrackedEdit } from './gh-edit-task-record' -import { assertTaskPageGitHubDialogStateAuthority } from '@/components/task-page/github/task-page-github-dialog-state-authority' +import { assertTaskPageGitHubDialogStateAuthority } from '@/components/task-page/github/mutations/task-page-github-dialog-state-authority' import { runIssueUpdate } from '@/components/github/github-work-item-edit-mutations' import type { GitHubWorkItem } from '../../../../../shared/github/work-items/work-item-types' import type { GitHubRepositoryIdentity as GitHubOwnerRepo } from '../../../../../shared/github/repositories/repository-identity' diff --git a/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-status-popover.tsx b/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-status-popover.tsx index 99e9fbf6c70..570788962a0 100644 --- a/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-status-popover.tsx +++ b/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-status-popover.tsx @@ -15,7 +15,7 @@ import { Input } from '@/components/ui/input' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { cn } from '@/lib/class-names' import type { GitHubWorkItem } from '../../../../../shared/github/work-items/work-item-types' -import type { TaskPageGitHubCloseAction } from '@/components/task-page/github/task-page-github-status-actions' +import type { TaskPageGitHubCloseAction } from '@/components/task-page/github/issue-status/task-page-github-status-actions' import { getStateLabel } from '@/components/github/work-item-state-presentation' import { useTranslate } from '@/i18n/use-translate' import { getStateTone } from '../load-item-details/work-item-state-badge' diff --git a/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-top-columns.tsx b/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-top-columns.tsx index eec2c5c370e..dc99bddc5af 100644 --- a/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-top-columns.tsx +++ b/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-top-columns.tsx @@ -2,7 +2,7 @@ import React from 'react' import { FolderKanban } from 'lucide-react' import type { GitHubAssignableUser } from '../../../../../shared/github/pull-requests/pull-request-types' import type { GitHubWorkItem } from '../../../../../shared/github/work-items/work-item-types' -import type { TaskPageGitHubCloseAction } from '@/components/task-page/github/task-page-github-status-actions' +import type { TaskPageGitHubCloseAction } from '@/components/task-page/github/issue-status/task-page-github-status-actions' import { useTranslate } from '@/i18n/use-translate' import { GHEditSectionStatusPopover } from './gh-edit-section-status-popover' import { GHEditSectionAssigneesColumn } from './gh-edit-section-assignees' diff --git a/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section.assignee-identity.test.tsx b/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section.assignee-identity.test.tsx deleted file mode 100644 index 3dcd00abfeb..00000000000 --- a/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section.assignee-identity.test.tsx +++ /dev/null @@ -1,304 +0,0 @@ -// @vitest-environment happy-dom - -import { act, useState } from 'react' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { GitHubWorkItem } from '../../../../../shared/github/work-items/work-item-types' -import type { GitHubItemDialogProjectOrigin } from '../load-item-details/github-item-dialog-types' -import { GHEditSection } from './gh-edit-section' -import { resetTaskPageGitHubMutationRegistryForTests } from '@/components/task-page/github/task-page-github-work-item-mutation-registry' - -const mocks = vi.hoisted(() => ({ - update: vi.fn(), - patchWorkItem: vi.fn(), - patchProjectRowContent: vi.fn(), - recordFeatureInteraction: vi.fn(), - onMutated: vi.fn() -})) - -vi.mock('@/store', () => { - const state = { - patchWorkItem: mocks.patchWorkItem, - patchProjectRowContent: mocks.patchProjectRowContent, - recordFeatureInteraction: mocks.recordFeatureInteraction, - repos: [], - settings: null - } - return { - useAppStore: Object.assign((selector: (value: typeof state) => unknown) => selector(state), { - getState: () => state - }) - } -}) - -vi.mock('@/hooks/useIssueMetadata', async () => { - const { useImmediateMutation } = await import('@/hooks/useImmediateMutation') - return { useImmediateMutation } -}) -vi.mock('@/hooks/useGitHubRepoMetadata', () => ({ - useRepoLabels: () => ({ data: [] }), - useRepoAssignees: () => ({ data: [] }) -})) -vi.mock('@/hooks/useGitHubSlugMetadata', () => ({ - useRepoLabelsBySlug: () => ({ data: [] }), - useRepoAssigneesBySlug: () => ({ data: [] }) -})) -vi.mock('@/components/github/github-duplicate-issue-candidates', () => ({ - useGitHubDuplicateIssueCandidates: () => [] -})) -vi.mock('@/components/github/github-work-item-edit-mutations', () => ({ - runIssueUpdate: mocks.update -})) -vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) -vi.mock('./gh-edit-section-horizontal', () => ({ GHEditSectionHorizontal: () => null })) -vi.mock('./gh-edit-section-top-columns', () => ({ - GHEditSectionTopColumns: ({ - localAssignees, - onAssigneeToggle, - localState, - localLabels, - onStateChange, - onLabelToggle - }: { - localAssignees: string[] - onAssigneeToggle: (login: string) => void - localState: GitHubWorkItem['state'] - localLabels: string[] - onStateChange: (state: 'open' | 'closed') => void - onLabelToggle: (label: string) => void - }) => ( - <> - {localAssignees.join(',')} - {localState} - {localLabels.join(',')} - - - - - - ) -})) - -const item: GitHubWorkItem = { - id: 'issue:5', - repoId: 'registered-repo', - type: 'issue', - number: 5, - title: 'Issue five', - state: 'open', - url: 'https://github.com/upstream/widgets/issues/5', - labels: [], - updatedAt: '', - author: null -} -let root: Root -let container: HTMLDivElement - -async function render( - nextItem = item, - assignees: string[] = [], - projectOrigin?: GitHubItemDialogProjectOrigin -): Promise { - await act(async () => { - root.render( - {}} - onLabelsChange={() => {}} - onMutated={mocks.onMutated} - assignees={assignees} - onUse={() => {}} - layout="top-columns" - /> - ) - }) -} - -async function click(label = 'Assign candidate'): Promise { - const button = [...container.querySelectorAll('button')].find( - (candidate) => candidate.textContent === label - ) - expect(button).toBeDefined() - await act(async () => button?.click()) -} - -function selected(): string | null { - return container.querySelector('[data-field="assignees"]')?.textContent ?? null -} - -let updateParentSelection: (nextItem: GitHubWorkItem) => void = () => {} - -function ParentSelection({ item }: { item: GitHubWorkItem }): React.JSX.Element { - const [localState, setLocalState] = useState(item.state) - const [localLabels, setLocalLabels] = useState(item.labels) - updateParentSelection = (nextItem) => { - setLocalState(nextItem.state) - setLocalLabels(nextItem.labels) - } - return ( - {}} - layout="top-columns" - /> - ) -} - -async function renderParent(nextItem: GitHubWorkItem): Promise { - await act(async () => { - updateParentSelection(nextItem) - root.render() - }) -} - -describe('opened issue assignee ownership', () => { - beforeEach(() => { - vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) - vi.clearAllMocks() - resetTaskPageGitHubMutationRegistryForTests() - updateParentSelection = () => {} - mocks.update.mockResolvedValue({ ok: true }) - container = document.createElement('div') - document.body.appendChild(container) - root = createRoot(container) - }) - - afterEach(() => { - act(() => root.unmount()) - container.remove() - vi.unstubAllGlobals() - resetTaskPageGitHubMutationRegistryForTests() - }) - - it('retires an optimistic selection across fork and return navigation', async () => { - await render() - await click() - expect(selected()).toBe('candidate-user') - await render(item, []) - expect(selected()).toBe('candidate-user') - - await render({ ...item, url: 'https://github.com/fork/widgets/issues/5' }, ['fork-user']) - expect(selected()).toBe('fork-user') - await render(item, ['authoritative-upstream-user']) - expect(selected()).toBe('authoritative-upstream-user') - }) - - it.each(['Assign candidate', 'Toggle original'])( - 'does not roll a late failed %s edit into another opened issue', - async (button) => { - let rejectUpdate: (reason: Error) => void = () => {} - mocks.update.mockImplementation( - () => - new Promise((_resolve, reject) => { - rejectUpdate = reject - }) - ) - await render(item, ['original-user']) - await click(button) - await render({ ...item, id: 'issue:6', number: 6 }, ['other-issue-user']) - expect(selected()).toBe('other-issue-user') - await act(async () => rejectUpdate(new Error('held edit failed'))) - expect(selected()).toBe('other-issue-user') - } - ) - - it('still rolls back a failed edit while its issue remains open', async () => { - mocks.update.mockRejectedValue(new Error('edit failed')) - await render(item, ['original-user']) - await click() - expect(selected()).toBe('original-user') - await render(item, ['refetched-user']) - expect(selected()).toBe('refetched-user') - }) - - it('keeps a late Project-row rollback scoped to the captured row', async () => { - const projectOrigin: GitHubItemDialogProjectOrigin = { - owner: 'upstream', - repo: 'widgets', - number: 5, - type: 'issue', - projectId: 'project', - projectItemId: 'upstream-row', - cacheKey: 'upstream-cache' - } - let rejectUpdate: (reason: Error) => void = () => {} - mocks.update.mockImplementation( - () => - new Promise((_resolve, reject) => { - rejectUpdate = reject - }) - ) - await render(item, ['original-user'], projectOrigin) - await click() - await render(item, ['fork-user'], { - ...projectOrigin, - owner: 'fork', - projectItemId: 'fork-row', - cacheKey: 'fork-cache' - }) - expect(selected()).toBe('fork-user') - await act(async () => rejectUpdate(new Error('held Project edit failed'))) - expect(selected()).toBe('fork-user') - expect(mocks.patchProjectRowContent).toHaveBeenLastCalledWith( - 'upstream-cache', - 'upstream-row', - { assignees: ['original-user'] } - ) - }) - - it.each([ - { button: 'Toggle label', field: 'labels', expected: 'fork-only-label' }, - { button: 'Close issue', field: 'state', expected: 'closed' } - ])( - 'a late failed $field edit cannot overwrite the new parent selection', - async ({ button, field, expected }) => { - let rejectUpdate: (reason: Error) => void = () => {} - mocks.update.mockImplementation( - () => - new Promise((_resolve, reject) => { - rejectUpdate = reject - }) - ) - await renderParent({ ...item, labels: ['upstream-original-label'] }) - await click(button) - await renderParent({ - ...item, - state: 'closed', - url: 'https://github.com/fork/widgets/issues/5', - labels: ['fork-only-label'] - }) - expect(container.querySelector(`[data-field="${field}"]`)?.textContent).toBe(expected) - await act(async () => rejectUpdate(new Error('held edit failed'))) - expect(container.querySelector(`[data-field="${field}"]`)?.textContent).toBe(expected) - // A failed edit without a tracked owner never touched other copies, so it has nothing to undo there. - expect(mocks.patchWorkItem).not.toHaveBeenCalled() - } - ) - - it.each([ - { button: 'Toggle label', field: 'labels', expected: 'upstream-original-label' }, - { button: 'Close issue', field: 'state', expected: 'open' } - ])( - 'a same-item failed $field edit still restores its prior parent value', - async ({ button, field, expected }) => { - mocks.update.mockRejectedValue(new Error('same issue edit failed')) - await renderParent({ ...item, labels: ['upstream-original-label'] }) - await click(button) - expect(container.querySelector(`[data-field="${field}"]`)?.textContent).toBe(expected) - } - ) -}) diff --git a/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section.tsx b/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section.tsx index 605f411c3c2..5705223ec65 100644 --- a/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section.tsx +++ b/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section.tsx @@ -11,7 +11,7 @@ import { getTaskPageGitHubDuplicateTargetErrorMessage, validateTaskPageGitHubDuplicateTarget, type TaskPageGitHubCloseAction -} from '@/components/task-page/github/task-page-github-status-actions' +} from '@/components/task-page/github/issue-status/task-page-github-status-actions' import { parseGitHubItemRepository as parseOwnerRepoFromItemUrl } from '../../../../../shared/github/work-items/work-item-target' import { translate } from '@/i18n/i18n' import { useTranslate } from '@/i18n/use-translate' diff --git a/src/renderer/src/components/github-item-dialog/land-pull-request/pr-actions-panel.tsx b/src/renderer/src/components/github-item-dialog/land-pull-request/pr-actions-panel.tsx index 25b2f8e94ea..10964d3108d 100644 --- a/src/renderer/src/components/github-item-dialog/land-pull-request/pr-actions-panel.tsx +++ b/src/renderer/src/components/github-item-dialog/land-pull-request/pr-actions-panel.tsx @@ -22,7 +22,7 @@ import { getPullRequestMergeMethodLabel, getPullRequestStateConfirmationTitle } from '@/components/github/pull-request-confirmation-copy' -import { assertTaskPageGitHubDialogStateAuthority } from '@/components/task-page/github/task-page-github-dialog-state-authority' +import { assertTaskPageGitHubDialogStateAuthority } from '@/components/task-page/github/mutations/task-page-github-dialog-state-authority' import { resolvePullRequestRepo } from '@/components/github/github-work-item-identity' import { notifyWorkItemDetailsMutation } from '@/components/github/github-work-item-comment-mutations' import { runPullRequestStateUpdate } from '@/components/github/github-work-item-edit-mutations' diff --git a/src/renderer/src/components/github/work-item-state-presentation.tsx b/src/renderer/src/components/github/work-item-state-presentation.tsx index 425312e3c59..2174d933430 100644 --- a/src/renderer/src/components/github/work-item-state-presentation.tsx +++ b/src/renderer/src/components/github/work-item-state-presentation.tsx @@ -3,7 +3,7 @@ import { GitHubUserAvatar } from '@/components/github/github-user-avatar' import type { GitHubAssignableUser } from '../../../../shared/github/pull-requests/pull-request-types' import type { GitHubWorkItem } from '../../../../shared/github/work-items/work-item-types' import { formatUiRelativeTimeFromDate } from '@/i18n/relative-time-format' -import { getTaskPageGitHubWorkItemStateLabel } from '@/components/task-page/github/task-page-github-work-item-status' +import { getTaskPageGitHubWorkItemStateLabel } from '@/components/task-page/github/listing/task-page-github-work-item-status' import type { Translate } from '@/i18n/use-translate' export function formatRelativeTime(input: string, intlLocale?: string): string { diff --git a/src/renderer/src/components/task-page/Content.tsx b/src/renderer/src/components/task-page/Content.tsx index b9700f80d28..e496b877a95 100644 --- a/src/renderer/src/components/task-page/Content.tsx +++ b/src/renderer/src/components/task-page/Content.tsx @@ -3,7 +3,7 @@ import PullRequestPage from '@/components/PullRequestPage' import GitHubItemDialog from '@/components/GitHubItemDialog' import { useTranslate } from '@/i18n/use-translate' import ProjectViewWrapper from '@/components/github-project/ProjectViewWrapper' -import { TaskPageGitHubList } from './github/List' +import { TaskPageGitHubList } from './github/listing/List' import { TaskPageGitLabTodoList } from './gitlab/TodoList' import { TaskPageGitLabItemList } from './gitlab/ItemList' import { TaskPageJiraContent } from './jira/Content' diff --git a/src/renderer/src/components/task-page/ListChrome.tsx b/src/renderer/src/components/task-page/ListChrome.tsx index 74fd024d47b..5dd58fd1373 100644 --- a/src/renderer/src/components/task-page/ListChrome.tsx +++ b/src/renderer/src/components/task-page/ListChrome.tsx @@ -2,7 +2,7 @@ import type { TaskPageComposerActionsModel } from './use-task-page-composer-acti import { cn } from '@/lib/class-names' import { TaskPageSourceBar } from './SourceBar' import { AlertCircle } from 'lucide-react' -import { TaskPageGitHubModeControls } from './github/ModeControls' +import { TaskPageGitHubModeControls } from './github/listing/ModeControls' import { TaskPageProviderFilters } from './ProviderFilters' export function TaskPageListChrome({ model diff --git a/src/renderer/src/components/task-page/ProviderFilters.tsx b/src/renderer/src/components/task-page/ProviderFilters.tsx index 9c21eb8c47d..b2043802b05 100644 --- a/src/renderer/src/components/task-page/ProviderFilters.tsx +++ b/src/renderer/src/components/task-page/ProviderFilters.tsx @@ -1,5 +1,5 @@ import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions' -import { TaskPageGitHubFilters } from './github/Filters' +import { TaskPageGitHubFilters } from './github/listing/Filters' import { TaskPageLinearFilters } from './linear/Filters' import { TaskPageJiraFilters } from './jira/Filters' import { TaskPageGitLabFilters } from './gitlab/Filters' diff --git a/src/renderer/src/components/task-page/Surface.tsx b/src/renderer/src/components/task-page/Surface.tsx index 2737627ec2c..736001ade30 100644 --- a/src/renderer/src/components/task-page/Surface.tsx +++ b/src/renderer/src/components/task-page/Surface.tsx @@ -1,6 +1,6 @@ import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions' import { TaskPageFrame } from './Frame' -import { TaskPageGitHubIssueDialog } from './github/IssueDialog' +import { TaskPageGitHubIssueDialog } from './github/issue-creation/IssueDialog' import { TaskPageLinearProjectDialog } from './linear/ProjectDialog' import { TaskPageLinearIssueDialog } from './linear/IssueDialog' import { TaskPageJiraIssueDialog } from './jira/IssueDialog' diff --git a/src/renderer/src/components/task-page/TaskPage.tsx b/src/renderer/src/components/task-page/TaskPage.tsx index 783f360c0d6..c8d42e8d023 100644 --- a/src/renderer/src/components/task-page/TaskPage.tsx +++ b/src/renderer/src/components/task-page/TaskPage.tsx @@ -4,10 +4,10 @@ import { useTaskPageRepoSelection } from './use-task-page-repo-selection' import { useTaskPageRuntimeHosts } from './use-task-page-runtime-hosts' import { useTaskPageSourceAvailability } from './use-task-page-source-availability' import { useTaskPageProviderState } from './use-task-page-provider-state' -import { useTaskPageGitHubListState } from './github/use-task-page-github-list-state' +import { useTaskPageGitHubListState } from './github/listing/use-task-page-github-list-state' import { useTaskPageGitHubDetail } from './github/use-task-page-github-detail' -import { useTaskPageGitHubCacheReconciliation } from './github/use-task-page-github-cache-reconciliation' -import { useTaskPageGitHubIssueDraft } from './github/use-task-page-github-issue-draft' +import { useTaskPageGitHubCacheReconciliation } from './github/cache/use-task-page-github-cache-reconciliation' +import { useTaskPageGitHubIssueDraft } from './github/issue-creation/use-task-page-github-issue-draft' import { useTaskPageDetailRouting } from './use-task-page-detail-routing' import { useTaskPageLinearViewState } from './linear/use-task-page-linear-view-state' import { useTaskPageJiraListState } from './jira/use-task-page-jira-list-state' @@ -19,16 +19,16 @@ import { useTaskPageLinearListProjection } from './linear/use-task-page-linear-l import { useTaskPageLinearBoard } from './linear/use-task-page-linear-board' import { useTaskPageJiraListProjection } from './jira/use-task-page-jira-list-projection' import { useTaskPageLinearCreationState } from './linear/use-task-page-linear-creation-state' -import { useTaskPageGitHubMutationState } from './github/use-task-page-github-mutation-state' +import { useTaskPageGitHubMutationState } from './github/mutations/use-task-page-github-mutation-state' import { useTaskPageJiraCreationState } from './jira/use-task-page-jira-creation-state' import { useTaskPageJiraCreationMetadata } from './jira/use-task-page-jira-creation-metadata' -import { useTaskPageGitHubListProjection } from './github/use-task-page-github-list-projection' -import { useTaskPageGitHubSearchPagination } from './github/use-task-page-github-search-pagination' -import { useTaskPageGitHubLandingRefresh } from './github/use-task-page-github-landing-refresh' -import { useTaskPageGitHubQuietRefresh } from './github/use-task-page-github-quiet-refresh' +import { useTaskPageGitHubListProjection } from './github/listing/use-task-page-github-list-projection' +import { useTaskPageGitHubSearchPagination } from './github/listing/use-task-page-github-search-pagination' +import { useTaskPageGitHubLandingRefresh } from './github/refresh/use-task-page-github-landing-refresh' +import { useTaskPageGitHubQuietRefresh } from './github/refresh/use-task-page-github-quiet-refresh' import { useTaskPageSearchActions } from './use-task-page-search-actions' import { useTaskPageWorkspaceActions } from './use-task-page-workspace-actions' -import { useTaskPageGitHubIssueCreation } from './github/use-task-page-github-issue-creation' +import { useTaskPageGitHubIssueCreation } from './github/issue-creation/use-task-page-github-issue-creation' import { useTaskPageLinearProjectCreation } from './linear/use-task-page-linear-project-creation' import { useTaskPageLinearIssueCreation } from './linear/use-task-page-linear-issue-creation' import { useTaskPageJiraIssueCreation } from './jira/use-task-page-jira-issue-creation' diff --git a/src/renderer/src/components/task-page/github/task-page-github-resume-cache.ts b/src/renderer/src/components/task-page/github/cache/task-page-github-resume-cache.ts similarity index 96% rename from src/renderer/src/components/task-page/github/task-page-github-resume-cache.ts rename to src/renderer/src/components/task-page/github/cache/task-page-github-resume-cache.ts index 3306b23b7a6..4a8f2b11eb5 100644 --- a/src/renderer/src/components/task-page/github/task-page-github-resume-cache.ts +++ b/src/renderer/src/components/task-page/github/cache/task-page-github-resume-cache.ts @@ -1,4 +1,4 @@ -import type { GitHubWorkItem } from '../../../../../shared/github/work-items/work-item-types' +import type { GitHubWorkItem } from '../../../../../../shared/github/work-items/work-item-types' export const TASK_PAGE_GITHUB_RESUME_CACHE_LIMIT = 5 export const TASK_PAGE_GITHUB_RESUME_CACHE_TTL_MS = 10 * 60_000 diff --git a/src/renderer/src/components/task-page/github/use-task-page-github-cache-reconciliation.ts b/src/renderer/src/components/task-page/github/cache/use-task-page-github-cache-reconciliation.ts similarity index 93% rename from src/renderer/src/components/task-page/github/use-task-page-github-cache-reconciliation.ts rename to src/renderer/src/components/task-page/github/cache/use-task-page-github-cache-reconciliation.ts index 6bdf228e00e..926d11ae214 100644 --- a/src/renderer/src/components/task-page/github/use-task-page-github-cache-reconciliation.ts +++ b/src/renderer/src/components/task-page/github/cache/use-task-page-github-cache-reconciliation.ts @@ -1,11 +1,11 @@ -import type { TaskPageGitHubDetailModel } from './use-task-page-github-detail' +import type { TaskPageGitHubDetailModel } from '../use-task-page-github-detail' import { useCallback, useMemo, useEffect, useRef, useState } from 'react' -import type { GitHubWorkItem } from '../../../../../shared/github/work-items/work-item-types' -import type { GitHubAssignableUser } from '../../../../../shared/github/pull-requests/pull-request-types' +import type { GitHubWorkItem } from '../../../../../../shared/github/work-items/work-item-types' +import type { GitHubAssignableUser } from '../../../../../../shared/github/pull-requests/pull-request-types' import { patchTaskPageGitHubWorkItemPages, overlayPendingOnTaskPagePages -} from '@/components/task-page/github/task-page-github-work-item-mutation-pages' +} from '@/components/task-page/github/mutations/task-page-github-work-item-mutation-pages' import { type TaskPageRepoSourceState, buildTaskPageRepoSourceState, diff --git a/src/renderer/src/components/task-page/github/use-task-page-work-items-cache-selection.ts b/src/renderer/src/components/task-page/github/cache/use-task-page-work-items-cache-selection.ts similarity index 92% rename from src/renderer/src/components/task-page/github/use-task-page-work-items-cache-selection.ts rename to src/renderer/src/components/task-page/github/cache/use-task-page-work-items-cache-selection.ts index 7e432e11be5..9ab4d2dfc1a 100644 --- a/src/renderer/src/components/task-page/github/use-task-page-work-items-cache-selection.ts +++ b/src/renderer/src/components/task-page/github/cache/use-task-page-work-items-cache-selection.ts @@ -5,9 +5,9 @@ import { getTaskPageRepoCacheRows, createTaskPageWorkItemsCacheSelector } from '@/components/task-page/task-page-cache-selectors' -import { isGitHubWorkItemsQueryTooLarge } from '../../../../../shared/github/work-items/work-items-query-bounds' -import type { Repo } from '../../../../../shared/repo-types' -import { getTaskPageRepoCacheInput } from '../task-page-source-context' +import { isGitHubWorkItemsQueryTooLarge } from '../../../../../../shared/github/work-items/work-items-query-bounds' +import type { Repo } from '../../../../../../shared/repo-types' +import { getTaskPageRepoCacheInput } from '../../task-page-source-context' /** Selected rows' current-query cache entries, plus the entries the source displays read (see the selector). */ export function useTaskPageWorkItemsCacheSelection( diff --git a/src/renderer/src/components/task-page/github/IssueDialog.tsx b/src/renderer/src/components/task-page/github/issue-creation/IssueDialog.tsx similarity index 98% rename from src/renderer/src/components/task-page/github/IssueDialog.tsx rename to src/renderer/src/components/task-page/github/issue-creation/IssueDialog.tsx index 5df25094af5..60a86c92cf7 100644 --- a/src/renderer/src/components/task-page/github/IssueDialog.tsx +++ b/src/renderer/src/components/task-page/github/issue-creation/IssueDialog.tsx @@ -1,4 +1,4 @@ -import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions' +import type { TaskPageComposerActionsModel } from '../../use-task-page-composer-actions' import { useRef, useState } from 'react' import { getGitHubRuntimeRepoId } from '@/lib/work-items/github/github-source-runtime-context' import { @@ -12,7 +12,7 @@ import { import { isScreenSubmitShortcut } from '@/lib/keyboard/screen-submit-shortcut' import { useTranslate } from '@/i18n/use-translate' import { sameGitHubOwnerRepo } from '@/components/github/IssueSourceIndicator' -import { getRepoExecutionHostId } from '../../../../../shared/execution-host' +import { getRepoExecutionHostId } from '../../../../../../shared/execution-host' import { repoMatchesHostIdentity } from '@/store/repos/repo-host-identity' import IssueSourceSelector from '@/components/github/IssueSourceSelector' import { diff --git a/src/renderer/src/components/task-page/github/IssueSelectors.tsx b/src/renderer/src/components/task-page/github/issue-creation/IssueSelectors.tsx similarity index 97% rename from src/renderer/src/components/task-page/github/IssueSelectors.tsx rename to src/renderer/src/components/task-page/github/issue-creation/IssueSelectors.tsx index 3e8f289e0fd..b3327c2fba5 100644 --- a/src/renderer/src/components/task-page/github/IssueSelectors.tsx +++ b/src/renderer/src/components/task-page/github/issue-creation/IssueSelectors.tsx @@ -4,8 +4,8 @@ import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover import { Button } from '@/components/ui/button' import { LoaderCircle, Check } from 'lucide-react' import { cn } from '@/lib/class-names' -import type { GitHubAssignableUser } from '../../../../../shared/github/pull-requests/pull-request-types' -import { GitHubAssigneeAvatar } from './Avatars' +import type { GitHubAssignableUser } from '../../../../../../shared/github/pull-requests/pull-request-types' +import { GitHubAssigneeAvatar } from '../listing/Avatars' export function GitHubIssueLabelSelector({ labels, selectedLabels, diff --git a/src/renderer/src/components/task-page/github/use-task-page-github-issue-creation.ts b/src/renderer/src/components/task-page/github/issue-creation/use-task-page-github-issue-creation.ts similarity index 97% rename from src/renderer/src/components/task-page/github/use-task-page-github-issue-creation.ts rename to src/renderer/src/components/task-page/github/issue-creation/use-task-page-github-issue-creation.ts index 1c96f6cdba2..36ee1b6a085 100644 --- a/src/renderer/src/components/task-page/github/use-task-page-github-issue-creation.ts +++ b/src/renderer/src/components/task-page/github/issue-creation/use-task-page-github-issue-creation.ts @@ -1,10 +1,10 @@ -import type { TaskPageWorkspaceActionsModel } from '../use-task-page-workspace-actions' +import type { TaskPageWorkspaceActionsModel } from '../../use-task-page-workspace-actions' import { useCallback } from 'react' import { callRuntimeRpc } from '@/runtime/rpc/calls/runtime-rpc-call' import { useAppStore } from '@/store/index' import { toast } from 'sonner' import { translate } from '@/i18n/i18n' -import type { GitHubWorkItem } from '../../../../../shared/github/work-items/work-item-types' +import type { GitHubWorkItem } from '../../../../../../shared/github/work-items/work-item-types' import { codeHostErrorText } from '@/lib/source-control/remote/code-host-error-copy' import { openAppHttpLink } from '@/lib/browser/navigation/app-http-link-open' type TaskPageGitHubIssueCreationInput = Pick< diff --git a/src/renderer/src/components/task-page/github/use-task-page-github-issue-draft.ts b/src/renderer/src/components/task-page/github/issue-creation/use-task-page-github-issue-draft.ts similarity index 94% rename from src/renderer/src/components/task-page/github/use-task-page-github-issue-draft.ts rename to src/renderer/src/components/task-page/github/issue-creation/use-task-page-github-issue-draft.ts index 86f78ea3bce..cc09af77d7e 100644 --- a/src/renderer/src/components/task-page/github/use-task-page-github-issue-draft.ts +++ b/src/renderer/src/components/task-page/github/issue-creation/use-task-page-github-issue-draft.ts @@ -1,6 +1,6 @@ -import type { TaskPageGitHubCacheReconciliationModel } from './use-task-page-github-cache-reconciliation' +import type { TaskPageGitHubCacheReconciliationModel } from '../cache/use-task-page-github-cache-reconciliation' import { useState, useMemo, useEffect } from 'react' -import type { GitHubAssignableUser } from '../../../../../shared/github/pull-requests/pull-request-types' +import type { GitHubAssignableUser } from '../../../../../../shared/github/pull-requests/pull-request-types' import { useAppStore } from '@/store/index' import { getGitHubRepoRoutingTarget } from '@/lib/work-items/github/github-source-runtime-context' import { useRepoLabels, useRepoAssignees } from '@/hooks/useGitHubRepoMetadata' @@ -8,7 +8,7 @@ import { resolveVanishedNewIssueRepoReset, isNewIssueDraftContentful } from '@/components/task-page/task-page-new-issue-draft' -import { getTaskPageRepoSourceContext } from '../../../lib/work-items/task-repo-source-context' +import { getTaskPageRepoSourceContext } from '../../../../lib/work-items/task-repo-source-context' export function useTaskPageGitHubIssueDraft(model: TaskPageGitHubCacheReconciliationModel) { const { settings, repos, selectedRepos } = model const [newIssueOpen, setNewIssueOpen] = useState(false) diff --git a/src/renderer/src/components/task-page/github/DuplicatePicker.tsx b/src/renderer/src/components/task-page/github/issue-status/DuplicatePicker.tsx similarity index 97% rename from src/renderer/src/components/task-page/github/DuplicatePicker.tsx rename to src/renderer/src/components/task-page/github/issue-status/DuplicatePicker.tsx index 7d6b03e5b5f..a7c5148069b 100644 --- a/src/renderer/src/components/task-page/github/DuplicatePicker.tsx +++ b/src/renderer/src/components/task-page/github/issue-status/DuplicatePicker.tsx @@ -2,7 +2,7 @@ import type React from 'react' import { CheckCircle2, ChevronLeft, CircleDot, Copy, Search } from 'lucide-react' import { useTranslate } from '@/i18n/use-translate' -import type { GitHubWorkItem } from '../../../../../shared/github/work-items/work-item-types' +import type { GitHubWorkItem } from '../../../../../../shared/github/work-items/work-item-types' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' diff --git a/src/renderer/src/components/task-page/github/StatusCell.tsx b/src/renderer/src/components/task-page/github/issue-status/StatusCell.tsx similarity index 93% rename from src/renderer/src/components/task-page/github/StatusCell.tsx rename to src/renderer/src/components/task-page/github/issue-status/StatusCell.tsx index 5fdb7f29398..a08d0c80fac 100644 --- a/src/renderer/src/components/task-page/github/StatusCell.tsx +++ b/src/renderer/src/components/task-page/github/issue-status/StatusCell.tsx @@ -1,34 +1,34 @@ -import type { GitHubWorkItem } from '../../../../../shared/github/work-items/work-item-types' -import type { GitHubIssueUpdate } from '../../../../../shared/issue-mutation-types' -import type { Repo } from '../../../../../shared/repo-types' -import type { TaskSourceContext } from '../../../../../shared/work-items/task-source-context' +import type { GitHubWorkItem } from '../../../../../../shared/github/work-items/work-item-types' +import type { GitHubIssueUpdate } from '../../../../../../shared/issue-mutation-types' +import type { Repo } from '../../../../../../shared/repo-types' +import type { TaskSourceContext } from '../../../../../../shared/work-items/task-source-context' import React, { useState, useMemo, useCallback } from 'react' import { createTaskPageGitHubStatusStateDraft, resolveTaskPageGitHubStatusStateDraft, updateTaskPageGitHubStatusLocalState -} from '@/components/task-page/github/task-page-github-status-state' +} from '@/components/task-page/github/issue-status/task-page-github-status-state' import { useAppStore } from '@/store/index' import { useShallow } from 'zustand/react/shallow' import { getGitHubRepoRoutingTarget } from '@/lib/work-items/github/github-source-runtime-context' -import { parseGitHubIssueOrPRLink } from '../../../../../shared/github/repositories/links' +import { parseGitHubIssueOrPRLink } from '../../../../../../shared/github/repositories/links' import { getTaskPageGitHubDuplicateCandidates, validateTaskPageGitHubDuplicateTarget, type TaskPageGitHubCloseAction, buildTaskPageGitHubCloseUpdate, getTaskPageGitHubDuplicateTargetErrorMessage -} from '@/components/task-page/github/task-page-github-status-actions' +} from '@/components/task-page/github/issue-status/task-page-github-status-actions' import { translate } from '@/i18n/i18n' import { useTranslate } from '@/i18n/use-translate' import { callRuntimeRpc } from '@/runtime/rpc/calls/runtime-rpc-call' import { gitHubSlugApiForTarget } from '@/runtime/repository-github/github-slug-owner-api' -import { githubProjectHost } from '../../../../../shared/github/projects/project-identity' -import { TaskPageGitHubWorkItemStateBadge } from '@/components/task-page/github/task-page-github-work-item-status-badge' +import { githubProjectHost } from '../../../../../../shared/github/projects/project-identity' +import { TaskPageGitHubWorkItemStateBadge } from '@/components/task-page/github/listing/task-page-github-work-item-status-badge' import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover' import { cn } from '@/lib/class-names' import { CircleDot, ChevronDown, Copy, CheckCircle2, Ban, ChevronRight } from 'lucide-react' -import type { TaskPageGitHubWorkItemMutationRunner } from '../linear/task-page-linear-jira-list-model' +import type { TaskPageGitHubWorkItemMutationRunner } from '../../linear/task-page-linear-jira-list-model' import { TaskPageGitHubDuplicatePicker } from './DuplicatePicker' import { useGitHubDuplicateIssueCandidates } from '@/components/github/github-duplicate-issue-candidates' diff --git a/src/renderer/src/components/task-page/github/task-page-github-status-actions.ts b/src/renderer/src/components/task-page/github/issue-status/task-page-github-status-actions.ts similarity index 94% rename from src/renderer/src/components/task-page/github/task-page-github-status-actions.ts rename to src/renderer/src/components/task-page/github/issue-status/task-page-github-status-actions.ts index 01628da6fef..e565281a0c1 100644 --- a/src/renderer/src/components/task-page/github/task-page-github-status-actions.ts +++ b/src/renderer/src/components/task-page/github/issue-status/task-page-github-status-actions.ts @@ -1,8 +1,8 @@ -import type { GitHubWorkItem } from '../../../../../shared/github/work-items/work-item-types' +import type { GitHubWorkItem } from '../../../../../../shared/github/work-items/work-item-types' import type { GitHubIssueCloseReason, GitHubIssueUpdate -} from '../../../../../shared/issue-mutation-types' +} from '../../../../../../shared/issue-mutation-types' export type TaskPageGitHubCloseAction = | { stateReason: Exclude } diff --git a/src/renderer/src/components/task-page/github/task-page-github-status-state.ts b/src/renderer/src/components/task-page/github/issue-status/task-page-github-status-state.ts similarity index 91% rename from src/renderer/src/components/task-page/github/task-page-github-status-state.ts rename to src/renderer/src/components/task-page/github/issue-status/task-page-github-status-state.ts index 7ebd889cafd..d69ff9669e3 100644 --- a/src/renderer/src/components/task-page/github/task-page-github-status-state.ts +++ b/src/renderer/src/components/task-page/github/issue-status/task-page-github-status-state.ts @@ -1,4 +1,4 @@ -import type { GitHubWorkItem } from '../../../../../shared/github/work-items/work-item-types' +import type { GitHubWorkItem } from '../../../../../../shared/github/work-items/work-item-types' type GitHubStatusItem = Pick diff --git a/src/renderer/src/components/task-page/github/AssigneesCell.tsx b/src/renderer/src/components/task-page/github/listing/AssigneesCell.tsx similarity index 93% rename from src/renderer/src/components/task-page/github/AssigneesCell.tsx rename to src/renderer/src/components/task-page/github/listing/AssigneesCell.tsx index 6d923beb901..a573bc3441b 100644 --- a/src/renderer/src/components/task-page/github/AssigneesCell.tsx +++ b/src/renderer/src/components/task-page/github/listing/AssigneesCell.tsx @@ -1,22 +1,22 @@ -import type { GitHubWorkItem } from '../../../../../shared/github/work-items/work-item-types' -import type { GitHubAssignableUser } from '../../../../../shared/github/pull-requests/pull-request-types' -import type { Repo } from '../../../../../shared/repo-types' -import type { TaskSourceContext } from '../../../../../shared/work-items/task-source-context' +import type { GitHubWorkItem } from '../../../../../../shared/github/work-items/work-item-types' +import type { GitHubAssignableUser } from '../../../../../../shared/github/pull-requests/pull-request-types' +import type { Repo } from '../../../../../../shared/repo-types' +import type { TaskSourceContext } from '../../../../../../shared/work-items/task-source-context' import React, { useMemo, useState, useCallback } from 'react' import { useAppStore } from '@/store/index' import { useShallow } from 'zustand/react/shallow' import { getGitHubRepoRoutingTarget } from '@/lib/work-items/github/github-source-runtime-context' -import { parseGitHubIssueOrPRLink } from '../../../../../shared/github/repositories/links' +import { parseGitHubIssueOrPRLink } from '../../../../../../shared/github/repositories/links' import { useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata' import { translate } from '@/i18n/i18n' import { useTranslate } from '@/i18n/use-translate' import { callRuntimeRpc } from '@/runtime/rpc/calls/runtime-rpc-call' import { gitHubSlugApiForTarget } from '@/runtime/repository-github/github-slug-owner-api' -import { githubProjectHost } from '../../../../../shared/github/projects/project-identity' +import { githubProjectHost } from '../../../../../../shared/github/projects/project-identity' import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover' import { cn } from '@/lib/class-names' import { LoaderCircle, ChevronDown, Check } from 'lucide-react' -import type { TaskPageGitHubWorkItemMutationRunner } from '../linear/task-page-linear-jira-list-model' +import type { TaskPageGitHubWorkItemMutationRunner } from '../../linear/task-page-linear-jira-list-model' import { GitHubAssigneeAvatar } from './Avatars' import { GitHubUserAvatar } from '@/components/github/github-user-avatar' import { codeHostErrorText } from '@/lib/source-control/remote/code-host-error-copy' diff --git a/src/renderer/src/components/task-page/github/Avatars.tsx b/src/renderer/src/components/task-page/github/listing/Avatars.tsx similarity index 84% rename from src/renderer/src/components/task-page/github/Avatars.tsx rename to src/renderer/src/components/task-page/github/listing/Avatars.tsx index 6305ad6c57e..1719d7267fc 100644 --- a/src/renderer/src/components/task-page/github/Avatars.tsx +++ b/src/renderer/src/components/task-page/github/listing/Avatars.tsx @@ -1,8 +1,8 @@ -import type { GitHubPRPrimaryReviewer } from '../../../../../shared/github/pull-requests/pr-reviewer-display' +import type { GitHubPRPrimaryReviewer } from '../../../../../../shared/github/pull-requests/pr-reviewer-display' import React from 'react' import { GitHubUserAvatar } from '@/components/github/github-user-avatar' import { Users } from 'lucide-react' -import type { GitHubAssignableUser } from '../../../../../shared/github/pull-requests/pull-request-types' +import type { GitHubAssignableUser } from '../../../../../../shared/github/pull-requests/pull-request-types' export function ReviewChipAvatar({ reviewer, avatarHost diff --git a/src/renderer/src/components/task-page/github/Filters.tsx b/src/renderer/src/components/task-page/github/listing/Filters.tsx similarity index 99% rename from src/renderer/src/components/task-page/github/Filters.tsx rename to src/renderer/src/components/task-page/github/listing/Filters.tsx index a57878bb346..b64ff20fe0e 100644 --- a/src/renderer/src/components/task-page/github/Filters.tsx +++ b/src/renderer/src/components/task-page/github/listing/Filters.tsx @@ -1,4 +1,4 @@ -import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions' +import type { TaskPageComposerActionsModel } from '../../use-task-page-composer-actions' import { getGitHubPRStateChips, getGitHubPRWhoChips, @@ -15,7 +15,7 @@ import { resolveNewIssueOpenSeed } from '@/components/task-page/task-page-new-is import { useAppStore } from '@/store/index' import { useShallow } from 'zustand/react/shallow' import { getGitHubRepoRoutingTarget } from '@/lib/work-items/github/github-source-runtime-context' -import { hasUpstreamCandidateDivergence, hasDivergentSources } from '../task-page-draft-storage' +import { hasUpstreamCandidateDivergence, hasDivergentSources } from '../../task-page-draft-storage' import IssueSourceIndicator from '@/components/github/IssueSourceIndicator' import IssueSourceSelector, { issueSourceChipClass } from '@/components/github/IssueSourceSelector' import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' diff --git a/src/renderer/src/components/task-page/github/List.tsx b/src/renderer/src/components/task-page/github/listing/List.tsx similarity index 98% rename from src/renderer/src/components/task-page/github/List.tsx rename to src/renderer/src/components/task-page/github/listing/List.tsx index c90b4e4d7d3..42374fe3136 100644 --- a/src/renderer/src/components/task-page/github/List.tsx +++ b/src/renderer/src/components/task-page/github/listing/List.tsx @@ -1,4 +1,4 @@ -import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions' +import type { TaskPageComposerActionsModel } from '../../use-task-page-composer-actions' import { useAppStore } from '@/store/index' import { cn } from '@/lib/class-names' import { @@ -7,12 +7,12 @@ import { GITHUB_TASK_STICKY_TITLE_HEADER_CLASS, GITHUB_TASK_STICKY_ID_CELL_CLASS, GITHUB_TASK_STICKY_TITLE_CELL_CLASS -} from '../task-page-source-context' +} from '../../task-page-source-context' import { useTranslate } from '@/i18n/use-translate' import { Button } from '@/components/ui/button' import { LoaderCircle } from 'lucide-react' import { TaskPageGitHubRows } from './Rows' -import { PaginationBar } from '../PaginationBar' +import { PaginationBar } from '../../PaginationBar' import { supersedeGitHubListScrollRestore } from './task-page-github-list-scroll-restore' export function TaskPageGitHubList({ model diff --git a/src/renderer/src/components/task-page/github/ModeControls.tsx b/src/renderer/src/components/task-page/github/listing/ModeControls.tsx similarity index 98% rename from src/renderer/src/components/task-page/github/ModeControls.tsx rename to src/renderer/src/components/task-page/github/listing/ModeControls.tsx index 5b4d6e83a12..4fe5e971b50 100644 --- a/src/renderer/src/components/task-page/github/ModeControls.tsx +++ b/src/renderer/src/components/task-page/github/listing/ModeControls.tsx @@ -1,4 +1,4 @@ -import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions' +import type { TaskPageComposerActionsModel } from '../../use-task-page-composer-actions' import { cn } from '@/lib/class-names' import TaskProjectSourceCombobox from '@/components/task-project-source-combobox' import { normalizeTaskRepoSelection } from '@/components/task-page/task-page-default-repo-selection' diff --git a/src/renderer/src/components/task-page/github/Rows.tsx b/src/renderer/src/components/task-page/github/listing/Rows.tsx similarity index 96% rename from src/renderer/src/components/task-page/github/Rows.tsx rename to src/renderer/src/components/task-page/github/listing/Rows.tsx index 62de36cb12c..186a5297722 100644 --- a/src/renderer/src/components/task-page/github/Rows.tsx +++ b/src/renderer/src/components/task-page/github/listing/Rows.tsx @@ -1,13 +1,13 @@ -import { getTaskPageRepoSourceContext } from '../../../lib/work-items/task-repo-source-context' -import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions' +import { getTaskPageRepoSourceContext } from '../../../../lib/work-items/task-repo-source-context' +import type { TaskPageComposerActionsModel } from '../../use-task-page-composer-actions' import { getWorkItemRepo } from '@/lib/work-items/work-item-repo-row' import { findGithubWorkItemWorkspaceAttachment } from '@/lib/work-items/github/github-work-item-workspace-attachment' import { getWorktreeAttachmentLabel } from '@/lib/workspace/display/worktree-attachment-label' -import { formatPRDelta } from '../task-page-pr-delta-summary' +import { formatPRDelta } from '../../task-page-pr-delta-summary' import { isTaskPageGitHubDraftPR, getTaskPageGitHubPRIconTone -} from '@/components/task-page/github/task-page-github-work-item-status' +} from '@/components/task-page/github/listing/task-page-github-work-item-status' import { GitPullRequestDraft, GitPullRequest, @@ -25,16 +25,16 @@ import { GITHUB_TASK_STICKY_ID_CELL_CLASS, GITHUB_TASK_STICKY_TITLE_CELL_CLASS, formatRelativeTime -} from '../task-page-source-context' +} from '../../task-page-source-context' import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' import { useIntlLocale, useTranslate } from '@/i18n/use-translate' -import { TaskPageGitHubWorkItemStateBadge } from '@/components/task-page/github/task-page-github-work-item-status-badge' +import { TaskPageGitHubWorkItemStateBadge } from '@/components/task-page/github/listing/task-page-github-work-item-status-badge' import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' import { GHAssigneesCell } from './AssigneesCell' -import { PRReviewCell } from './ReviewCell' -import { PRChecksCell } from './ChecksCell' -import { PRMergeCell } from './MergeCell' -import { GHStatusCell } from './StatusCell' +import { PRReviewCell } from '../pull-requests/ReviewCell' +import { PRChecksCell } from '../pull-requests/ChecksCell' +import { PRMergeCell } from '../pull-requests/MergeCell' +import { GHStatusCell } from '../issue-status/StatusCell' import { DropdownMenu, DropdownMenuTrigger, diff --git a/src/renderer/src/components/task-page/github/task-page-github-list-scroll-restore.ts b/src/renderer/src/components/task-page/github/listing/task-page-github-list-scroll-restore.ts similarity index 100% rename from src/renderer/src/components/task-page/github/task-page-github-list-scroll-restore.ts rename to src/renderer/src/components/task-page/github/listing/task-page-github-list-scroll-restore.ts diff --git a/src/renderer/src/components/task-page/github/task-page-github-work-item-status-badge.tsx b/src/renderer/src/components/task-page/github/listing/task-page-github-work-item-status-badge.tsx similarity index 89% rename from src/renderer/src/components/task-page/github/task-page-github-work-item-status-badge.tsx rename to src/renderer/src/components/task-page/github/listing/task-page-github-work-item-status-badge.tsx index 5e0319ee08a..ca46349da1c 100644 --- a/src/renderer/src/components/task-page/github/task-page-github-work-item-status-badge.tsx +++ b/src/renderer/src/components/task-page/github/listing/task-page-github-work-item-status-badge.tsx @@ -4,7 +4,7 @@ import { getTaskPageGitHubWorkItemStateLabel, getTaskPageGitHubWorkItemStateTone, type GitHubWorkItemStatusItem -} from '@/components/task-page/github/task-page-github-work-item-status' +} from '@/components/task-page/github/listing/task-page-github-work-item-status' import { useTranslate } from '@/i18n/use-translate' export function TaskPageGitHubWorkItemStateBadge({ diff --git a/src/renderer/src/components/task-page/github/task-page-github-work-item-status.ts b/src/renderer/src/components/task-page/github/listing/task-page-github-work-item-status.ts similarity index 96% rename from src/renderer/src/components/task-page/github/task-page-github-work-item-status.ts rename to src/renderer/src/components/task-page/github/listing/task-page-github-work-item-status.ts index 139ad4c6410..0229ce7eebd 100644 --- a/src/renderer/src/components/task-page/github/task-page-github-work-item-status.ts +++ b/src/renderer/src/components/task-page/github/listing/task-page-github-work-item-status.ts @@ -1,6 +1,6 @@ import { translate } from '@/i18n/i18n' import type { Translate } from '@/i18n/use-translate' -import type { GitHubWorkItem } from '../../../../../shared/github/work-items/work-item-types' +import type { GitHubWorkItem } from '../../../../../../shared/github/work-items/work-item-types' export type GitHubWorkItemStatusItem = Pick diff --git a/src/renderer/src/components/task-page/github/task-page-pr-chips.test.ts b/src/renderer/src/components/task-page/github/listing/task-page-pr-chips.test.ts similarity index 100% rename from src/renderer/src/components/task-page/github/task-page-pr-chips.test.ts rename to src/renderer/src/components/task-page/github/listing/task-page-pr-chips.test.ts diff --git a/src/renderer/src/components/task-page/github/task-page-pr-chips.ts b/src/renderer/src/components/task-page/github/listing/task-page-pr-chips.ts similarity index 91% rename from src/renderer/src/components/task-page/github/task-page-pr-chips.ts rename to src/renderer/src/components/task-page/github/listing/task-page-pr-chips.ts index f5102a46bd6..650765e22ea 100644 --- a/src/renderer/src/components/task-page/github/task-page-pr-chips.ts +++ b/src/renderer/src/components/task-page/github/listing/task-page-pr-chips.ts @@ -3,10 +3,10 @@ import { parseTaskQuery, serializeTaskQuery, withQualifier -} from '../../../../../shared/work-items/task-query' -import type { ParsedTaskQuery } from '../../../../../shared/work-items/task-query' -import { getTaskPresetQuery } from '../../../../../shared/work-items/task-preset-query' -import type { TaskViewPresetId } from '../../../../../shared/ui-chrome-types' +} from '../../../../../../shared/work-items/task-query' +import type { ParsedTaskQuery } from '../../../../../../shared/work-items/task-query' +import { getTaskPresetQuery } from '../../../../../../shared/work-items/task-preset-query' +import type { TaskViewPresetId } from '../../../../../../shared/ui-chrome-types' export type GitHubPRStateChip = 'open' | 'merged' | 'closed' | 'all' export type GitHubPRWhoChip = 'everyone' | 'mine' | 'reviewing' diff --git a/src/renderer/src/components/task-page/github/task-page-preset-highlight.ts b/src/renderer/src/components/task-page/github/listing/task-page-preset-highlight.ts similarity index 71% rename from src/renderer/src/components/task-page/github/task-page-preset-highlight.ts rename to src/renderer/src/components/task-page/github/listing/task-page-preset-highlight.ts index 6fc9097da8d..2793368b7b7 100644 --- a/src/renderer/src/components/task-page/github/task-page-preset-highlight.ts +++ b/src/renderer/src/components/task-page/github/listing/task-page-preset-highlight.ts @@ -1,6 +1,6 @@ -import type { ParsedTaskQuery } from '../../../../../shared/work-items/task-query' -import type { GitHubTaskKind } from '../../../../../shared/work-items/task-github-search' -import type { TaskViewPresetId } from '../../../../../shared/ui-chrome-types' +import type { ParsedTaskQuery } from '../../../../../../shared/work-items/task-query' +import type { GitHubTaskKind } from '../../../../../../shared/work-items/task-github-search' +import type { TaskViewPresetId } from '../../../../../../shared/ui-chrome-types' export function deriveGitHubTaskPreset( kind: GitHubTaskKind, query: ParsedTaskQuery diff --git a/src/renderer/src/components/task-page/github/use-task-page-github-collection-state.ts b/src/renderer/src/components/task-page/github/listing/use-task-page-github-collection-state.ts similarity index 86% rename from src/renderer/src/components/task-page/github/use-task-page-github-collection-state.ts rename to src/renderer/src/components/task-page/github/listing/use-task-page-github-collection-state.ts index f2792452c49..ee3799e4a1c 100644 --- a/src/renderer/src/components/task-page/github/use-task-page-github-collection-state.ts +++ b/src/renderer/src/components/task-page/github/listing/use-task-page-github-collection-state.ts @@ -1,12 +1,12 @@ import { useState, useCallback, useMemo, useSyncExternalStore, type SetStateAction } from 'react' -import type { TaskPageProviderStateModel } from '../use-task-page-provider-state' -import type { GitHubWorkItem } from '../../../../../shared/github/work-items/work-item-types' -import { sortWorkItemsByNumber } from '../../../../../shared/work-items/work-item-order' -import { stripRepoQualifiers } from '../../../../../shared/work-items/task-query' +import type { TaskPageProviderStateModel } from '../../use-task-page-provider-state' +import type { GitHubWorkItem } from '../../../../../../shared/github/work-items/work-item-types' +import { sortWorkItemsByNumber } from '../../../../../../shared/work-items/work-item-order' +import { stripRepoQualifiers } from '../../../../../../shared/work-items/task-query' import type { GitHubSourceCollections } from '@/store/github/sources/source-collections' import { githubCollectionScope } from '@/store/github/sources/source-preference-ownership' -import { getTaskPageRepoSourceContext } from '../task-page-source-context' -import { buildTaskPageGitHubResumeContextKey } from './task-page-github-resume-cache' +import { getTaskPageRepoSourceContext } from '../../../../lib/work-items/task-repo-source-context' +import { buildTaskPageGitHubResumeContextKey } from '../cache/task-page-github-resume-cache' type CollectionInput = Pick< TaskPageProviderStateModel, diff --git a/src/renderer/src/components/task-page/github/use-task-page-github-list-pending.ts b/src/renderer/src/components/task-page/github/listing/use-task-page-github-list-pending.ts similarity index 95% rename from src/renderer/src/components/task-page/github/use-task-page-github-list-pending.ts rename to src/renderer/src/components/task-page/github/listing/use-task-page-github-list-pending.ts index 3636d205d42..60c4063fb2e 100644 --- a/src/renderer/src/components/task-page/github/use-task-page-github-list-pending.ts +++ b/src/renderer/src/components/task-page/github/listing/use-task-page-github-list-pending.ts @@ -1,5 +1,5 @@ import { useAppStore } from '@/store/index' -import type { TaskPageJiraCreationMetadataModel } from '../jira/use-task-page-jira-creation-metadata' +import type { TaskPageJiraCreationMetadataModel } from '../../jira/use-task-page-jira-creation-metadata' // Why the list projection's input model: the landing run's later-stage model carries these same fields. type LandingRequestInput = Pick< diff --git a/src/renderer/src/components/task-page/github/use-task-page-github-list-projection.ts b/src/renderer/src/components/task-page/github/listing/use-task-page-github-list-projection.ts similarity index 90% rename from src/renderer/src/components/task-page/github/use-task-page-github-list-projection.ts rename to src/renderer/src/components/task-page/github/listing/use-task-page-github-list-projection.ts index 1945976b170..a45c6318b84 100644 --- a/src/renderer/src/components/task-page/github/use-task-page-github-list-projection.ts +++ b/src/renderer/src/components/task-page/github/listing/use-task-page-github-list-projection.ts @@ -1,12 +1,12 @@ import { useCallback, useMemo } from 'react' import { useGitHubTaskItems } from '@/hooks/useGitHubTaskItems' -import type { TaskPageJiraCreationMetadataModel } from '../jira/use-task-page-jira-creation-metadata' -import type { GitHubWorkItem } from '../../../../../shared/github/work-items/work-item-types' -import { taskPageGitHubItemKey } from '@/components/task-page/github/task-page-github-work-item-mutation-keys' -import { deriveAdvertisedTotalPages } from '../../../../../shared/work-items/work-item-empty-page' -import { GITHUB_PR_TASK_GRID_CLASS, GITHUB_TASK_GRID_CLASS } from '../task-page-source-context' +import type { TaskPageJiraCreationMetadataModel } from '../../jira/use-task-page-jira-creation-metadata' +import type { GitHubWorkItem } from '../../../../../../shared/github/work-items/work-item-types' +import { taskPageGitHubItemKey } from '@/components/task-page/github/mutations/task-page-github-work-item-mutation-keys' +import { deriveAdvertisedTotalPages } from '../../../../../../shared/work-items/work-item-empty-page' +import { GITHUB_PR_TASK_GRID_CLASS, GITHUB_TASK_GRID_CLASS } from '../../task-page-source-context' import { useTaskPageGitHubListPending } from './use-task-page-github-list-pending' -import { useTaskPagePRCheckHydration } from './use-task-page-pr-check-hydration' +import { useTaskPagePRCheckHydration } from '../pull-requests/use-task-page-pr-check-hydration' export function useTaskPageGitHubListProjection(model: TaskPageJiraCreationMetadataModel) { const { tasksFiltering, diff --git a/src/renderer/src/components/task-page/github/use-task-page-github-list-state.ts b/src/renderer/src/components/task-page/github/listing/use-task-page-github-list-state.ts similarity index 97% rename from src/renderer/src/components/task-page/github/use-task-page-github-list-state.ts rename to src/renderer/src/components/task-page/github/listing/use-task-page-github-list-state.ts index c014170602d..e443699a30a 100644 --- a/src/renderer/src/components/task-page/github/use-task-page-github-list-state.ts +++ b/src/renderer/src/components/task-page/github/listing/use-task-page-github-list-state.ts @@ -1,13 +1,13 @@ import { useTaskPageGitHubCollectionState } from './use-task-page-github-collection-state' -import type { TaskPageProviderStateModel } from '../use-task-page-provider-state' +import type { TaskPageProviderStateModel } from '../../use-task-page-provider-state' import { useState, useRef, useEffect, useLayoutEffect, useSyncExternalStore } from 'react' -import type { TaskViewPresetId } from '../../../../../shared/ui-chrome-types' +import type { TaskViewPresetId } from '../../../../../../shared/ui-chrome-types' import { PER_REPO_FETCH_LIMIT, CROSS_REPO_DISPLAY_LIMIT, getTaskPagePerRepoLimit -} from '../../../../../shared/work-items/work-item-pagination' -import { taskPageGitHubResumeCache } from '@/components/task-page/github/task-page-github-resume-cache' +} from '../../../../../../shared/work-items/work-item-pagination' +import { taskPageGitHubResumeCache } from '@/components/task-page/github/cache/task-page-github-resume-cache' import { githubTaskBinding } from '@/lib/work-items/github/github-task-observation' import { useAppStore } from '@/store/index' import type { GitHubListRestoreWrite } from './task-page-github-list-scroll-restore' diff --git a/src/renderer/src/components/task-page/github/use-task-page-github-search-pagination.ts b/src/renderer/src/components/task-page/github/listing/use-task-page-github-search-pagination.ts similarity index 93% rename from src/renderer/src/components/task-page/github/use-task-page-github-search-pagination.ts rename to src/renderer/src/components/task-page/github/listing/use-task-page-github-search-pagination.ts index c122de5c9e1..6b51e915d29 100644 --- a/src/renderer/src/components/task-page/github/use-task-page-github-search-pagination.ts +++ b/src/renderer/src/components/task-page/github/listing/use-task-page-github-search-pagination.ts @@ -1,18 +1,18 @@ import type { TaskPageGitHubListProjectionModel } from './use-task-page-github-list-projection' import { useCallback, useEffect } from 'react' -import { stripRepoQualifiers } from '../../../../../shared/work-items/task-query' -import { taskPageToGitHubApiPage } from '../../../../../shared/work-items/work-item-pagination' +import { stripRepoQualifiers } from '../../../../../../shared/work-items/task-query' +import { taskPageToGitHubApiPage } from '../../../../../../shared/work-items/work-item-pagination' import { resolveEmptyPageOutcome, applyEmptyPageClamp, applyWindowPageLimit -} from '../../../../../shared/work-items/work-item-empty-page' +} from '../../../../../../shared/work-items/work-item-empty-page' import { toast } from 'sonner' import { translate } from '@/i18n/i18n' -import { overlayPendingOnTaskPagePages } from '@/components/task-page/github/task-page-github-work-item-mutation-pages' -import { scopeGitHubTaskSearch } from '../../../../../shared/work-items/task-github-search' +import { overlayPendingOnTaskPagePages } from '@/components/task-page/github/mutations/task-page-github-work-item-mutation-pages' +import { scopeGitHubTaskSearch } from '../../../../../../shared/work-items/task-github-search' import { useGitHubTaskSearchCommit } from '@/components/use-github-task-search-commit' -import { getTaskPageRepoSourceContext } from '../../../lib/work-items/task-repo-source-context' +import { getTaskPageRepoSourceContext } from '../../../../lib/work-items/task-repo-source-context' export function useTaskPageGitHubSearchPagination(model: TaskPageGitHubListProjectionModel) { const { setTaskResumeState, diff --git a/src/renderer/src/components/task-page/github/task-page-github-confirmed-client-values.ts b/src/renderer/src/components/task-page/github/mutations/task-page-github-confirmed-client-values.ts similarity index 90% rename from src/renderer/src/components/task-page/github/task-page-github-confirmed-client-values.ts rename to src/renderer/src/components/task-page/github/mutations/task-page-github-confirmed-client-values.ts index 074ecc96171..7c93f208259 100644 --- a/src/renderer/src/components/task-page/github/task-page-github-confirmed-client-values.ts +++ b/src/renderer/src/components/task-page/github/mutations/task-page-github-confirmed-client-values.ts @@ -1,5 +1,5 @@ -import type { GitHubRepositoryIdentity as GitHubOwnerRepo } from '../../../../../shared/github/repositories/repository-identity' -import { githubRepoIdentityKey } from '../../../../../shared/github/repositories/repository-identity-key' +import type { GitHubRepositoryIdentity as GitHubOwnerRepo } from '../../../../../../shared/github/repositories/repository-identity' +import { githubRepoIdentityKey } from '../../../../../../shared/github/repositories/repository-identity-key' import { taskPageGitHubLastConfirmedKey } from './task-page-github-work-item-mutation-keys' const lastConfirmedClientValues = new Map< diff --git a/src/renderer/src/components/task-page/github/task-page-github-dialog-state-authority.ts b/src/renderer/src/components/task-page/github/mutations/task-page-github-dialog-state-authority.ts similarity index 88% rename from src/renderer/src/components/task-page/github/task-page-github-dialog-state-authority.ts rename to src/renderer/src/components/task-page/github/mutations/task-page-github-dialog-state-authority.ts index 3761ec4101d..66a23c18c64 100644 --- a/src/renderer/src/components/task-page/github/task-page-github-dialog-state-authority.ts +++ b/src/renderer/src/components/task-page/github/mutations/task-page-github-dialog-state-authority.ts @@ -1,9 +1,9 @@ -import type { GitHubWorkItem } from '../../../../../shared/github/work-items/work-item-types' -import type { GitHubRepositoryIdentity as GitHubOwnerRepo } from '../../../../../shared/github/repositories/repository-identity' +import type { GitHubWorkItem } from '../../../../../../shared/github/work-items/work-item-types' +import type { GitHubRepositoryIdentity as GitHubOwnerRepo } from '../../../../../../shared/github/repositories/repository-identity' import { getTaskSourceCacheScope, type TaskSourceContext -} from '../../../../../shared/work-items/task-source-context' +} from '../../../../../../shared/work-items/task-source-context' import { deleteLastConfirmedClientValue, getLastConfirmedClientValue @@ -13,7 +13,7 @@ import { notifyTaskPageGitHubMutationRegistry, setLastConfirmedClientValue } from './task-page-github-work-item-mutation-registry' -import { markTaskPageGitHubFamiliesDirty } from './task-page-github-work-item-quiet-state' +import { markTaskPageGitHubFamiliesDirty } from '../refresh/task-page-github-work-item-quiet-state' import { taskPageGitHubItemKey } from './task-page-github-work-item-mutation-keys' function markStateFamilyDirty(repoId: string, itemId: string): void { diff --git a/src/renderer/src/components/task-page/github/task-page-github-work-item-filter-membership.ts b/src/renderer/src/components/task-page/github/mutations/task-page-github-work-item-filter-membership.ts similarity index 91% rename from src/renderer/src/components/task-page/github/task-page-github-work-item-filter-membership.ts rename to src/renderer/src/components/task-page/github/mutations/task-page-github-work-item-filter-membership.ts index a234297feed..da4522b0415 100644 --- a/src/renderer/src/components/task-page/github/task-page-github-work-item-filter-membership.ts +++ b/src/renderer/src/components/task-page/github/mutations/task-page-github-work-item-filter-membership.ts @@ -1,6 +1,6 @@ -import type { ParsedTaskQuery } from '../../../../../shared/work-items/task-query' -import type { GitHubAssignableUser } from '../../../../../shared/github/pull-requests/pull-request-types' -import type { GitHubWorkItem } from '../../../../../shared/github/work-items/work-item-types' +import type { ParsedTaskQuery } from '../../../../../../shared/work-items/task-query' +import type { GitHubAssignableUser } from '../../../../../../shared/github/pull-requests/pull-request-types' +import type { GitHubWorkItem } from '../../../../../../shared/github/work-items/work-item-types' import type { StickyHideEntry } from './task-page-github-work-item-registry-types' function includesLogin(users: readonly GitHubAssignableUser[] | undefined, login: string): boolean { diff --git a/src/renderer/src/components/task-page/github/task-page-github-work-item-mutation-composition.ts b/src/renderer/src/components/task-page/github/mutations/task-page-github-work-item-mutation-composition.ts similarity index 94% rename from src/renderer/src/components/task-page/github/task-page-github-work-item-mutation-composition.ts rename to src/renderer/src/components/task-page/github/mutations/task-page-github-work-item-mutation-composition.ts index d35a96e0bc9..5dc0be181cc 100644 --- a/src/renderer/src/components/task-page/github/task-page-github-work-item-mutation-composition.ts +++ b/src/renderer/src/components/task-page/github/mutations/task-page-github-work-item-mutation-composition.ts @@ -1,8 +1,8 @@ -import { projectPRCheckOwner } from '../../../../../shared/github/checks/complete-pr-check-owner' -import type { ParsedTaskQuery } from '../../../../../shared/work-items/task-query' -import type { GitHubAssignableUser } from '../../../../../shared/github/pull-requests/pull-request-types' -import type { GitHubWorkItem } from '../../../../../shared/github/work-items/work-item-types' -import { parseGitHubIssueOrPRLink } from '../../../../../shared/github/repositories/links' +import { projectPRCheckOwner } from '../../../../../../shared/github/checks/complete-pr-check-owner' +import type { ParsedTaskQuery } from '../../../../../../shared/work-items/task-query' +import type { GitHubAssignableUser } from '../../../../../../shared/github/pull-requests/pull-request-types' +import type { GitHubWorkItem } from '../../../../../../shared/github/work-items/work-item-types' +import { parseGitHubIssueOrPRLink } from '../../../../../../shared/github/repositories/links' import { recomputeTaskPageGitHubItemSoftHide, shouldSoftHideTaskPageGitHubWorkItem diff --git a/src/renderer/src/components/task-page/github/task-page-github-work-item-mutation-keys.ts b/src/renderer/src/components/task-page/github/mutations/task-page-github-work-item-mutation-keys.ts similarity index 100% rename from src/renderer/src/components/task-page/github/task-page-github-work-item-mutation-keys.ts rename to src/renderer/src/components/task-page/github/mutations/task-page-github-work-item-mutation-keys.ts diff --git a/src/renderer/src/components/task-page/github/task-page-github-work-item-mutation-lifecycle.ts b/src/renderer/src/components/task-page/github/mutations/task-page-github-work-item-mutation-lifecycle.ts similarity index 95% rename from src/renderer/src/components/task-page/github/task-page-github-work-item-mutation-lifecycle.ts rename to src/renderer/src/components/task-page/github/mutations/task-page-github-work-item-mutation-lifecycle.ts index 97342909657..199227b5411 100644 --- a/src/renderer/src/components/task-page/github/task-page-github-work-item-mutation-lifecycle.ts +++ b/src/renderer/src/components/task-page/github/mutations/task-page-github-work-item-mutation-lifecycle.ts @@ -1,6 +1,6 @@ -import type { ParsedTaskQuery } from '../../../../../shared/work-items/task-query' -import type { GitHubWorkItem } from '../../../../../shared/github/work-items/work-item-types' -import type { TaskSourceContext } from '../../../../../shared/work-items/task-source-context' +import type { ParsedTaskQuery } from '../../../../../../shared/work-items/task-query' +import type { GitHubWorkItem } from '../../../../../../shared/github/work-items/work-item-types' +import type { TaskSourceContext } from '../../../../../../shared/work-items/task-source-context' import { applyTaskPageGitHubListOps } from './task-page-github-work-item-mutation-patches' import { familiesFromPendingOp, @@ -19,7 +19,7 @@ import { setLastConfirmedClientValue, isTaskPageGitHubMutationQueryKeyCurrent } from './task-page-github-work-item-mutation-registry' -import { markTaskPageGitHubFamiliesDirty } from './task-page-github-work-item-quiet-state' +import { markTaskPageGitHubFamiliesDirty } from '../refresh/task-page-github-work-item-quiet-state' import { taskPageGitHubItemKey } from './task-page-github-work-item-mutation-keys' import type { TaskPageGitHubMutationKey } from './task-page-github-work-item-registry-types' import type { TaskPageGitHubPatchWorkItem } from './task-page-github-work-item-mutation-types' diff --git a/src/renderer/src/components/task-page/github/task-page-github-work-item-mutation-pages.ts b/src/renderer/src/components/task-page/github/mutations/task-page-github-work-item-mutation-pages.ts similarity index 93% rename from src/renderer/src/components/task-page/github/task-page-github-work-item-mutation-pages.ts rename to src/renderer/src/components/task-page/github/mutations/task-page-github-work-item-mutation-pages.ts index 9e30d7a143b..37da8932dfe 100644 --- a/src/renderer/src/components/task-page/github/task-page-github-work-item-mutation-pages.ts +++ b/src/renderer/src/components/task-page/github/mutations/task-page-github-work-item-mutation-pages.ts @@ -1,7 +1,7 @@ -import { projectPRCheckOwner } from '../../../../../shared/github/checks/complete-pr-check-owner' -import type { GitHubWorkItem } from '../../../../../shared/github/work-items/work-item-types' -import type { TaskSourceContext } from '../../../../../shared/work-items/task-source-context' -import { parseGitHubIssueOrPRLink } from '../../../../../shared/github/repositories/links' +import { projectPRCheckOwner } from '../../../../../../shared/github/checks/complete-pr-check-owner' +import type { GitHubWorkItem } from '../../../../../../shared/github/work-items/work-item-types' +import type { TaskSourceContext } from '../../../../../../shared/work-items/task-source-context' +import { parseGitHubIssueOrPRLink } from '../../../../../../shared/github/repositories/links' import { getRegistryMergedTaskPageGitHubWorkItem } from './task-page-github-work-item-mutation-composition' import { getStickyHideEntry, diff --git a/src/renderer/src/components/task-page/github/task-page-github-work-item-mutation-patches.ts b/src/renderer/src/components/task-page/github/mutations/task-page-github-work-item-mutation-patches.ts similarity index 95% rename from src/renderer/src/components/task-page/github/task-page-github-work-item-mutation-patches.ts rename to src/renderer/src/components/task-page/github/mutations/task-page-github-work-item-mutation-patches.ts index d3a1659df87..b2ef2397e98 100644 --- a/src/renderer/src/components/task-page/github/task-page-github-work-item-mutation-patches.ts +++ b/src/renderer/src/components/task-page/github/mutations/task-page-github-work-item-mutation-patches.ts @@ -1,6 +1,6 @@ -import type { GitHubAssignableUser } from '../../../../../shared/github/pull-requests/pull-request-types' -import type { GitHubWorkItem } from '../../../../../shared/github/work-items/work-item-types' -import type { TaskPageGitHubCloseAction } from './task-page-github-status-actions' +import type { GitHubAssignableUser } from '../../../../../../shared/github/pull-requests/pull-request-types' +import type { GitHubWorkItem } from '../../../../../../shared/github/work-items/work-item-types' +import type { TaskPageGitHubCloseAction } from '../issue-status/task-page-github-status-actions' import { taskPageGitHubListOpKey } from './task-page-github-work-item-mutation-keys' import type { PendingListOp, diff --git a/src/renderer/src/components/task-page/github/task-page-github-work-item-mutation-registry.ts b/src/renderer/src/components/task-page/github/mutations/task-page-github-work-item-mutation-registry.ts similarity index 93% rename from src/renderer/src/components/task-page/github/task-page-github-work-item-mutation-registry.ts rename to src/renderer/src/components/task-page/github/mutations/task-page-github-work-item-mutation-registry.ts index 41472fa4635..7fbad1b3089 100644 --- a/src/renderer/src/components/task-page/github/task-page-github-work-item-mutation-registry.ts +++ b/src/renderer/src/components/task-page/github/mutations/task-page-github-work-item-mutation-registry.ts @@ -4,8 +4,8 @@ import { getLastConfirmedClientValue, setLastConfirmedClientValue as storeLastConfirmedClientValue } from './task-page-github-confirmed-client-values' -import type { GitHubAssignableUser } from '../../../../../shared/github/pull-requests/pull-request-types' -import type { GitHubRepositoryIdentity as GitHubOwnerRepo } from '../../../../../shared/github/repositories/repository-identity' +import type { GitHubAssignableUser } from '../../../../../../shared/github/pull-requests/pull-request-types' +import type { GitHubRepositoryIdentity as GitHubOwnerRepo } from '../../../../../../shared/github/repositories/repository-identity' import type { PendingOp, StickyHideEntry, @@ -17,7 +17,7 @@ import { taskPageGitHubItemKey, taskPageGitHubSnapshotKey } from './task-page-github-work-item-mutation-keys' -import { clearTaskPageGitHubQuietStates } from './task-page-github-work-item-quiet-state' +import { clearTaskPageGitHubQuietStates } from '../refresh/task-page-github-work-item-quiet-state' type Listener = () => void const listeners = new Set() const pendingByKey = new Map() @@ -257,15 +257,3 @@ export function gcStickyHidesAbsentFromPages( } } } -export function resetTaskPageGitHubMutationRegistryForTests(): void { - pendingByKey.clear() - generations.clear() - confirmedSnapshots.clear() - clearLastConfirmedClientValues() - itemSourceScopeByItemKey.clear() - stickyHideByItemKey.clear() - softHiddenItemKeys.clear() - clearTaskPageGitHubQuietStates() - mutationQueryKey = null - listeners.clear() -} diff --git a/src/renderer/src/components/task-page/github/task-page-github-work-item-mutation-types.ts b/src/renderer/src/components/task-page/github/mutations/task-page-github-work-item-mutation-types.ts similarity index 71% rename from src/renderer/src/components/task-page/github/task-page-github-work-item-mutation-types.ts rename to src/renderer/src/components/task-page/github/mutations/task-page-github-work-item-mutation-types.ts index 0d59ae7b9c4..e09ec3e4cb8 100644 --- a/src/renderer/src/components/task-page/github/task-page-github-work-item-mutation-types.ts +++ b/src/renderer/src/components/task-page/github/mutations/task-page-github-work-item-mutation-types.ts @@ -1,7 +1,7 @@ -import type { ParsedTaskQuery } from '../../../../../shared/work-items/task-query' -import type { GitHubWorkItem } from '../../../../../shared/github/work-items/work-item-types' -import type { TaskSourceContext } from '../../../../../shared/work-items/task-source-context' -import type { GitHubPatchWorkItemOptions } from '../../../store/github/work-items/work-item-patch-options' +import type { ParsedTaskQuery } from '../../../../../../shared/work-items/task-query' +import type { GitHubWorkItem } from '../../../../../../shared/github/work-items/work-item-types' +import type { TaskSourceContext } from '../../../../../../shared/work-items/task-source-context' +import type { GitHubPatchWorkItemOptions } from '../../../../store/github/work-items/work-item-patch-options' import type { TaskPageGitHubMutationIntent } from './task-page-github-work-item-mutation-patches' import type { TaskPageGitHubMutationKey } from './task-page-github-work-item-registry-types' diff --git a/src/renderer/src/components/task-page/github/task-page-github-work-item-mutations.ts b/src/renderer/src/components/task-page/github/mutations/task-page-github-work-item-mutations.ts similarity index 88% rename from src/renderer/src/components/task-page/github/task-page-github-work-item-mutations.ts rename to src/renderer/src/components/task-page/github/mutations/task-page-github-work-item-mutations.ts index ff4f5ec992b..01f552912cc 100644 --- a/src/renderer/src/components/task-page/github/task-page-github-work-item-mutations.ts +++ b/src/renderer/src/components/task-page/github/mutations/task-page-github-work-item-mutations.ts @@ -1,8 +1,8 @@ -import type { GitHubWorkItem } from '../../../../../shared/github/work-items/work-item-types' +import type { GitHubWorkItem } from '../../../../../../shared/github/work-items/work-item-types' import { getTaskSourceCacheScope, type TaskSourceContext -} from '../../../../../shared/work-items/task-source-context' +} from '../../../../../../shared/work-items/task-source-context' import { buildTaskPageGitHubWorkItemMutationPatch, type TaskPageGitHubMutationIntent @@ -11,13 +11,11 @@ import { familiesFromPendingOp, getRegistryMergedTaskPageGitHubWorkItem, recomputeSoftHideForItem, - rebuildSoftHiddenKeysFromPendingAndSticky, stripFamilyPendingFromList } from './task-page-github-work-item-mutation-composition' import { getConfirmedListSnapshot, getPendingTaskPageGitHubOp, - getStickyHideEntry, listPendingTaskPageGitHubOpsForItem, nextTaskPageGitHubMutationGeneration, notifyTaskPageGitHubMutationRegistry, @@ -34,7 +32,6 @@ import type { BeginTaskPageGitHubWorkItemMutationArgs, BeginTaskPageGitHubWorkItemMutationResult } from './task-page-github-work-item-mutation-types' -import type { ParsedTaskQuery } from '../../../../../shared/work-items/task-query' function resolveSourceScope(sourceContext?: TaskSourceContext | null): string | null { if (sourceContext?.provider === 'github') { @@ -163,16 +160,3 @@ export function beginTaskPageGitHubWorkItemMutation( export function isTaskPageGitHubMutationPendingKey(key: TaskPageGitHubMutationKey): boolean { return getPendingTaskPageGitHubOp(key) !== undefined } - -export function getTaskPageGitHubStickyHideForTests(itemKey: string) { - return getStickyHideEntry(itemKey) -} - -export function rebuildSoftHiddenFromItemsForTests(args: { - query: ParsedTaskQuery - queryKey: string - viewerLogin: string | null - items: readonly GitHubWorkItem[] -}): void { - rebuildSoftHiddenKeysFromPendingAndSticky(args) -} diff --git a/src/renderer/src/components/task-page/github/task-page-github-work-item-registry-types.ts b/src/renderer/src/components/task-page/github/mutations/task-page-github-work-item-registry-types.ts similarity index 78% rename from src/renderer/src/components/task-page/github/task-page-github-work-item-registry-types.ts rename to src/renderer/src/components/task-page/github/mutations/task-page-github-work-item-registry-types.ts index 26cbb52c7a7..292caf9ffda 100644 --- a/src/renderer/src/components/task-page/github/task-page-github-work-item-registry-types.ts +++ b/src/renderer/src/components/task-page/github/mutations/task-page-github-work-item-registry-types.ts @@ -1,5 +1,5 @@ -import type { GitHubAssignableUser } from '../../../../../shared/github/pull-requests/pull-request-types' -import type { GitHubWorkItem } from '../../../../../shared/github/work-items/work-item-types' +import type { GitHubAssignableUser } from '../../../../../../shared/github/pull-requests/pull-request-types' +import type { GitHubWorkItem } from '../../../../../../shared/github/work-items/work-item-types' export type TaskPageGitHubListFamily = 'assignees' | 'reviewRequests' export type TaskPageGitHubMutationKey = { diff --git a/src/renderer/src/components/task-page/github/use-task-page-github-mutation-state.ts b/src/renderer/src/components/task-page/github/mutations/use-task-page-github-mutation-state.ts similarity index 87% rename from src/renderer/src/components/task-page/github/use-task-page-github-mutation-state.ts rename to src/renderer/src/components/task-page/github/mutations/use-task-page-github-mutation-state.ts index d4310cf6cad..cd0a1f6e7a8 100644 --- a/src/renderer/src/components/task-page/github/use-task-page-github-mutation-state.ts +++ b/src/renderer/src/components/task-page/github/mutations/use-task-page-github-mutation-state.ts @@ -1,18 +1,18 @@ -import type { TaskPageLinearCreationStateModel } from '../linear/use-task-page-linear-creation-state' -import { getGitHubTaskKind } from '../../../../../shared/work-items/task-github-search' +import type { TaskPageLinearCreationStateModel } from '../../linear/use-task-page-linear-creation-state' +import { getGitHubTaskKind } from '../../../../../../shared/work-items/task-github-search' import { useMemo, useLayoutEffect, useEffect, useCallback, useRef } from 'react' -import { parseTaskQuery } from '../../../../../shared/work-items/task-query' +import { parseTaskQuery } from '../../../../../../shared/work-items/task-query' import { setTaskPageGitHubMutationQueryKey, getTaskPageGitHubConfirmedAuthorityItemKeys -} from '@/components/task-page/github/task-page-github-work-item-mutation-registry' -import { getOrCreateQuietRevalidateState } from '@/components/task-page/github/task-page-github-work-item-quiet-state' -import type { TaskPageGitHubPatchWorkItem } from '@/components/task-page/github/task-page-github-work-item-mutation-types' +} from '@/components/task-page/github/mutations/task-page-github-work-item-mutation-registry' +import { getOrCreateQuietRevalidateState } from '@/components/task-page/github/refresh/task-page-github-work-item-quiet-state' +import type { TaskPageGitHubPatchWorkItem } from '@/components/task-page/github/mutations/task-page-github-work-item-mutation-types' import { useAppStore } from '@/store/index' import { useTaskPageGitHubWorkItemMutation } from '@/hooks/useTaskPageGitHubWorkItemMutation' -import { rebuildSoftHiddenKeysFromPendingAndSticky } from '@/components/task-page/github/task-page-github-work-item-mutation-composition' -import { getTaskPageRepoSourceContext } from '../../../lib/work-items/task-repo-source-context' -import { useTaskPageSourceDisplay } from '../use-task-page-source-display' +import { rebuildSoftHiddenKeysFromPendingAndSticky } from '@/components/task-page/github/mutations/task-page-github-work-item-mutation-composition' +import { getTaskPageRepoSourceContext } from '../../../../lib/work-items/task-repo-source-context' +import { useTaskPageSourceDisplay } from '../../use-task-page-source-display' export function useTaskPageGitHubMutationState(model: TaskPageLinearCreationStateModel) { const { selectedRepos, diff --git a/src/renderer/src/components/task-page/github/pr-check-hydration.test.tsx b/src/renderer/src/components/task-page/github/pr-check-hydration.test.tsx deleted file mode 100644 index 7bc7faeedbb..00000000000 --- a/src/renderer/src/components/task-page/github/pr-check-hydration.test.tsx +++ /dev/null @@ -1,195 +0,0 @@ -import { Suspense, startTransition, useState } from 'react' -// @vitest-environment happy-dom -import { act, cleanup, render, renderHook, screen } from '@testing-library/react' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import type { GitHubWorkItem } from '../../../../../shared/github/work-items/work-item-types' -import type { TaskPageJiraCreationMetadataModel } from '../jira/use-task-page-jira-creation-metadata' -import { GITHUB_COMPLETE_PR_CHECK_OBSERVATION_CAPABILITY as capability } from '../../../../../shared/source-control/runtime/source-control-runtime-capabilities' -import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/hosts/capabilities/local-runtime-capabilities' -import { useTaskPageGitHubListProjection } from './use-task-page-github-list-projection' - -const { lookup } = vi.hoisted(() => ({ lookup: vi.fn() })) -vi.mock('@/store', async () => { - const { create } = await import('zustand') - return { useAppStore: create(() => ({ runtimeStatusByEnvironmentId: new Map() })) } -}) -vi.mock('@/hooks/useGitHubTaskItems', () => ({ - useGitHubTaskItems: (items: GitHubWorkItem[]) => items -})) -vi.mock('./use-task-page-github-list-pending', () => ({ - useTaskPageGitHubListPending: () => false -})) -vi.mock('@/lib/work-items/github/github-work-item-source-lookup', () => ({ - lookupGitHubWorkItemByOwnerRepoForSource: lookup -})) -const repo = { - id: 'repo-1', - path: '/repo', - displayName: 'Repo', - badgeColor: '', - addedAt: 1, - kind: 'git' as const -} -const prRepo = { owner: 'acme', repo: 'widgets', host: 'github.com' } -function item(number: number): GitHubWorkItem { - return { - id: `pr-${number}`, - repoId: repo.id, - number, - type: 'pr', - title: 'PR', - state: 'open', - url: `https://github.com/acme/widgets/pull/${number}`, - labels: [], - author: null, - updatedAt: '2026-11-04', - headSha: 'head', - prRepo - } -} -function model(items: GitHubWorkItem[], patch = vi.fn()) { - const input = { - repos: [repo], - selectedRepos: [repo], - fetchPRChecks: vi.fn().mockResolvedValue([]), - taskSource: 'github', - githubMode: 'items', - tasksFiltering: false, - githubPageSize: 100, - pages: [items], - currentPage: 0, - countedTotalPages: 1, - provenPageLimit: 1, - patchTaskPageWorkItemRows: patch, - perRepoSourceState: [], - activeGithubTaskKind: 'prs', - githubWorkItemMutation: { softHiddenItemKeys: new Set() }, - selectedReposKey: 'repo', - taskRefreshNonce: 0, - workItemsInvalidationNonce: 0, - appliedTaskSearch: '', - paginationGenerationRef: { current: 1 } - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture supplies all fields the projection hook reads. - return input as unknown as TaskPageJiraCreationMetadataModel -} -beforeEach(() => { - lookup.mockReset() - setLocalRuntimeCapabilitiesForTests([capability]) -}) -afterEach(() => { - cleanup() - setLocalRuntimeCapabilitiesForTests(null) -}) - -it('publishes exact-owner counts, rejects a newer owner replacement, and retires source changes', async () => { - const row = item(42) - let current = row - const patch = vi.fn((_key, values, accepts: (item: GitHubWorkItem) => boolean) => { - if (accepts(current)) { - current = { ...current, ...values } - } - }) - const input = model([row], patch) - const read = Promise.withResolvers() - lookup.mockReturnValue(read.promise) - const view = renderHook(() => useTaskPageGitHubListProjection(input)) - const summary = { - state: 'failure' as const, - total: 101, - passed: 100, - failed: 1, - cancelled: 0, - pending: 0, - neutral: 0 - } - await act(async () => { - read.resolve({ ...row, checksSummary: summary }) - await read.promise - }) - expect(current.checksSummary).toEqual(summary) - // A late hydration read cannot replace a newer full observation on the same head. - input.taskRefreshNonce++ - input.paginationGenerationRef.current++ - const second = Promise.withResolvers() - lookup.mockReturnValue(second.promise) - view.rerender() - current = { ...row, checksSummary: { ...summary, failed: 2, total: 102 } } - await act(async () => { - second.resolve({ ...row, checksSummary: summary }) - await second.promise - }) - expect(current.checksSummary?.failed).toBe(2) - const third = Promise.withResolvers() - lookup.mockReturnValue(third.promise) - input.taskRefreshNonce++ - view.rerender() - view.unmount() - const previousCalls = patch.mock.calls.length - await act(async () => { - third.resolve({ ...row, checksSummary: summary }) - await third.promise - }) - expect(patch.mock.calls.length).toBe(previousCalls) -}) - -it.each([true, false])( - 'only a committed query replacement retires a pending read (suspend=%s)', - async (suspend) => { - const row = item(42) - let current = row - const patch = vi.fn((_key, values, accepts: (item: GitHubWorkItem) => boolean) => { - if (accepts(current)) { - current = { ...current, ...values } - } - }) - const original = Promise.withResolvers() - const replacement = Promise.withResolvers() - const blocked = Promise.withResolvers() - const summary = { - state: 'failure' as const, - total: 1, - passed: 0, - failed: 1, - cancelled: 0, - pending: 0, - neutral: 0 - } - lookup.mockReturnValueOnce(original.promise).mockReturnValueOnce(replacement.promise) - const first = model([row], patch) - const second = { ...first, appliedTaskSearch: 'replacement' } - let navigate!: (next: boolean) => void - function Consumer({ next }: { next: boolean }) { - useTaskPageGitHubListProjection(next ? second : first) - if (next && suspend) { - throw blocked.promise - } - return {next ? 'Replacement query' : 'Original query'} - } - function Harness() { - const [next, setNext] = useState(false) - navigate = setNext - return ( - Waiting}> - - - ) - } - const view = render() - await act(async () => { - startTransition(() => navigate(true)) - }) - expect(screen.getByText(suspend ? 'Original query' : 'Replacement query')).toBeTruthy() - await act(async () => { - original.resolve({ ...row, checksSummary: summary }) - await original.promise - }) - expect(current.checksSummary).toEqual(suspend ? summary : undefined) - view.unmount() - await act(async () => { - replacement.resolve({ ...row, checksSummary: summary }) - blocked.resolve() - await replacement.promise - }) - } -) diff --git a/src/renderer/src/components/task-page/github/ChecksCell.tsx b/src/renderer/src/components/task-page/github/pull-requests/ChecksCell.tsx similarity index 89% rename from src/renderer/src/components/task-page/github/ChecksCell.tsx rename to src/renderer/src/components/task-page/github/pull-requests/ChecksCell.tsx index 86b3b6cdb83..5e970953788 100644 --- a/src/renderer/src/components/task-page/github/ChecksCell.tsx +++ b/src/renderer/src/components/task-page/github/pull-requests/ChecksCell.tsx @@ -1,11 +1,11 @@ -import { getProviderChecksOutcome } from '../../../../../shared/source-control/checks/provider-check-summary' -import type { GitHubWorkItem } from '../../../../../shared/github/work-items/work-item-types' +import { getProviderChecksOutcome } from '../../../../../../shared/source-control/checks/provider-check-summary' +import type { GitHubWorkItem } from '../../../../../../shared/github/work-items/work-item-types' import React, { useRef, useEffect } from 'react' import { useTranslate } from '@/i18n/use-translate' import { CheckCircle2, AlertCircle, Clock3, Minus } from 'lucide-react' import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' import { cn } from '@/lib/class-names' -import { getChecksPillTone, getChecksLabel } from '../task-page-checks-pill' +import { getChecksPillTone, getChecksLabel } from '../../task-page-checks-pill' export function PRChecksCell({ item, onOpen, diff --git a/src/renderer/src/components/task-page/github/MergeCell.tsx b/src/renderer/src/components/task-page/github/pull-requests/MergeCell.tsx similarity index 93% rename from src/renderer/src/components/task-page/github/MergeCell.tsx rename to src/renderer/src/components/task-page/github/pull-requests/MergeCell.tsx index 90adc647649..66d3830ecff 100644 --- a/src/renderer/src/components/task-page/github/MergeCell.tsx +++ b/src/renderer/src/components/task-page/github/pull-requests/MergeCell.tsx @@ -1,7 +1,7 @@ -import type { GitHubWorkItem } from '../../../../../shared/github/work-items/work-item-types' -import type { GitHubPRMergeMethod } from '../../../../../shared/github/pull-requests/pull-request-types' -import type { Repo } from '../../../../../shared/repo-types' -import type { TaskSourceContext } from '../../../../../shared/work-items/task-source-context' +import type { GitHubWorkItem } from '../../../../../../shared/github/work-items/work-item-types' +import type { GitHubPRMergeMethod } from '../../../../../../shared/github/pull-requests/pull-request-types' +import type { Repo } from '../../../../../../shared/repo-types' +import type { TaskSourceContext } from '../../../../../../shared/work-items/task-source-context' import React, { useState } from 'react' import { useConfirmationDialog } from '@/components/confirmation-dialog-context' import { useAppStore } from '@/store/index' @@ -13,7 +13,7 @@ import { getPullRequestMergeMethodLabel } from '@/components/github/pull-request-confirmation-copy' import { presentGitHubPRMergeState } from '@/components/github-pr-merge-state' -import { resolveGitHubPRMergeMethods } from '../../../../../shared/github/pull-requests/merge/pull-request-merge-methods' +import { resolveGitHubPRMergeMethods } from '../../../../../../shared/github/pull-requests/merge/pull-request-merge-methods' import { callRuntimeRpc } from '@/runtime/rpc/calls/runtime-rpc-call' import { DropdownMenu, @@ -25,7 +25,7 @@ import { import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' import { cn } from '@/lib/class-names' import { LoaderCircle, GitMerge, ChevronDown, ExternalLink } from 'lucide-react' -import type { TaskPageGitHubWorkItemMutationRunner } from '../linear/task-page-linear-jira-list-model' +import type { TaskPageGitHubWorkItemMutationRunner } from '../../linear/task-page-linear-jira-list-model' import { resolveTaskPullRequestRepo } from './task-page-github-review-model' import { openAppHttpLink } from '@/lib/browser/navigation/app-http-link-open' export function PRMergeCell({ diff --git a/src/renderer/src/components/task-page/github/ReviewCell.tsx b/src/renderer/src/components/task-page/github/pull-requests/ReviewCell.tsx similarity index 95% rename from src/renderer/src/components/task-page/github/ReviewCell.tsx rename to src/renderer/src/components/task-page/github/pull-requests/ReviewCell.tsx index 210f99b7647..175ce368376 100644 --- a/src/renderer/src/components/task-page/github/ReviewCell.tsx +++ b/src/renderer/src/components/task-page/github/pull-requests/ReviewCell.tsx @@ -1,7 +1,7 @@ -import type { GitHubWorkItem } from '../../../../../shared/github/work-items/work-item-types' -import type { GitHubAssignableUser } from '../../../../../shared/github/pull-requests/pull-request-types' -import type { Repo } from '../../../../../shared/repo-types' -import type { TaskSourceContext } from '../../../../../shared/work-items/task-source-context' +import type { GitHubWorkItem } from '../../../../../../shared/github/work-items/work-item-types' +import type { GitHubAssignableUser } from '../../../../../../shared/github/pull-requests/pull-request-types' +import type { Repo } from '../../../../../../shared/repo-types' +import type { TaskSourceContext } from '../../../../../../shared/work-items/task-source-context' import React, { useState, useMemo, useRef, useCallback } from 'react' import { useAppStore } from '@/store/index' import { useShallow } from 'zustand/react/shallow' @@ -16,16 +16,16 @@ import { getGitHubPRPrimaryReviewer, getGitHubPRReviewerRows, getGitHubPRReviewLabel -} from '../../../../../shared/github/pull-requests/pr-reviewer-display' +} from '../../../../../../shared/github/pull-requests/pr-reviewer-display' import { cn } from '@/lib/class-names' import { ChevronDown } from 'lucide-react' import { Popover, PopoverTrigger } from '@/components/ui/popover' -import type { TaskPageGitHubWorkItemMutationRunner } from '../linear/task-page-linear-jira-list-model' +import type { TaskPageGitHubWorkItemMutationRunner } from '../../linear/task-page-linear-jira-list-model' import { mergeReviewerSuggestions, resolveTaskPullRequestRepo } from './task-page-github-review-model' -import { ReviewChipAvatar } from './Avatars' +import { ReviewChipAvatar } from '../listing/Avatars' import { createTaskPageGitHubReviewerActions } from './task-page-github-reviewer-actions' import { TaskPageGitHubReviewerPicker } from './ReviewerPicker' export function PRReviewCell({ diff --git a/src/renderer/src/components/task-page/github/ReviewerPicker.tsx b/src/renderer/src/components/task-page/github/pull-requests/ReviewerPicker.tsx similarity index 97% rename from src/renderer/src/components/task-page/github/ReviewerPicker.tsx rename to src/renderer/src/components/task-page/github/pull-requests/ReviewerPicker.tsx index 1517f28a104..fd256c94948 100644 --- a/src/renderer/src/components/task-page/github/ReviewerPicker.tsx +++ b/src/renderer/src/components/task-page/github/pull-requests/ReviewerPicker.tsx @@ -6,8 +6,8 @@ import { cn } from '@/lib/class-names' import { Input } from '@/components/ui/input' import { PopoverContent } from '@/components/ui/popover' -import type { GitHubAssignableUser } from '../../../../../shared/github/pull-requests/pull-request-types' -import type { Repo } from '../../../../../shared/repo-types' +import type { GitHubAssignableUser } from '../../../../../../shared/github/pull-requests/pull-request-types' +import type { Repo } from '../../../../../../shared/repo-types' import { GitHubUserAvatar } from '@/components/github/github-user-avatar' type ReviewerPickerProps = { diff --git a/src/renderer/src/components/task-page/github/task-page-github-review-model.tsx b/src/renderer/src/components/task-page/github/pull-requests/task-page-github-review-model.tsx similarity index 83% rename from src/renderer/src/components/task-page/github/task-page-github-review-model.tsx rename to src/renderer/src/components/task-page/github/pull-requests/task-page-github-review-model.tsx index 1b86933b814..7c5b8851f06 100644 --- a/src/renderer/src/components/task-page/github/task-page-github-review-model.tsx +++ b/src/renderer/src/components/task-page/github/pull-requests/task-page-github-review-model.tsx @@ -1,9 +1,9 @@ -import type { GitHubRepositoryIdentity as GitHubOwnerRepo } from '../../../../../shared/github/repositories/repository-identity' -import type { GitHubAssignableUser } from '../../../../../shared/github/pull-requests/pull-request-types' -import type { GitHubWorkItem } from '../../../../../shared/github/work-items/work-item-types' +import type { GitHubRepositoryIdentity as GitHubOwnerRepo } from '../../../../../../shared/github/repositories/repository-identity' +import type { GitHubAssignableUser } from '../../../../../../shared/github/pull-requests/pull-request-types' +import type { GitHubWorkItem } from '../../../../../../shared/github/work-items/work-item-types' import { sameGitHubOwnerRepo } from '@/components/github/IssueSourceIndicator' -import { parseGitHubIssueOrPRLink } from '../../../../../shared/github/repositories/links' -import { githubProjectHost } from '../../../../../shared/github/projects/project-identity' +import { parseGitHubIssueOrPRLink } from '../../../../../../shared/github/repositories/links' +import { githubProjectHost } from '../../../../../../shared/github/projects/project-identity' export function sameOptionalGitHubOwnerRepo( left: GitHubOwnerRepo | null | undefined, right: GitHubOwnerRepo | null | undefined diff --git a/src/renderer/src/components/task-page/github/task-page-github-reviewer-actions.ts b/src/renderer/src/components/task-page/github/pull-requests/task-page-github-reviewer-actions.ts similarity index 90% rename from src/renderer/src/components/task-page/github/task-page-github-reviewer-actions.ts rename to src/renderer/src/components/task-page/github/pull-requests/task-page-github-reviewer-actions.ts index 7a12f17a108..46bf6f0376c 100644 --- a/src/renderer/src/components/task-page/github/task-page-github-reviewer-actions.ts +++ b/src/renderer/src/components/task-page/github/pull-requests/task-page-github-reviewer-actions.ts @@ -2,20 +2,20 @@ import type { Dispatch, SetStateAction } from 'react' import { translate } from '@/i18n/i18n' import { callRuntimeRpc } from '@/runtime/rpc/calls/runtime-rpc-call' -import type { RuntimeClientTarget } from '../../../../../shared/runtime-client-target' -import type { TaskPageGitHubMutationIntent } from '@/components/task-page/github/task-page-github-work-item-mutation-patches' +import type { RuntimeClientTarget } from '../../../../../../shared/runtime-client-target' +import type { TaskPageGitHubMutationIntent } from '@/components/task-page/github/mutations/task-page-github-work-item-mutation-patches' import { normalizeGitHubReviewerLogins, parseGitHubReviewerInputLogins -} from '../../../../../shared/github/pull-requests/pr-reviewer-display' +} from '../../../../../../shared/github/pull-requests/pr-reviewer-display' import { toast } from 'sonner' -import type { GitHubAssignableUser } from '../../../../../shared/github/pull-requests/pull-request-types' -import type { GitHubRepositoryIdentity as GitHubOwnerRepo } from '../../../../../shared/github/repositories/repository-identity' -import type { GitHubWorkItem } from '../../../../../shared/github/work-items/work-item-types' -import type { Repo } from '../../../../../shared/repo-types' -import type { TaskSourceContext } from '../../../../../shared/work-items/task-source-context' -import type { TaskPageGitHubWorkItemMutationRunner } from '../linear/task-page-linear-jira-list-model' +import type { GitHubAssignableUser } from '../../../../../../shared/github/pull-requests/pull-request-types' +import type { GitHubRepositoryIdentity as GitHubOwnerRepo } from '../../../../../../shared/github/repositories/repository-identity' +import type { GitHubWorkItem } from '../../../../../../shared/github/work-items/work-item-types' +import type { Repo } from '../../../../../../shared/repo-types' +import type { TaskSourceContext } from '../../../../../../shared/work-items/task-source-context' +import type { TaskPageGitHubWorkItemMutationRunner } from '../../linear/task-page-linear-jira-list-model' import { buildRequestedReviewUsers } from './task-page-github-review-model' type ReviewerActionsInput = { diff --git a/src/renderer/src/components/task-page/github/use-task-page-pr-check-hydration.ts b/src/renderer/src/components/task-page/github/pull-requests/use-task-page-pr-check-hydration.ts similarity index 93% rename from src/renderer/src/components/task-page/github/use-task-page-pr-check-hydration.ts rename to src/renderer/src/components/task-page/github/pull-requests/use-task-page-pr-check-hydration.ts index 5db2fe96aac..ce313a40bb7 100644 --- a/src/renderer/src/components/task-page/github/use-task-page-pr-check-hydration.ts +++ b/src/renderer/src/components/task-page/github/pull-requests/use-task-page-pr-check-hydration.ts @@ -10,7 +10,7 @@ import { readLocalRuntimeCapabilityObservation, subscribeLocalRuntimeCapabilityObservation } from '@/runtime/hosts/capabilities/local-runtime-capabilities' -import { useAppStore } from '@/store' +import { useAppStore } from '@/store/index' import { capturePRCheckContract, type PRCheckContract @@ -22,13 +22,13 @@ import { acceptPRCheckObservation, projectPRCheckOwner, completePRCheckOwnerScope -} from '../../../../../shared/github/checks/complete-pr-check-owner' +} from '../../../../../../shared/github/checks/complete-pr-check-owner' import { getWorkItemRepo } from '@/lib/work-items/work-item-repo-row' -import type { GitHubWorkItem } from '../../../../../shared/github/work-items/work-item-types' -import type { TaskPageJiraCreationMetadataModel } from '../jira/use-task-page-jira-creation-metadata' -import { deriveTaskPagePRCheckSummary } from '../task-page-pr-check-summary' -import { PR_CHECKS_EAGER_PREFETCH_LIMIT } from '../task-page-source-context' -import { getTaskPageRepoSourceContext } from '../../../lib/work-items/task-repo-source-context' +import type { GitHubWorkItem } from '../../../../../../shared/github/work-items/work-item-types' +import type { TaskPageJiraCreationMetadataModel } from '../../jira/use-task-page-jira-creation-metadata' +import { deriveTaskPagePRCheckSummary } from '../../task-page-pr-check-summary' +import { PR_CHECKS_EAGER_PREFETCH_LIMIT } from '../../task-page-source-context' +import { getTaskPageRepoSourceContext } from '../../../../lib/work-items/task-repo-source-context' import { sameOptionalGitHubOwnerRepo } from './task-page-github-review-model' import { recomputeOn } from '@/lib/recompute-on' export function useTaskPagePRCheckHydration( diff --git a/src/renderer/src/components/task-page/github/task-page-github-landing-refresh-model.ts b/src/renderer/src/components/task-page/github/refresh/task-page-github-landing-refresh-model.ts similarity index 92% rename from src/renderer/src/components/task-page/github/task-page-github-landing-refresh-model.ts rename to src/renderer/src/components/task-page/github/refresh/task-page-github-landing-refresh-model.ts index 2bc528e952a..8c184e8af62 100644 --- a/src/renderer/src/components/task-page/github/task-page-github-landing-refresh-model.ts +++ b/src/renderer/src/components/task-page/github/refresh/task-page-github-landing-refresh-model.ts @@ -1,4 +1,4 @@ -import type { TaskPageGitHubSearchPaginationModel } from './use-task-page-github-search-pagination' +import type { TaskPageGitHubSearchPaginationModel } from '../listing/use-task-page-github-search-pagination' /** The parts of the Tasks page model a landing refresh reads and drives. */ export type TaskPageGitHubLandingRefreshModel = Pick< diff --git a/src/renderer/src/components/task-page/github/task-page-github-landing-refresh-run.tsx b/src/renderer/src/components/task-page/github/refresh/task-page-github-landing-refresh-run.tsx similarity index 93% rename from src/renderer/src/components/task-page/github/task-page-github-landing-refresh-run.tsx rename to src/renderer/src/components/task-page/github/refresh/task-page-github-landing-refresh-run.tsx index 4119be279bf..00fa8240564 100644 --- a/src/renderer/src/components/task-page/github/task-page-github-landing-refresh-run.tsx +++ b/src/renderer/src/components/task-page/github/refresh/task-page-github-landing-refresh-run.tsx @@ -1,22 +1,22 @@ import type { TaskPageGitHubLandingRefreshModel } from './task-page-github-landing-refresh-model' -import { stripRepoQualifiers } from '../../../../../shared/work-items/task-query' +import { stripRepoQualifiers } from '../../../../../../shared/work-items/task-query' import { useAppStore } from '@/store/index' import { taskPageGitHubResumeCache, TASK_PAGE_GITHUB_RESUME_FRESH_MS -} from '@/components/task-page/github/task-page-github-resume-cache' -import type { GitHubWorkItem } from '../../../../../shared/github/work-items/work-item-types' -import { getTaskPageRepoSourceContext } from '../../../lib/work-items/task-repo-source-context' -import { sortWorkItemsByNumber } from '../../../../../shared/work-items/work-item-order' -import { taskPageToGitHubApiPage } from '../../../../../shared/work-items/work-item-pagination' +} from '@/components/task-page/github/cache/task-page-github-resume-cache' +import type { GitHubWorkItem } from '../../../../../../shared/github/work-items/work-item-types' +import { getTaskPageRepoSourceContext } from '../../../../lib/work-items/task-repo-source-context' +import { sortWorkItemsByNumber } from '../../../../../../shared/work-items/work-item-order' +import { taskPageToGitHubApiPage } from '../../../../../../shared/work-items/work-item-pagination' import { materializeTaskPageItemList, overlayPendingOnTaskPagePages, reapplyPendingTaskPageGitHubMutationsToCache -} from '@/components/task-page/github/task-page-github-work-item-mutation-pages' -import { clearTaskPageGitHubAuthorityThroughGeneration } from '@/components/task-page/github/task-page-github-work-item-authority-refresh' -import { getOrCreateQuietRevalidateState } from '@/components/task-page/github/task-page-github-work-item-quiet-state' -import { resolveEmptyPageOutcome } from '../../../../../shared/work-items/work-item-empty-page' +} from '@/components/task-page/github/mutations/task-page-github-work-item-mutation-pages' +import { clearTaskPageGitHubAuthorityThroughGeneration } from '@/components/task-page/github/refresh/task-page-github-work-item-authority-refresh' +import { getOrCreateQuietRevalidateState } from '@/components/task-page/github/refresh/task-page-github-work-item-quiet-state' +import { resolveEmptyPageOutcome } from '../../../../../../shared/work-items/work-item-empty-page' import { deriveTaskPageGitHubWorkItemsFetchOptions, shouldReplaceTaskPageItemsAfterRefresh, @@ -24,7 +24,7 @@ import { reconcileTaskPagePagesAfterLandingRefresh } from '@/components/task-page/task-page-cache-selectors' import { translate } from '@/i18n/i18n' -import { taskPageGitHubLandingRequestKey } from './use-task-page-github-list-pending' +import { taskPageGitHubLandingRequestKey } from '../listing/use-task-page-github-list-pending' export function runTaskPageGitHubLandingRefresh(model: TaskPageGitHubLandingRefreshModel) { const { fetchWorkItemsAcrossRepos, diff --git a/src/renderer/src/components/task-page/github/task-page-github-quiet-refresh-run.tsx b/src/renderer/src/components/task-page/github/refresh/task-page-github-quiet-refresh-run.tsx similarity index 92% rename from src/renderer/src/components/task-page/github/task-page-github-quiet-refresh-run.tsx rename to src/renderer/src/components/task-page/github/refresh/task-page-github-quiet-refresh-run.tsx index afd6b0e815b..18065a15548 100644 --- a/src/renderer/src/components/task-page/github/task-page-github-quiet-refresh-run.tsx +++ b/src/renderer/src/components/task-page/github/refresh/task-page-github-quiet-refresh-run.tsx @@ -3,32 +3,32 @@ import { getOrCreateQuietRevalidateState, beginTaskPageQuietRevalidateRun, finishTaskPageQuietRevalidateRun -} from '@/components/task-page/github/task-page-github-work-item-quiet-state' -import { taskPageGitHubItemKey } from '@/components/task-page/github/task-page-github-work-item-mutation-keys' -import { getTaskPageGitHubConfirmedAuthorityItemKeys } from '@/components/task-page/github/task-page-github-work-item-mutation-registry' +} from '@/components/task-page/github/refresh/task-page-github-work-item-quiet-state' +import { taskPageGitHubItemKey } from '@/components/task-page/github/mutations/task-page-github-work-item-mutation-keys' +import { getTaskPageGitHubConfirmedAuthorityItemKeys } from '@/components/task-page/github/mutations/task-page-github-work-item-mutation-registry' import { clearTaskPageGitHubAuthorityAbsentFromLoadedItems, getTaskPageGitHubRevalidatableAuthorityItemKeys -} from '@/components/task-page/github/task-page-github-work-item-authority-refresh' +} from '@/components/task-page/github/refresh/task-page-github-work-item-authority-refresh' import { isTaskPageQuietRevalidateScopeCurrent, isTaskPageQuietRevalidateRunCurrent, processTaskPageQuietRevalidateSettle, getTaskPageQuietRevalidateBackoffAttempt -} from '@/components/task-page/github/task-page-github-work-item-quiet-revalidate' +} from '@/components/task-page/github/refresh/task-page-github-work-item-quiet-revalidate' import { reapplyPendingTaskPageGitHubMutationsToCache, reconcileTaskPagePagesAfterQuietRefresh -} from '@/components/task-page/github/task-page-github-work-item-mutation-pages' +} from '@/components/task-page/github/mutations/task-page-github-work-item-mutation-pages' import { LAG_WALL_BUDGET_MS, MAX_LAG_TRAILS, LAG_BACKOFF_MS -} from '@/components/task-page/github/task-page-github-work-item-quiet-adopt' -import { stripRepoQualifiers } from '../../../../../shared/work-items/task-query' -import { getTaskPageRepoSourceContext } from '../../../lib/work-items/task-repo-source-context' -import type { GitHubWorkItem } from '../../../../../shared/github/work-items/work-item-types' -import { taskPageToGitHubApiPage } from '../../../../../shared/work-items/work-item-pagination' +} from '@/components/task-page/github/refresh/task-page-github-work-item-quiet-adopt' +import { stripRepoQualifiers } from '../../../../../../shared/work-items/task-query' +import { getTaskPageRepoSourceContext } from '../../../../lib/work-items/task-repo-source-context' +import type { GitHubWorkItem } from '../../../../../../shared/github/work-items/work-item-types' +import { taskPageToGitHubApiPage } from '../../../../../../shared/work-items/work-item-pagination' import { useAppStore } from '@/store/index' export function runTaskPageGitHubQuietRefresh(model: TaskPageGitHubQuietRefreshPreludeModel) { const { diff --git a/src/renderer/src/components/task-page/github/task-page-github-work-item-authority-refresh.ts b/src/renderer/src/components/task-page/github/refresh/task-page-github-work-item-authority-refresh.ts similarity index 94% rename from src/renderer/src/components/task-page/github/task-page-github-work-item-authority-refresh.ts rename to src/renderer/src/components/task-page/github/refresh/task-page-github-work-item-authority-refresh.ts index fcde70a7fe9..24223196de1 100644 --- a/src/renderer/src/components/task-page/github/task-page-github-work-item-authority-refresh.ts +++ b/src/renderer/src/components/task-page/github/refresh/task-page-github-work-item-authority-refresh.ts @@ -1,4 +1,4 @@ -import { taskPageGitHubFamilyDirtyKey } from './task-page-github-work-item-mutation-keys' +import { taskPageGitHubFamilyDirtyKey } from '../mutations/task-page-github-work-item-mutation-keys' import { clearConfirmedAuthorityForItem, deleteConfirmedListSnapshot, @@ -11,11 +11,11 @@ import { notifyTaskPageGitHubMutationRegistry, resolveItemSourceScope, updateSoftHiddenItemKey -} from './task-page-github-work-item-mutation-registry' +} from '../mutations/task-page-github-work-item-mutation-registry' import { deleteLastConfirmedClientValue, getLastConfirmedClientValue -} from './task-page-github-confirmed-client-values' +} from '../mutations/task-page-github-confirmed-client-values' import { getQuietRevalidateState } from './task-page-github-work-item-quiet-state' import { MAX_LAG_TRAILS } from './task-page-github-work-item-quiet-adopt' diff --git a/src/renderer/src/components/task-page/github/task-page-github-work-item-quiet-adopt.ts b/src/renderer/src/components/task-page/github/refresh/task-page-github-work-item-quiet-adopt.ts similarity index 87% rename from src/renderer/src/components/task-page/github/task-page-github-work-item-quiet-adopt.ts rename to src/renderer/src/components/task-page/github/refresh/task-page-github-work-item-quiet-adopt.ts index 418beba587f..8b21d57f6eb 100644 --- a/src/renderer/src/components/task-page/github/task-page-github-work-item-quiet-adopt.ts +++ b/src/renderer/src/components/task-page/github/refresh/task-page-github-work-item-quiet-adopt.ts @@ -1,26 +1,29 @@ -import type { GitHubWorkItem } from '../../../../../shared/github/work-items/work-item-types' -import type { TaskSourceContext } from '../../../../../shared/work-items/task-source-context' -import { parseGitHubIssueOrPRLink } from '../../../../../shared/github/repositories/links' -import { loginSetOfUsers, loginSetsEqual } from './task-page-github-work-item-mutation-patches' +import type { GitHubWorkItem } from '../../../../../../shared/github/work-items/work-item-types' +import type { TaskSourceContext } from '../../../../../../shared/work-items/task-source-context' +import { parseGitHubIssueOrPRLink } from '../../../../../../shared/github/repositories/links' +import { + loginSetOfUsers, + loginSetsEqual +} from '../mutations/task-page-github-work-item-mutation-patches' import { familiesFromPendingOp, freezeTaskPageGitHubUsers -} from './task-page-github-work-item-mutation-composition' +} from '../mutations/task-page-github-work-item-mutation-composition' import { getConfirmedListSnapshot, listPendingTaskPageGitHubOpsForItem, deleteConfirmedListSnapshot -} from './task-page-github-work-item-mutation-registry' +} from '../mutations/task-page-github-work-item-mutation-registry' import { getLastConfirmedClientValue, deleteLastConfirmedClientValue -} from './task-page-github-confirmed-client-values' +} from '../mutations/task-page-github-confirmed-client-values' import { getOrCreateQuietRevalidateState } from './task-page-github-work-item-quiet-state' import { taskPageGitHubFamilyDirtyKey, taskPageGitHubItemKey -} from './task-page-github-work-item-mutation-keys' -import type { TaskPageGitHubPatchWorkItem } from './task-page-github-work-item-mutation-types' +} from '../mutations/task-page-github-work-item-mutation-keys' +import type { TaskPageGitHubPatchWorkItem } from '../mutations/task-page-github-work-item-mutation-types' export const MAX_LAG_TRAILS = 5 export const LAG_WALL_BUDGET_MS = 90_000 diff --git a/src/renderer/src/components/task-page/github/task-page-github-work-item-quiet-revalidate.ts b/src/renderer/src/components/task-page/github/refresh/task-page-github-work-item-quiet-revalidate.ts similarity index 92% rename from src/renderer/src/components/task-page/github/task-page-github-work-item-quiet-revalidate.ts rename to src/renderer/src/components/task-page/github/refresh/task-page-github-work-item-quiet-revalidate.ts index b25c4d16daf..23896a8a183 100644 --- a/src/renderer/src/components/task-page/github/task-page-github-work-item-quiet-revalidate.ts +++ b/src/renderer/src/components/task-page/github/refresh/task-page-github-work-item-quiet-revalidate.ts @@ -1,5 +1,5 @@ -import type { GitHubWorkItem } from '../../../../../shared/github/work-items/work-item-types' -import type { TaskSourceContext } from '../../../../../shared/work-items/task-source-context' +import type { GitHubWorkItem } from '../../../../../../shared/github/work-items/work-item-types' +import type { TaskSourceContext } from '../../../../../../shared/work-items/task-source-context' import { adoptQuietSearchFieldsForItem, MAX_LAG_TRAILS @@ -12,10 +12,10 @@ import { hasPendingTaskPageGitHubOpsForItem, notifyTaskPageGitHubMutationRegistry, resolveItemSourceScope -} from './task-page-github-work-item-mutation-registry' +} from '../mutations/task-page-github-work-item-mutation-registry' import { getOrCreateQuietRevalidateState } from './task-page-github-work-item-quiet-state' -import { taskPageGitHubItemKey } from './task-page-github-work-item-mutation-keys' -import type { TaskPageGitHubPatchWorkItem } from './task-page-github-work-item-mutation-types' +import { taskPageGitHubItemKey } from '../mutations/task-page-github-work-item-mutation-keys' +import type { TaskPageGitHubPatchWorkItem } from '../mutations/task-page-github-work-item-mutation-types' export type TaskPageQuietRevalidateScope = { queryKey: string; generation: number } diff --git a/src/renderer/src/components/task-page/github/task-page-github-work-item-quiet-state.ts b/src/renderer/src/components/task-page/github/refresh/task-page-github-work-item-quiet-state.ts similarity index 96% rename from src/renderer/src/components/task-page/github/task-page-github-work-item-quiet-state.ts rename to src/renderer/src/components/task-page/github/refresh/task-page-github-work-item-quiet-state.ts index 2d9891020b5..49c9fed0347 100644 --- a/src/renderer/src/components/task-page/github/task-page-github-work-item-quiet-state.ts +++ b/src/renderer/src/components/task-page/github/refresh/task-page-github-work-item-quiet-state.ts @@ -1,4 +1,4 @@ -import { taskPageGitHubFamilyDirtyKey } from './task-page-github-work-item-mutation-keys' +import { taskPageGitHubFamilyDirtyKey } from '../mutations/task-page-github-work-item-mutation-keys' /** Identity token for the caller driving one quiet run; compared by reference, never read. */ export type QuietRevalidateRunOwner = Record diff --git a/src/renderer/src/components/task-page/github/use-task-page-github-landing-refresh.ts b/src/renderer/src/components/task-page/github/refresh/use-task-page-github-landing-refresh.ts similarity index 90% rename from src/renderer/src/components/task-page/github/use-task-page-github-landing-refresh.ts rename to src/renderer/src/components/task-page/github/refresh/use-task-page-github-landing-refresh.ts index ea8c4cbffd4..238bd8ec870 100644 --- a/src/renderer/src/components/task-page/github/use-task-page-github-landing-refresh.ts +++ b/src/renderer/src/components/task-page/github/refresh/use-task-page-github-landing-refresh.ts @@ -1,4 +1,4 @@ -import type { TaskPageGitHubSearchPaginationModel } from './use-task-page-github-search-pagination' +import type { TaskPageGitHubSearchPaginationModel } from '../listing/use-task-page-github-search-pagination' import { useEffect } from 'react' import { runTaskPageGitHubLandingRefresh } from './task-page-github-landing-refresh-run' export function useTaskPageGitHubLandingRefresh(model: TaskPageGitHubSearchPaginationModel) { diff --git a/src/renderer/src/components/task-page/github/use-task-page-github-quiet-refresh-effect.ts b/src/renderer/src/components/task-page/github/refresh/use-task-page-github-quiet-refresh-effect.ts similarity index 100% rename from src/renderer/src/components/task-page/github/use-task-page-github-quiet-refresh-effect.ts rename to src/renderer/src/components/task-page/github/refresh/use-task-page-github-quiet-refresh-effect.ts diff --git a/src/renderer/src/components/task-page/github/use-task-page-github-quiet-refresh.ts b/src/renderer/src/components/task-page/github/refresh/use-task-page-github-quiet-refresh.ts similarity index 94% rename from src/renderer/src/components/task-page/github/use-task-page-github-quiet-refresh.ts rename to src/renderer/src/components/task-page/github/refresh/use-task-page-github-quiet-refresh.ts index 732e11642e5..d9ae71b01f4 100644 --- a/src/renderer/src/components/task-page/github/use-task-page-github-quiet-refresh.ts +++ b/src/renderer/src/components/task-page/github/refresh/use-task-page-github-quiet-refresh.ts @@ -1,8 +1,8 @@ import type { TaskPageGitHubLandingRefreshModel } from './use-task-page-github-landing-refresh' import { useMountedRef } from '@/hooks/useMountedRef' import { useRef } from 'react' -import type { QuietRevalidateRunOwner } from '@/components/task-page/github/task-page-github-work-item-quiet-state' -import { advanceTaskPageQuietRevalidateScope } from '@/components/task-page/github/task-page-github-work-item-quiet-revalidate' +import type { QuietRevalidateRunOwner } from '@/components/task-page/github/refresh/task-page-github-work-item-quiet-state' +import { advanceTaskPageQuietRevalidateScope } from '@/components/task-page/github/refresh/task-page-github-work-item-quiet-revalidate' import { useTaskPageGitHubQuietRefreshEffect } from './use-task-page-github-quiet-refresh-effect' export type TaskPageGitHubQuietRefreshPreludeModel = ReturnType< typeof useTaskPageGitHubQuietRefreshPrelude diff --git a/src/renderer/src/components/task-page/github/task-page-github-list-scroll-restore.test.ts b/src/renderer/src/components/task-page/github/task-page-github-list-scroll-restore.test.ts deleted file mode 100644 index 1985bd0e804..00000000000 --- a/src/renderer/src/components/task-page/github/task-page-github-list-scroll-restore.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -// @vitest-environment happy-dom - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -import { - startGitHubListScrollRestore, - supersedeGitHubListScrollRestore, - type GitHubListRestoreWrite -} from './task-page-github-list-scroll-restore' - -type FakeResizeObserver = { - targets: Set - notify: () => void -} - -const observers: FakeResizeObserver[] = [] - -/** Notifies every observer watching `target`, mirroring a real content/box resize. */ -function resize(target: Element): void { - for (const observer of observers) { - if (observer.targets.has(target)) { - observer.notify() - } - } -} - -/** Scroll container whose scrollTop clamps to `maxScrollTop`, like a real overflow box. */ -function createScrollList(maxScrollTop: number): { - element: HTMLDivElement - rows: HTMLDivElement - setMaxScrollTop: (next: number) => void -} { - const element = document.createElement('div') - const rows = document.createElement('div') - element.append(rows) - let max = maxScrollTop - let value = 0 - Object.defineProperty(element, 'scrollTop', { - configurable: true, - get: () => value, - set: (next: number) => { - value = Math.max(0, Math.min(next, max)) - } - }) - return { - element, - rows, - setMaxScrollTop: (next: number) => { - max = next - value = Math.min(value, next) - } - } -} - -function ref(current: T): { current: T } { - return { current } -} - -describe('GitHub task list scroll restore', () => { - beforeEach(() => { - vi.useFakeTimers() - observers.length = 0 - vi.stubGlobal( - 'ResizeObserver', - class { - private readonly entry: FakeResizeObserver - constructor(callback: () => void) { - this.entry = { targets: new Set(), notify: callback } - observers.push(this.entry) - } - observe(target: Element): void { - this.entry.targets.add(target) - } - disconnect(): void { - this.entry.targets.clear() - } - } - ) - }) - afterEach(() => { - vi.useRealTimers() - vi.unstubAllGlobals() - }) - - describe('user takeover', () => { - it('ends a restore the list can never satisfy when the user scrolls', () => { - const list = createScrollList(0) - const pendingRestoreRef = ref(360) - const restoreWriteRef = ref(null) - - startGitHubListScrollRestore({ - target: 360, - scrollElementRef: ref(list.element), - pendingRestoreRef, - restoreWriteRef, - onScrollTopApplied: () => {} - }) - - list.setMaxScrollTop(900) - list.element.scrollTop = 120 - expect( - supersedeGitHubListScrollRestore({ scrollTop: 120, pendingRestoreRef, restoreWriteRef }) - ).toBe(true) - expect(pendingRestoreRef.current).toBeNull() - expect(restoreWriteRef.current).toBeNull() - - // Without the takeover the next resize would drag the user back to the old offset. - resize(list.rows) - expect(list.element.scrollTop).toBe(120) - }) - }) -}) diff --git a/src/renderer/src/components/task-page/github/use-task-page-github-detail.ts b/src/renderer/src/components/task-page/github/use-task-page-github-detail.ts index da60f83304a..64e273c77ce 100644 --- a/src/renderer/src/components/task-page/github/use-task-page-github-detail.ts +++ b/src/renderer/src/components/task-page/github/use-task-page-github-detail.ts @@ -1,4 +1,4 @@ -import type { TaskPageGitHubListStateModel } from './use-task-page-github-list-state' +import type { TaskPageGitHubListStateModel } from './listing/use-task-page-github-list-state' import { useAppStore } from '@/store/index' import { getWorkItemRepo } from '@/lib/work-items/work-item-repo-row' import { githubTaskBinding } from '@/lib/work-items/github/github-task-observation' @@ -6,11 +6,11 @@ import { useState, useMemo, useLayoutEffect, useCallback, useEffect } from 'reac import type { ItemDialogTab } from '@/components/github/github-work-item-identity' import { stripRepoQualifiers } from '../../../../../shared/work-items/task-query' import { createTaskPageDialogWorkItemReader } from '@/components/task-page/task-page-dialog-work-item' -import { useTaskPageWorkItemsCacheSelection } from './use-task-page-work-items-cache-selection' +import { useTaskPageWorkItemsCacheSelection } from './cache/use-task-page-work-items-cache-selection' import type { GitHubWorkItem } from '../../../../../shared/github/work-items/work-item-types' import type { GitLabWorkItem } from '../../../../../shared/gitlab-types' import { getTaskPageRepoSourceContext } from '../../../lib/work-items/task-repo-source-context' -import { startGitHubListScrollRestore } from './task-page-github-list-scroll-restore' +import { startGitHubListScrollRestore } from './listing/task-page-github-list-scroll-restore' function getTaskPageScrollTop( scrollRef: React.RefObject, diff --git a/src/renderer/src/components/task-page/jira/use-task-page-jira-creation-state.ts b/src/renderer/src/components/task-page/jira/use-task-page-jira-creation-state.ts index 17173609a47..f7075dc9606 100644 --- a/src/renderer/src/components/task-page/jira/use-task-page-jira-creation-state.ts +++ b/src/renderer/src/components/task-page/jira/use-task-page-jira-creation-state.ts @@ -1,4 +1,4 @@ -import type { TaskPageGitHubMutationStateModel } from '../github/use-task-page-github-mutation-state' +import type { TaskPageGitHubMutationStateModel } from '../github/mutations/use-task-page-github-mutation-state' import { useState, useRef, useEffect } from 'react' import type { JiraIssueType, JiraCreateField } from '../../../../../shared/jira/jira-types' import { useTaskCreationDraftRetention } from '@/components/use-task-creation-draft-retention' diff --git a/src/renderer/src/components/task-page/linear/task-page-linear-jira-list-model.tsx b/src/renderer/src/components/task-page/linear/task-page-linear-jira-list-model.tsx index 45d7e9d57d0..9101632e7c9 100644 --- a/src/renderer/src/components/task-page/linear/task-page-linear-jira-list-model.tsx +++ b/src/renderer/src/components/task-page/linear/task-page-linear-jira-list-model.tsx @@ -13,7 +13,7 @@ import React from 'react' import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible' import { AlertCircle, ChevronDown, ChevronRight } from 'lucide-react' import { Button } from '@/components/ui/button' -import type { TaskPageGitHubMutationIntent } from '@/components/task-page/github/task-page-github-work-item-mutation-patches' +import type { TaskPageGitHubMutationIntent } from '@/components/task-page/github/mutations/task-page-github-work-item-mutation-patches' import type { TaskSourceContext } from '../../../../../shared/work-items/task-source-context' import type { LinearGroupSection } from './task-page-linear-issue-model' import { compareNumericLocaleText } from '../../../../../shared/locale-text-collators' diff --git a/src/renderer/src/components/task-page/linear/use-task-page-linear-project-creation.ts b/src/renderer/src/components/task-page/linear/use-task-page-linear-project-creation.ts index e1b353e3ceb..d3fe6b4509b 100644 --- a/src/renderer/src/components/task-page/linear/use-task-page-linear-project-creation.ts +++ b/src/renderer/src/components/task-page/linear/use-task-page-linear-project-creation.ts @@ -2,7 +2,7 @@ import { taskProviderErrorMessage, taskProviderErrorText } from '@/lib/work-items/task-provider-error-copy' -import type { TaskPageGitHubIssueCreationModel } from '../github/use-task-page-github-issue-creation' +import type { TaskPageGitHubIssueCreationModel } from '../github/issue-creation/use-task-page-github-issue-creation' import { useCallback } from 'react' import { linearCreateProject } from '@/runtime/linear/runtime-linear-project-client' import { toast } from 'sonner' diff --git a/src/renderer/src/components/task-page/task-page-github-pr-chips.test.tsx b/src/renderer/src/components/task-page/task-page-github-pr-chips.test.tsx deleted file mode 100644 index eea0382c4b9..00000000000 --- a/src/renderer/src/components/task-page/task-page-github-pr-chips.test.tsx +++ /dev/null @@ -1,63 +0,0 @@ -// @vitest-environment happy-dom -import '@testing-library/jest-dom/vitest' -import { act, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { useAppStore } from '@/store' -import { GITHUB_TASK_SEARCH_IDLE_MS } from '@/components/use-github-task-search-commit' -import { createGlobalSettingsFixture } from '../../../../shared/global-settings-test-fixture' -import { - Harness, - installSourceDisplayTestLifecycle, - repo, - view -} from './use-task-page-source-display.test-fixture' - -vi.mock('@/components/github/PRFilterDropdowns', () => ({ default: () => null })) - -installSourceDisplayTestLifecycle() - -afterEach(() => { - vi.useRealTimers() -}) - -function chip(name: string) { - return screen.getByRole('button', { name }) -} - -describe('GitHub PR state × who chips', () => { - it('saves Open × Mine as the Mine preset after the persist effect and idle commit', () => { - vi.useFakeTimers() - useAppStore.setState({ - repos: [repo], - settings: createGlobalSettingsFixture({ defaultRepoSelection: [repo.id] }), - taskPageData: { taskSource: 'github' } - }) - render() - act(() => { - view.setTaskSearchInput('is:pr is:merged') - view.setAppliedTaskSearch('is:pr is:merged') - view.setTaskResumeApplied(true) - }) - expect(chip('Merged')).toHaveAttribute('aria-pressed', 'true') - - fireEvent.click(chip('Open')) - fireEvent.click(chip('Mine')) - act(() => { - vi.advanceTimersByTime(GITHUB_TASK_SEARCH_IDLE_MS + 1) - }) - - expect(useAppStore.getState().taskResumeState).toMatchObject({ - githubItemsPreset: 'my-prs', - githubItemsQuery: 'author:@me is:pr is:open' - }) - expect(view.taskSearchInput).toBe('author:@me is:pr is:open') - expect(chip('Open')).toHaveAttribute('aria-pressed', 'true') - expect(chip('Mine')).toHaveAttribute('aria-pressed', 'true') - - fireEvent.click(chip('Merged')) - expect(useAppStore.getState().taskResumeState).toMatchObject({ - githubItemsPreset: null, - githubItemsQuery: 'is:pr is:merged author:@me' - }) - }) -}) diff --git a/src/renderer/src/components/task-page/task-page-localized-options.tsx b/src/renderer/src/components/task-page/task-page-localized-options.tsx index 87be90c903f..7113f309372 100644 --- a/src/renderer/src/components/task-page/task-page-localized-options.tsx +++ b/src/renderer/src/components/task-page/task-page-localized-options.tsx @@ -16,7 +16,7 @@ import { } from '../../../../shared/linear/issue-view-resume-state' import { getTaskPresetQuery } from '../../../../shared/work-items/task-preset-query' import type { GitLabMRInvolvement } from '../../../../shared/gitlab-types' -import type { GitHubPRStateChip, GitHubPRWhoChip } from './github/task-page-pr-chips' +import type { GitHubPRStateChip, GitHubPRWhoChip } from './github/listing/task-page-pr-chips' import type { TaskProvider } from '../../../../shared/work-items/task-providers' import type { TaskViewPresetId } from '../../../../shared/ui-chrome-types' diff --git a/src/renderer/src/components/task-page/use-task-page-detail-routing.ts b/src/renderer/src/components/task-page/use-task-page-detail-routing.ts index c54ba66e5a3..3b0fc51893d 100644 --- a/src/renderer/src/components/task-page/use-task-page-detail-routing.ts +++ b/src/renderer/src/components/task-page/use-task-page-detail-routing.ts @@ -5,7 +5,7 @@ import { registerLinearIssueConsumer } from '@/store/slices/linear/linear-issue-write-publication' import { defaultScopeSource } from '@/runtime/hosts/selection/default-creation-host' -import type { TaskPageGitHubIssueDraftModel } from './github/use-task-page-github-issue-draft' +import type { TaskPageGitHubIssueDraftModel } from './github/issue-creation/use-task-page-github-issue-draft' import { useState, useMemo, useCallback, useLayoutEffect, useRef } from 'react' import type { LinearIssue } from '../../../../shared/linear/issue-types' import type { JiraIssue } from '../../../../shared/jira/jira-types' diff --git a/src/renderer/src/components/task-page/use-task-page-search-actions.ts b/src/renderer/src/components/task-page/use-task-page-search-actions.ts index ab1cc14a3df..7f028bf0411 100644 --- a/src/renderer/src/components/task-page/use-task-page-search-actions.ts +++ b/src/renderer/src/components/task-page/use-task-page-search-actions.ts @@ -1,11 +1,11 @@ -import type { TaskPageGitHubQuietRefreshModel } from './github/use-task-page-github-quiet-refresh' +import type { TaskPageGitHubQuietRefreshModel } from './github/refresh/use-task-page-github-quiet-refresh' import React, { useCallback, useEffect } from 'react' import type { PRFilterChange } from '@/components/github/PRFilterSections' import { scopeGitHubTaskSearch, getDefaultPresetForGitHubTaskKind } from '../../../../shared/work-items/task-github-search' -import { matchGitHubPRPreset, patchGitHubTaskQuery } from './github/task-page-pr-chips' +import { matchGitHubPRPreset, patchGitHubTaskQuery } from './github/listing/task-page-pr-chips' import type { TaskViewPresetId } from '../../../../shared/ui-chrome-types' import { toast } from 'sonner' import { translate } from '@/i18n/i18n' diff --git a/src/renderer/src/components/task-page/use-task-page-source-display.test-fixture.tsx b/src/renderer/src/components/task-page/use-task-page-source-display.test-fixture.tsx deleted file mode 100644 index d576373a064..00000000000 --- a/src/renderer/src/components/task-page/use-task-page-source-display.test-fixture.tsx +++ /dev/null @@ -1,197 +0,0 @@ -import { cleanup } from '@testing-library/react' -import { useLayoutEffect } from 'react' -import { useTaskPageGitHubSearchPagination } from './github/use-task-page-github-search-pagination' -import { useTaskPageGitHubLandingRefresh } from './github/use-task-page-github-landing-refresh' -import { taskPageGitHubResumeCache } from './github/task-page-github-resume-cache' -import { afterEach, beforeEach, vi, type Mock } from 'vitest' -import type { AppState } from '@/store/types' -import { TooltipProvider } from '@/components/ui/tooltip' -import { useAppStore } from '@/store' -import { i18n } from '@/i18n/i18n' -import type { WorkItemsCacheSources } from '@/store/github/cache/cache-model' -import type { GitHubRepositoryIdentity as GitHubOwnerRepo } from '../../../../shared/github/repositories/repository-identity' -import type { Repo } from '../../../../shared/repo-types' -import { getGitHubTaskKind } from '../../../../shared/work-items/task-github-search' -import { parseTaskQuery } from '../../../../shared/work-items/task-query' -import { useTaskPageStoreBindings } from './use-task-page-store-bindings' -import { useTaskPageRepoSelection } from './use-task-page-repo-selection' -import { useTaskPageRuntimeHosts } from './use-task-page-runtime-hosts' -import { useTaskPageSourceAvailability } from './use-task-page-source-availability' -import { useTaskPageProviderState } from './use-task-page-provider-state' -import { useTaskPageGitHubListState } from './github/use-task-page-github-list-state' -import { useTaskPageGitHubDetail } from './github/use-task-page-github-detail' -import { useTaskPageGitHubCacheReconciliation } from './github/use-task-page-github-cache-reconciliation' -import { useTaskPageSourceDisplay } from './use-task-page-source-display' -import { TaskPageGitHubFilters } from './github/Filters' -import { useTaskPageSearchActions } from './use-task-page-search-actions' -import { TaskPageGitHubList } from './github/List' -import { useTaskPageGitHubListProjection } from './github/use-task-page-github-list-projection' -import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions' - -export const Q1 = 'is:issue is:open' -export const Q2 = 'is:issue label:bug' -export const upstream = { owner: 'upstream-owner', repo: 'project' } -export const fork = { owner: 'fork-owner', repo: 'project', host: 'ghe.example.com' } -export const sources = (issues: GitHubOwnerRepo | null, prs = issues): WorkItemsCacheSources => ({ - issues, - prs, - originCandidate: fork, - upstreamCandidate: upstream -}) -// Saved project identity is the upstream; the list's resolved source can differ. -export const repo: Repo = { - id: 'repo', - path: '/repo', - displayName: 'Project', - badgeColor: '#000', - addedAt: 1, - executionHostId: 'local', - upstream -} -export const sshTwin: Repo = { - ...repo, - path: '/ssh/repo', - connectionId: 'box', - executionHostId: 'ssh:box' -} - -const initialStore = useAppStore.getState() -const initialApiDescriptor = Object.getOwnPropertyDescriptor(window, 'api') -// listWorkItems answers by repo path and query; a missing answer fails the fetch. -export const listAnswers = new Map() -export const setIssueSourcePreference: Mock = - vi.fn() - -function useMountedSourceDisplay(runRequests = false) { - const store = useTaskPageStoreBindings() - const selection = useTaskPageRepoSelection(store) - const hosts = useTaskPageRuntimeHosts(selection) - const availability = useTaskPageSourceAvailability(hosts) - const provider = useTaskPageProviderState(availability) - const list = useTaskPageGitHubListState(provider) - const detail = useTaskPageGitHubDetail(list) - const reconciled = useTaskPageGitHubCacheReconciliation(detail) - // Stage 21 derives the kind the same way before handing off to the source display. - const kindModel = Object.assign(reconciled, { - activeGithubTaskKind: getGitHubTaskKind( - reconciled.activeTaskPreset, - reconciled.appliedTaskSearch - ) - }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: pagination reads the stage-8 list state, source kind and store bindings present here; intervening provider stages add no GitHub pagination inputs. - const paginationInput = kindModel as unknown as typeof kindModel & - Parameters[0] - const withKind = useTaskPageGitHubSearchPagination(paginationInput) - // Stage 26 as mounted in production; without requests its run stays inert, as before the resume step. - useTaskPageGitHubLandingRefresh({ - ...withKind, - taskResumeApplied: runRequests && withKind.taskResumeApplied, - githubWorkItemMutationQueryKey: 'mounted-source-flow' - }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every field the source display reads comes from stages 1-8 or the kind above; stages 9-20 add none of them. - const sourceDisplayInput = withKind as unknown as typeof withKind & - Parameters[0] - return useTaskPageSourceDisplay(sourceDisplayInput) -} -type MountedSourceDisplay = ReturnType -export let view: MountedSourceDisplay -const NO_ROW_MUTATIONS = { softHiddenItemKeys: new Set() } -// A child, so the list's own subscriptions never count as Harness renders. -function MountedList({ model }: { model: MountedSourceDisplay }) { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the projection reads stage 1-8 state and the kind; of stages 9-23 it reads only row mutations, stubbed with none hidden. - const listInput = { ...model, githubWorkItemMutation: NO_ROW_MUTATIONS } as unknown as Parameters< - typeof useTaskPageGitHubListProjection - >[0] - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: List reads stage 1-8 state, refs and the projection; Rows, the only reader of later stages, is stubbed. - const listModel = useTaskPageGitHubListProjection( - listInput - ) as unknown as TaskPageComposerActionsModel - return ( - <> - {String(listModel.githubListPending)} - - - ) -} -/** Harness renders since the last reset; a test resets it to count one update's renders. */ -export const harnessRenders = { count: 0 } -export function Harness({ - runRequests = false, - list = false -}: { - runRequests?: boolean - list?: boolean -}) { - harnessRenders.count += 1 - const model = useMountedSourceDisplay(runRequests) - // Tests read the committed model between acts. - useLayoutEffect(() => { - view = model - }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the filter actions read the store bindings, search state and kind mounted here; modal and dialog fields only gate the find shortcut. - const searchInput = { ...model } as unknown as Parameters[0] - const searchActions = useTaskPageSearchActions(searchInput) - const filtersFields = { - ...model, - applyPRFilterChange: searchActions.applyPRFilterChange, - handleSetDefaultTaskPreset: searchActions.handleSetDefaultTaskPreset, - setIssueSourcePreference: runRequests - ? useAppStore.getState().setIssueSourcePreference - : setIssueSourcePreference, - appliedTaskQuery: parseTaskQuery(model.appliedTaskSearch), - loadedGitHubAuthorLogins: [], - primaryGithubFilterSlug: null, - githubTasksBusy: false, - newIssueTargetRepo: null - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mounted stage-8 model plus these stubs cover every field Filters reads; later-stage handlers are never fired here. - const filtersModel = filtersFields as unknown as TaskPageComposerActionsModel - return ( - - - - {model.pages[model.currentPage]?.map((row) => row.title).join(',') ?? ''} - - {model.countedTotalPages ?? 'unknown'} - {list ? : null} - - ) -} - -/** Each mounted source-display test starts from a clean store, preload API and resume cache. */ -export function installSourceDisplayTestLifecycle() { - beforeEach(() => { - taskPageGitHubResumeCache.clear() - listAnswers.clear() - setIssueSourcePreference.mockReset() - Object.defineProperty(window, 'api', { - configurable: true, - value: { - gh: { - countWorkItems: vi.fn().mockResolvedValue(0), - viewer: vi.fn().mockResolvedValue(null), - listWorkItems: vi.fn(async ({ repoPath, query = '' }) => { - const answer = listAnswers.get(`${repoPath}\0${query}`) - if (!answer) { - throw new Error('list failed') - } - return { items: [], sources: answer } - }) - }, - repos: { update: vi.fn(async ({ repoId }) => ({ ...repo, id: repoId })) }, - ui: { set: vi.fn().mockResolvedValue(undefined) } - } - }) - useAppStore.setState({ ...initialStore, workItemsCache: {} }, true) - }) - afterEach(async () => { - await i18n.changeLanguage('en') - cleanup() - useAppStore.setState(initialStore, true) - if (initialApiDescriptor) { - Object.defineProperty(window, 'api', initialApiDescriptor) - } else { - Reflect.deleteProperty(window, 'api') - } - }) -} diff --git a/src/renderer/src/hooks/useTaskPageGitHubWorkItemMutation.ts b/src/renderer/src/hooks/useTaskPageGitHubWorkItemMutation.ts index 3b6d9700e84..cb570e9068c 100644 --- a/src/renderer/src/hooks/useTaskPageGitHubWorkItemMutation.ts +++ b/src/renderer/src/hooks/useTaskPageGitHubWorkItemMutation.ts @@ -2,7 +2,7 @@ import { getWorkItemRepo } from '@/lib/work-items/work-item-repo-row' import { buildTaskPageGitHubWorkItemMutationPatch, type TaskPageGitHubMutationIntent -} from '@/components/task-page/github/task-page-github-work-item-mutation-patches' +} from '@/components/task-page/github/mutations/task-page-github-work-item-mutation-patches' import { useCallback, useEffect, useRef, useState } from 'react' import { toast } from 'sonner' import { parseExecutionHostId } from '../../../shared/execution-host' @@ -14,18 +14,18 @@ import { useAppStore } from '@/store' import { beginTaskPageGitHubWorkItemMutation, canStartTaskPageGitHubWorkItemMutation -} from '@/components/task-page/github/task-page-github-work-item-mutations' +} from '@/components/task-page/github/mutations/task-page-github-work-item-mutations' import { confirmTaskPageGitHubWorkItemMutation, rollbackTaskPageGitHubWorkItemMutation -} from '@/components/task-page/github/task-page-github-work-item-mutation-lifecycle' +} from '@/components/task-page/github/mutations/task-page-github-work-item-mutation-lifecycle' -import type { TaskPageGitHubPatchWorkItem } from '@/components/task-page/github/task-page-github-work-item-mutation-types' +import type { TaskPageGitHubPatchWorkItem } from '@/components/task-page/github/mutations/task-page-github-work-item-mutation-types' import { getTaskPageGitHubSoftHiddenItemKeys, subscribeTaskPageGitHubMutationRegistry, setTaskPageGitHubMutationQueryKey -} from '@/components/task-page/github/task-page-github-work-item-mutation-registry' +} from '@/components/task-page/github/mutations/task-page-github-work-item-mutation-registry' import { useMountedRef } from './useMountedRef' import { codeHostErrorText } from '@/lib/source-control/remote/code-host-error-copy' diff --git a/src/renderer/src/store/github/sources/github-source-lifetime.test.tsx b/src/renderer/src/store/github/sources/github-source-lifetime.test.tsx deleted file mode 100644 index f79887fd371..00000000000 --- a/src/renderer/src/store/github/sources/github-source-lifetime.test.tsx +++ /dev/null @@ -1,167 +0,0 @@ -// @vitest-environment happy-dom -import { cleanup } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { useStore } from 'zustand' -import { createTestStore, mockApi, resetRemoteRuntimeMocks } from '../github-slice-test-harness' -import { createRepoUpdateActions } from '../../repos/repo-update' -import { taskPageGitHubResumeCache } from '@/components/task-page/github/task-page-github-resume-cache' -import type { AppState } from '../../types' -import { createGlobalSettingsFixture } from '../../../../../shared/global-settings-test-fixture' -import type { Repo } from '../../../../../shared/repo-types' -import type { GitHubWorkItem } from '../../../../../shared/github/work-items/work-item-types' - -const holder = vi.hoisted((): { store: ReturnType | null } => ({ - store: null -})) -vi.mock('@/store/index', () => ({ - useAppStore: Object.assign( - (selector: (state: AppState) => T) => useStore(holder.store!, selector), - { - getState: () => holder.store!.getState(), - subscribe: (listener: (state: AppState) => void) => holder.store!.subscribe(listener) - } - ) -})) -vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } })) - -const origin = { owner: 'fork', repo: 'project' } -const upstream = { owner: 'parent', repo: 'project' } -const repo: Repo = { - id: 'source-repo', - path: '/source-repo', - displayName: 'Project', - addedAt: 1, - badgeColor: '', - executionHostId: 'local' -} -function row(owner: string, number = 12): GitHubWorkItem { - return { - id: `issue:${number}`, - repoId: repo.id, - type: 'issue', - number, - title: owner, - state: 'open', - url: `https://github.com/${owner}/project/issues/${number}`, - author: null, - labels: [], - assignees: [], - updatedAt: '2026-11-01T00:00:00Z' - } -} -const envelope = (owner: string) => ({ - items: [row(owner)], - sources: { - issues: { owner, repo: 'project' }, - prs: { owner, repo: 'project' }, - originCandidate: origin, - upstreamCandidate: upstream - } -}) -const args = [{ repoId: repo.id, path: repo.path }] -const flush = async () => { - for (let i = 0; i < 8; i++) { - await Promise.resolve() - } -} -function setup() { - const store = createTestStore() - store.setState({ - repos: [repo], - projects: [], - projectHostSetups: [], - settings: createGlobalSettingsFixture(), - fetchReposForAllHosts: vi.fn().mockResolvedValue(undefined), - ...createRepoUpdateActions(store.setState, store.getState) - }) - holder.store = store - return store -} -beforeEach(() => { - vi.stubGlobal('window', document.defaultView) - Object.defineProperty(window, 'api', { configurable: true, value: mockApi }) - vi.clearAllMocks() - resetRemoteRuntimeMocks() - mockApi.repos.update.mockResolvedValue(undefined) - mockApi.gh.listWorkItems.mockResolvedValue(envelope('parent')) -}) -afterEach(() => { - cleanup() - holder.store = null -}) - -describe('GitHub source collection lifetime', () => { - it('rejects inverted A1/B/A2 list deliveries including page N', async () => { - const store = setup() - const a1 = Promise.withResolvers>() - mockApi.gh.listWorkItems.mockReturnValueOnce(a1.promise) - const old = store.getState().fetchWorkItems(repo.id, repo.path, 20, '') - await flush() - await store.getState().setIssueSourcePreference(repo.id, repo.path, 'origin', 'local') - const b = Promise.withResolvers>() - mockApi.gh.listWorkItems.mockReturnValueOnce(b.promise) - const page = store.getState().fetchWorkItemsNextPage(args, 20, 20, '', 1) - await flush() - await store.getState().setIssueSourcePreference(repo.id, repo.path, 'upstream', 'local') - mockApi.gh.listWorkItems.mockResolvedValueOnce(envelope('parent')) - const a2 = await store.getState().fetchWorkItemsAcrossRepos(args, 20, 20, '') - b.resolve(envelope('fork')) - a1.resolve(envelope('parent')) - expect(await old).toBeUndefined() - expect(await page).toBeUndefined() - expect(a2?.items.map((item) => item.url)).toEqual([row('parent').url]) - expect( - store - .getState() - .getCachedWorkItems(repo.id, 20, '', repo.path) - ?.map((item) => item.url) - ).toEqual([row('parent').url]) - }) -}) - -describe('source mapping and estimate retirement', () => { - it('retires every query and resume page when origin alone changes, and cannot revive a removed owner through a pending save', async () => { - const store = setup() - await store.getState().fetchWorkItems(repo.id, repo.path, 20, '') - mockApi.gh.countWorkItems.mockResolvedValue(40) - await store.getState().countWorkItemsAcrossRepos(args, '', 20) - const owner = store.getState().githubSourceCollections - const oldOwner = owner.context(repo) - taskPageGitHubResumeCache.write('old-context', 2, [row('parent')], undefined, [oldOwner]) - const oldQuery = Promise.withResolvers>() - mockApi.gh.listWorkItems.mockReturnValueOnce(oldQuery.promise) - const old = store.getState().fetchWorkItems(repo.id, repo.path, 20, 'label:old') - await flush() - mockApi.gh.listWorkItems.mockResolvedValueOnce({ - ...envelope('parent'), - sources: { - ...envelope('parent').sources, - originCandidate: { owner: 'new-fork', repo: 'project' } - } - }) - await store.getState().fetchWorkItems(repo.id, repo.path, 20, 'label:new') - oldQuery.resolve(envelope('parent')) - expect(await old).toBeUndefined() - expect(store.getState().getCachedWorkItems(repo.id, 20, '', repo.path)).toBeNull() - expect(taskPageGitHubResumeCache.read('old-context', 2)).toBeNull() - expect(owner.context(repo)).not.toBe(oldOwner) - const save = Promise.withResolvers() - mockApi.repos.update.mockReturnValueOnce(save.promise) - const saving = store.getState().setIssueSourcePreference(repo.id, repo.path, 'origin', 'local') - const waiting = owner.awaitSave(store.getState().repos[0]) - const oldCatalog = owner.catalogSnapshot() - store.setState({ repos: [] }) - const removed = await waiting - expect(removed !== 'save-failed' && removed()).toBe(false) - store.setState({ repos: [{ ...repo, issueSourcePreference: 'upstream' }] }) - const replacement = owner.context(store.getState().repos[0]) - save.resolve({ ...repo, issueSourcePreference: 'origin' }) - await saving - expect(store.getState().repos[0].issueSourcePreference).toBe('upstream') - expect(owner.context(store.getState().repos[0])).toBe(replacement) - expect( - owner.adopt({ ...repo, issueSourcePreference: 'origin' }, oldCatalog).issueSourcePreference - ).toBe('upstream') - expect(store.getState().getCachedWorkItems(repo.id, 20, '', repo.path)).toBeNull() - }) -}) diff --git a/src/renderer/src/store/github/sources/source-collections.ts b/src/renderer/src/store/github/sources/source-collections.ts index 6d56e2f4591..bb24d84e6ce 100644 --- a/src/renderer/src/store/github/sources/source-collections.ts +++ b/src/renderer/src/store/github/sources/source-collections.ts @@ -9,7 +9,7 @@ import { nextProviderRequestId } from '../requests/request-coordination' import { withBoundedCacheEntry, evictStaleEntries } from '../cache/cache-policy' import { foldReportedCandidates } from './source-candidate-evidence' import { workItemsCacheKey } from '../cache/cache-identity' -import { taskPageGitHubResumeCache } from '@/components/task-page/github/task-page-github-resume-cache' +import { taskPageGitHubResumeCache } from '@/components/task-page/github/cache/task-page-github-resume-cache' import { GitHubSourceOwnership, githubCollectionScope, diff --git a/src/renderer/src/store/github/task-records/github-task-records.test.ts b/src/renderer/src/store/github/task-records/github-task-records.test.ts deleted file mode 100644 index ea149195500..00000000000 --- a/src/renderer/src/store/github/task-records/github-task-records.test.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { answered } from '../requests/answered-request.test-fixture' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { Repo } from '../../../../../shared/repo-types' -import type { GitHubWorkItem } from '../../../../../shared/github/work-items/work-item-types' -import { createTestStore, mockApi, resetRemoteRuntimeMocks } from '../github-slice-test-harness' -import { getTaskPageRepoSourceContext } from '@/lib/work-items/task-repo-source-context' -import { resetTaskPageGitHubMutationRegistryForTests } from '@/components/task-page/github/task-page-github-work-item-mutation-registry' - -const lookup = vi.hoisted(() => vi.fn()) -vi.mock('@/lib/work-items/github/github-work-item-source-lookup', () => ({ - lookupGitHubWorkItemByOwnerRepoForSource: lookup -})) -const repo: Repo = { - id: 'repo', - path: '/repo', - displayName: 'Repo', - badgeColor: 'blue', - addedAt: 1, - ghAccount: { host: 'github.com', user: 'alice' } -} -const row: GitHubWorkItem = { - id: 'issue:42', - repoId: 'repo', - type: 'issue', - number: 42, - title: 'Original', - state: 'open', - labels: [], - assignees: [], - url: 'https://github.com/acme/widgets/issues/42', - author: null, - updatedAt: '2026-10-31T12:00:02Z' -} -function deferred() { - let resolve!: (value: T) => void - let reject!: (error: Error) => void - const promise = new Promise((r, fail) => { - resolve = r - reject = fail - }) - return { promise, resolve, reject } -} -async function flush() { - await Promise.resolve() - await Promise.resolve() - await Promise.resolve() -} -function setup(repos = [repo]) { - const store = createTestStore() - store.setState({ repos }) - const cache = store.getState().githubTaskCache - const binding = cache.capture(repos[0])! - return { store, cache, binding } -} -const sources = { - issues: { owner: 'acme', repo: 'widgets' }, - prs: null, - originCandidate: null, - upstreamCandidate: null -} -/** A Tasks row cached by the real fetch, plus a binding captured for its source. */ -async function tasksRow(store: ReturnType, item: GitHubWorkItem = row) { - mockApi.gh.listWorkItems.mockResolvedValue({ items: [item], sources }) - const sourceContext = getTaskPageRepoSourceContext(repo, 'github') - await store.getState().fetchWorkItems(repo.id, repo.path, 20, '', { sourceContext }) - const copy = () => Object.values(store.getState().workItemsCache).flatMap((e) => e.data ?? [])[0] - const capture = () => store.getState().githubTaskCache.capture(repo, sourceContext)! - const search = async () => - answered( - await store - .getState() - .fetchWorkItems(repo.id, repo.path, 20, '', { sourceContext, force: true }) - )[0] - return { copy, capture, search } -} - -/** An account switch and back: the old binding is disposed and a new incarnation replaces it. */ -function rebind(store: ReturnType) { - store.setState({ repos: [{ ...repo, ghAccount: { host: 'github.com', user: 'bob' } }] }) - store.setState({ repos: [repo] }) -} - -function taskFacts(item: GitHubWorkItem) { - return { title: item.title, state: item.state, labels: item.labels } -} - -describe('shared GitHub task records through production store actions', () => { - beforeEach(() => { - vi.clearAllMocks() - resetRemoteRuntimeMocks() - resetTaskPageGitHubMutationRegistryForTests() - // This mock represents the already-shipped safe local item route. - Object.assign(mockApi.gh, { workItemByOwnerRepo: vi.fn() }) - lookup.mockReset() - lookup.mockResolvedValue(row) - }) - - it('rejects pre-edit canonical C and coalesces one replacement after B settles', async () => { - const { cache, binding } = setup() - const c = deferred() - const c2 = deferred() - lookup.mockReturnValue(c2.promise) - const read = cache.readCanonical(row, binding, () => c.promise) - const b = cache.operation(row, binding, { labels: { new: true } }) - b(true) - expect(lookup).not.toHaveBeenCalled() - c.resolve(row) - expect(await read).toBe(false) - - c2.resolve({ ...row, labels: ['new'] }) - await flush() - expect(cache.projectItem(row).labels).toEqual(['new']) - }) - - it('disposes an account incarnation and rejects its deferred result after A to B to A', async () => { - const { store, cache, binding } = setup() - const response = deferred() - const read = cache.readCanonical(row, binding, () => response.promise) - const listener = vi.fn() - cache.subscribe(listener) - store.setState({ repos: [{ ...repo, ghAccount: { host: 'github.com', user: 'bob' } }] }) - store.setState({ repos: [repo] }) - response.resolve({ ...row, title: 'Old Alice' }) - expect(await read).toBe(false) - expect(cache.current(binding)).toBe(false) - expect(cache.projectItem(row).title).toBe('Original') - expect(listener).toHaveBeenCalled() - expect(cache.capture(repo)!.incarnation).not.toBe(binding.incarnation) - }) - - it('establishes from the read after the last of two overlapping edits', async () => { - const { store, cache } = setup() - const { capture } = await tasksRow(store) - const r1 = deferred(), - r2 = deferred() - lookup.mockReturnValueOnce(r1.promise).mockReturnValueOnce(r2.promise) - cache.operation(row, capture(), { labels: { a: true } })(true) - cache.operation(row, capture(), { labels: { b: true } })(true) - - r1.resolve({ ...row, labels: ['a'], updatedAt: '2026-10-31T12:00:03Z' }) - await flush() - - expect(cache.projectItem(row).labels).toEqual(['a', 'b']) - const after = { - ...row, - title: 'After B', - labels: ['a', 'b'], - updatedAt: '2026-10-31T12:00:04Z' - } - r2.resolve(after) - await flush() - expect(taskFacts(cache.projectItem(row))).toEqual(taskFacts(after)) - cache.acceptSearch([after], capture()) - await flush() - }) - - it('leaves copies to a replacement incarnation and reconciles them with its own read', async () => { - const { store, cache } = setup() - const { copy, capture } = await tasksRow(store) - await cache.readCanonical(row, capture(), async () => row) - const old = cache.operation(row, capture(), { labels: { old: true } }) - expect(copy().labels).toEqual([]) - rebind(store) - await tasksRow(store) - const next = cache.operation(copy(), capture(), { labels: { new: true } }) - expect(cache.projectItem(copy()).labels).toEqual(['new']) - expect(old(false).labels).toEqual([]) - lookup.mockResolvedValue({ ...row, title: 'Reconciled', updatedAt: '2026-10-31T12:00:03Z' }) - next(false) - await flush() - // The late old settlement owes the replacement its own read, even though its own edit failed. - - expect(copy()).toMatchObject({ title: 'Reconciled', labels: [] }) - }) - it('lets a Refresh queued behind a discarded one read once that owner retires', async () => { - const { cache, binding } = setup() - const first = deferred() - const r1 = cache.readCanonical(row, binding, () => first.promise) - const second = vi.fn(async () => ({ ...row, title: 'Fresh' })) - const r2 = cache.readCanonical(row, binding, second) - first.resolve(null) - expect([await r1, await r2]).toEqual([false, true]) - - expect(cache.projectItem(row).title).toBe('Fresh') - }) -}) diff --git a/src/renderer/src/store/github/task-records/task-record-owners.ts b/src/renderer/src/store/github/task-records/task-record-owners.ts index 4e1752c04e8..fbecf9347c1 100644 --- a/src/renderer/src/store/github/task-records/task-record-owners.ts +++ b/src/renderer/src/store/github/task-records/task-record-owners.ts @@ -1,5 +1,5 @@ -import { deleteConfirmedListSnapshot } from '@/components/task-page/github/task-page-github-work-item-mutation-registry' -import { deleteLastConfirmedClientValue } from '@/components/task-page/github/task-page-github-confirmed-client-values' +import { deleteConfirmedListSnapshot } from '@/components/task-page/github/mutations/task-page-github-work-item-mutation-registry' +import { deleteLastConfirmedClientValue } from '@/components/task-page/github/mutations/task-page-github-confirmed-client-values' import type { GitHubWorkItem } from '../../../../../shared/github/work-items/work-item-types' import type { TaskSourceContext } from '../../../../../shared/work-items/task-source-context' import type { Repo } from '../../../../../shared/repo-types' From a3be49edd484e98580b47c12ef17c9e54a8b296c Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 13 Nov 2026 04:04:33 -0800 Subject: [PATCH 17/55] test: remove Calculator smoke and redundant coverage (#42000) * test: remove weak native smoke and repeated writer coverage * test: focus packaged PTY coverage on output and exit delivery --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .github/workflows/computer-e2e.yml | 3 +- .../mobile-web-app-code-block-selection.mjs | 1 - ...rcad-packaged-node-pty.integration.test.ts | 13 +- .../native-slots/orcad-node-slot-fixture.ts | 18 +-- tests/e2e/computer-windows-store.e2e.ts | 119 ------------------ .../cross-version-stop-note-scenarios.ts | 64 +++++----- 6 files changed, 35 insertions(+), 183 deletions(-) delete mode 100644 tests/e2e/computer-windows-store.e2e.ts diff --git a/.github/workflows/computer-e2e.yml b/.github/workflows/computer-e2e.yml index 60e15f4794c..f26cfe41eec 100644 --- a/.github/workflows/computer-e2e.yml +++ b/.github/workflows/computer-e2e.yml @@ -53,7 +53,6 @@ on: - 'src/shared/computer-use-*.ts' - 'tests/e2e/computer-linux.e2e.ts' - 'tests/e2e/computer-windows.e2e.ts' - - 'tests/e2e/computer-windows-store.e2e.ts' - 'tests/e2e/computer-windows-background.e2e.ts' - 'tests/e2e/helpers/windows-semantic-controls-fixture.*' - 'tests/e2e/helpers/computer-cli-driver.ts' @@ -153,4 +152,4 @@ jobs: - run: pnpm verify:computer-native - run: pnpm build:cli - run: pnpm run build:electron-vite:parallel - - run: pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-windows.e2e.ts tests/e2e/computer-windows-store.e2e.ts tests/e2e/computer-windows-background.e2e.ts + - run: pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-windows.e2e.ts tests/e2e/computer-windows-background.e2e.ts diff --git a/config/scripts/mobile-web-app/shell/mobile-web-app-code-block-selection.mjs b/config/scripts/mobile-web-app/shell/mobile-web-app-code-block-selection.mjs index d747e67fab9..738f5318edf 100644 --- a/config/scripts/mobile-web-app/shell/mobile-web-app-code-block-selection.mjs +++ b/config/scripts/mobile-web-app/shell/mobile-web-app-code-block-selection.mjs @@ -12,7 +12,6 @@ async function verifyCodeBlockSelections({ readSurface }) { const cases = [ - { markdown: 'selected text', html: '

selected text

', literal: 'selected text' }, { markdown: 'first\nsecond', html: '

first
second

', literal: 'first\nsecond' }, { markdown: 'first\n\nsecond', diff --git a/src/main/orcad/native-runtime/orcad-packaged-node-pty.integration.test.ts b/src/main/orcad/native-runtime/orcad-packaged-node-pty.integration.test.ts index 386d7ea0e79..62c67deedd8 100644 --- a/src/main/orcad/native-runtime/orcad-packaged-node-pty.integration.test.ts +++ b/src/main/orcad/native-runtime/orcad-packaged-node-pty.integration.test.ts @@ -5,7 +5,6 @@ import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { describe, expect, it } from 'vitest' import { runProcess } from '@orca/process-host' -import { NODE_RUNTIME_PIN } from '../../../shared/node-runtime-pin' import { ORCAD_NODE_PTY_DIR } from '../../../shared/orcad-artifacts' import { removeTreeSync } from '../../../shared/windows-transient-lock-removal' import { @@ -22,7 +21,7 @@ const skip = skipForMissingInputs('artifact', [ ]) describe.skipIf(skip)('packaged node-pty under the pinned Node', () => { - it('spawns, reattaches, delivers data and retires a shell from the shipped slot only', async () => { + it('spawns a shell and delivers its output and exit from the shipped slot only', async () => { const directory = await mkdtemp(join(tmpdir(), 'orca-packaged-node-pty-')) try { const entry = join(directory, 'local-pty.cjs') @@ -52,16 +51,13 @@ describe.skipIf(skip)('packaged node-pty under the pinned Node', () => { sessionId: 'packaged-node-pty', cols: 80, rows: 24, cwd: process.cwd(), shellOverride: process.platform === 'win32' ? getCmdExePath() : '/bin/sh' }) - const again = await provider.spawn({ sessionId: first.id, cols: 100, rows: 30 }) provider.write(first.id, process.platform === 'win32' ? 'echo ORCA_PACKAGED_PTY_READY & exit 17\\r' : 'printf ORCA_PACKAGED_PTY_READY; exit 17\\r') const code = await exit clearTimeout(deadline) console.log(JSON.stringify({ - version: process.versions.node, code, output: output.includes('ORCA_PACKAGED_PTY_READY'), - reattached: again.isReattach === true && again.pid === first.pid, - retired: provider.getPtyProcess(first.id) === undefined + code, output: output.includes('ORCA_PACKAGED_PTY_READY') })) })().catch(error => { clearTimeout(deadline); provider.killAll(); console.error(error); process.exitCode = 1 }) `, @@ -91,11 +87,8 @@ describe.skipIf(skip)('packaged node-pty under the pinned Node', () => { }) expect(result.code, result.stderr).toBe(0) expect(JSON.parse(result.stdout)).toEqual({ - version: NODE_RUNTIME_PIN.version, code: 17, - output: true, - reattached: true, - retired: true + output: true }) } finally { // Remove the junction before recursively deleting the test root. diff --git a/src/main/orcad/testing/native-slots/orcad-node-slot-fixture.ts b/src/main/orcad/testing/native-slots/orcad-node-slot-fixture.ts index f9f8fb83016..618729bc675 100644 --- a/src/main/orcad/testing/native-slots/orcad-node-slot-fixture.ts +++ b/src/main/orcad/testing/native-slots/orcad-node-slot-fixture.ts @@ -1,10 +1,9 @@ -import { copyFileSync, existsSync, linkSync, mkdirSync, readFileSync, readdirSync } from 'node:fs' +import { copyFileSync, existsSync, linkSync, mkdirSync, readdirSync } from 'node:fs' import { copyFile, link, mkdir, writeFile } from 'node:fs/promises' import { createRequire } from 'node:module' import type * as NodePty from 'node-pty' import { join, resolve } from 'node:path' import { it } from 'vitest' -import { runProcessSync } from '@orca/process-host' import { NODE_RUNTIME_ASSETS, SERVER_TARGETS, @@ -42,21 +41,6 @@ export function locatePinnedNodeForTests(): string | null { ) } -const crossRuntimeBunVersion = readFileSync( - new URL('../../../../../config/.bun-version', import.meta.url), - 'utf8' -).trim() - -export function locateBunForTests(): string | null { - const candidate = process.env.BUN_EXECUTABLE ?? 'bun' - try { - const result = runProcessSync({ program: candidate, args: ['--version'], timeoutMs: 10_000 }) - return result.code === 0 && result.stdout.trim() === crossRuntimeBunVersion ? candidate : null - } catch { - return null - } -} - export async function writeNodeSlotFixture( root: string, pinnedNode: string diff --git a/tests/e2e/computer-windows-store.e2e.ts b/tests/e2e/computer-windows-store.e2e.ts deleted file mode 100644 index 0c13828021e..00000000000 --- a/tests/e2e/computer-windows-store.e2e.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { describe, expect, test } from 'vitest' -import type { ComputerListAppsResult, ComputerSnapshotResult } from '../../src/shared/runtime-types' -import { - ensureOrcaRuntimeLaunched, - findRoleIndex, - parseJsonOutput, - runOrcaCli, - stopOrcaRuntime -} from './helpers/computer-driver' - -const isWindows = process.platform === 'win32' -const e2eOptIn = process.env.ORCA_COMPUTER_E2E === '1' - -describe.skipIf(!isWindows || !e2eOptIn)('computer-use Windows e2e (Calculator)', () => { - test('Calculator windows are discoverable by title and clickable', async () => { - await ensureOrcaRuntimeLaunched() - await launchCalculator() - try { - const apps = parseJsonOutput<{ result: ComputerListAppsResult }>( - (await runOrcaCli(['computer', 'list-apps', '--json'])).stdout - ) - // Windows 2025 hosts Calculator as win32calc; older images use ApplicationFrameHost. - const calculatorApp = apps.result.apps.find( - (app) => - (app.name === 'Calculator' && app.bundleId === 'ApplicationFrameHost') || - (app.name === 'win32calc' && app.bundleId === 'win32calc') - ) - expect(calculatorApp).toMatchObject({ isRunning: true }) - - const state = parseJsonOutput<{ result: ComputerSnapshotResult }>( - ( - await runOrcaCli([ - 'computer', - 'get-app-state', - '--app', - 'Calculator', - '--no-screenshot', - '--json' - ]) - ).stdout - ) - const buttonIndex = findRoleIndex( - state.result.snapshot.treeText, - /^\s*(\d+)\s+button(?:\s|$)/m - ) - // Classic Calculator exposes only pane nodes; clicking one still proves title routing. - const clickIndex = - buttonIndex >= 0 - ? buttonIndex - : findRoleIndex(state.result.snapshot.treeText, /^\s*(\d+)\s+pane(?:\s|$)/m) - expect(clickIndex, state.result.snapshot.treeText).toBeGreaterThanOrEqual(0) - const clicked = parseJsonOutput<{ result: ComputerSnapshotResult }>( - ( - await runOrcaCli([ - 'computer', - 'click', - '--app', - 'Calculator', - '--element-index', - String(clickIndex), - '--no-screenshot', - '--json' - ]) - ).stdout - ) - expect(clicked.result.snapshot.elementCount).toBeGreaterThan(0) - } finally { - await killCalculator() - await stopOrcaRuntime() - } - }) -}) - -async function launchCalculator(): Promise { - await runPowerShell('Start-Process calc.exe') - await runPowerShell( - [ - '$deadline = (Get-Date).AddSeconds(15)', - '$target = $null', - 'while ((Get-Date) -lt $deadline -and $null -eq $target) {', - ' Start-Sleep -Milliseconds 250', - ' $target = Get-Process |', - ' Where-Object { $_.MainWindowHandle -ne 0 -and $_.MainWindowTitle -eq "Calculator" } |', - ' Select-Object -First 1', - '}', - 'if ($null -eq $target) { throw "No visible Calculator window found" }' - ].join('\n') - ) -} - -async function killCalculator(): Promise { - // Why: teardown is best-effort so cleanup noise cannot mask assertion signal. - await runPowerShell( - [ - '$processes = @()', - '$processes += Get-Process -Name CalculatorApp -ErrorAction SilentlyContinue', - '$processes += Get-Process -Name win32calc -ErrorAction SilentlyContinue', - '$processes += Get-Process -Name ApplicationFrameHost -ErrorAction SilentlyContinue |', - ' Where-Object { $_.MainWindowTitle -eq "Calculator" }', - 'foreach ($process in $processes) {', - ' try { Stop-Process -Id $process.Id -Force -ErrorAction Stop } catch { }', - '}', - 'exit 0' - ].join('\n') - ).catch(() => undefined) -} - -async function runPowerShell(script: string): Promise { - const { execFile } = await import('node:child_process') - await new Promise((resolve, reject) => { - execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], (error) => { - if (error) { - reject(error) - return - } - resolve() - }) - }) -} diff --git a/tests/e2e/cross-version-wire/cross-version-stop-note-scenarios.ts b/tests/e2e/cross-version-wire/cross-version-stop-note-scenarios.ts index 4a483ba849c..655b92c92a7 100644 --- a/tests/e2e/cross-version-wire/cross-version-stop-note-scenarios.ts +++ b/tests/e2e/cross-version-wire/cross-version-stop-note-scenarios.ts @@ -17,40 +17,36 @@ export function describeReleasedStopNoteProjection(port: ScenarioPort): void { const rig = createReleasedStopNoteRig(port, ref) const { page, subscribe, events, reduce, render, turn, note, seed } = rig - it.each([false, true])( - 'renders the same-revision note with a live turn end (lifecycle batch=%s)', - async (batch) => { - const before = await seed() - expect(render(before)).toMatchObject({ failure: { kind: 'cancelUnconfirmed' } }) - const prior = before.items.find((item) => item.itemId === noteId)! - const replies = await subscribe() - replies.length = 0 - await turn('interrupted', batch) - const event = events(replies).find( - (event) => - event.type === 'batch' && event.batch.items.some((item) => item.itemId === noteId) - ) - expect(event).toMatchObject({ - type: 'batch', - batch: { - items: expect.arrayContaining([ - expect.objectContaining({ itemId: turnItemId }), - expect.objectContaining({ ...prior, body: ordinary }) - ]), - removedItemIds: [] - } - }) - const after = reduce(before, replies) - expect(render(after)).toEqual({ type: 'text', text: ordinary.text }) - expect(rig.journal.itemBody(noteId)).toEqual(unconfirmed) - // The turn end is one saved sequence that touches only the turn, whichever writer made it. - const since = rig.journal.readSince(before.cursor!) - expect(since).toMatchObject({ - ok: true, - rows: [expect.objectContaining({ kind: 'outcome', itemIds: [turnItemId] })] - }) - } - ) + it('renders the same-revision note with a live turn end', async () => { + const before = await seed() + expect(render(before)).toMatchObject({ failure: { kind: 'cancelUnconfirmed' } }) + const prior = before.items.find((item) => item.itemId === noteId)! + const replies = await subscribe() + replies.length = 0 + await turn('interrupted', true) + const event = events(replies).find( + (event) => + event.type === 'batch' && event.batch.items.some((item) => item.itemId === noteId) + ) + expect(event).toMatchObject({ + type: 'batch', + batch: { + items: expect.arrayContaining([ + expect.objectContaining({ itemId: turnItemId }), + expect.objectContaining({ ...prior, body: ordinary }) + ]), + removedItemIds: [] + } + }) + const after = reduce(before, replies) + expect(render(after)).toEqual({ type: 'text', text: ordinary.text }) + expect(rig.journal.itemBody(noteId)).toEqual(unconfirmed) + const since = rig.journal.readSince(before.cursor!) + expect(since).toMatchObject({ + ok: true, + rows: [expect.objectContaining({ kind: 'outcome', itemIds: [turnItemId] })] + }) + }) it('refreshes a non-upgrade stale page after an out-of-window live correction at the terminal cursor', async () => { await turn('running') await note() From 2f9b122201e1b6c3bde7935c6c2b5df86acf517d Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 13 Nov 2026 04:23:13 -0800 Subject: [PATCH 18/55] test: remove native smoke and duplicate input checks (#42007) * test: remove repeated focused Linux text insertion * test: remove repeated foreground TextEdit replacement * test: remove Notepad smoke and duplicate paste coverage * test: drop repeated numeric row and scroll-work counters --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../AttributeValueCoercionTests.swift | 1 - .../ElementActionabilityTests.swift | 6 - tests/e2e/computer-linux.e2e.ts | 23 ---- tests/e2e/computer-mac.e2e.ts | 72 ----------- tests/e2e/computer-windows.e2e.ts | 115 ++---------------- tests/e2e/helpers/notepad-document.ts | 22 +--- 6 files changed, 14 insertions(+), 225 deletions(-) diff --git a/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/AttributeValueCoercionTests.swift b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/AttributeValueCoercionTests.swift index 947a4c1da61..2eb42dd2ee1 100644 --- a/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/AttributeValueCoercionTests.swift +++ b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/AttributeValueCoercionTests.swift @@ -24,7 +24,6 @@ final class AttributeValueCoercionTests: XCTestCase { ("9.007199254740993e15", 9_007_199_254_740_993), ("10.0e-1", 1), ("+0.000E999999999999999999999", 0), - ("-.0e-999999999999999999999", 0), ] for (requested, expected) in cases { diff --git a/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/ElementActionabilityTests.swift b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/ElementActionabilityTests.swift index 92a411fa7b2..fc8b629438d 100644 --- a/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/ElementActionabilityTests.swift +++ b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/ElementActionabilityTests.swift @@ -19,7 +19,6 @@ final class ElementActionabilityTests: XCTestCase { var nodes: [Int: Node] var retriesLeft = 5 var onRetry: (FakeTree) -> Void = { _ in } - private(set) var scrollRequests = 0 init(_ nodes: [Int: Node]) { self.nodes = nodes @@ -49,7 +48,6 @@ final class ElementActionabilityTests: XCTestCase { func scrollToVisible(_ element: Int) throws -> Bool { guard let target = nodes[element]?.scrollsTo else { return false } - scrollRequests += 1 nodes[element]?.frame = target return true } @@ -100,7 +98,6 @@ final class ElementActionabilityTests: XCTestCase { guard case let .ready(point) = try ElementActionability.pointerTarget(3, windowFrame: window, ax: tree) else { return XCTFail("the button should be clickable once scrolled into view") } - XCTAssertEqual(tree.scrollRequests, 1) XCTAssertEqual(point, CGPoint(x: 50, y: 160)) } @@ -123,7 +120,6 @@ final class ElementActionabilityTests: XCTestCase { guard case let .ready(point) = try ElementActionability.pointerTarget(3, windowFrame: window, ax: tree) else { return XCTFail("the visible part of the group should take the click") } - XCTAssertEqual(tree.scrollRequests, 0) XCTAssertEqual(point, CGPoint(x: 200, y: 150)) } @@ -149,7 +145,6 @@ final class ElementActionabilityTests: XCTestCase { guard case let .ready(point) = try ElementActionability.pointerTarget(3, windowFrame: window, ax: tree) else { return XCTFail("the row should be clicked once scrolled out from under the header") } - XCTAssertEqual(tree.scrollRequests, 1) XCTAssertEqual(point, CGPoint(x: 50, y: 50)) } @@ -202,7 +197,6 @@ final class ElementActionabilityTests: XCTestCase { guard case let .ready(point) = try ElementActionability.pointerTarget(3, windowFrame: window, ax: tree) else { return XCTFail("the link should be clicked once scrolled into its container's view") } - XCTAssertEqual(tree.scrollRequests, 1) XCTAssertEqual(point, CGPoint(x: 50, y: 60)) } diff --git a/tests/e2e/computer-linux.e2e.ts b/tests/e2e/computer-linux.e2e.ts index 8da3c487d04..e001eefd01a 100644 --- a/tests/e2e/computer-linux.e2e.ts +++ b/tests/e2e/computer-linux.e2e.ts @@ -76,29 +76,6 @@ describe.skipIf(!isLinux || !e2eOptIn)('computer-use Linux e2e (gedit)', () => { await killGedit() }) - test('click and type-text insert into the focused document through AT-SPI', async () => { - await focusGeditDocument() - - const marker = ` orca-linux-type-${Date.now()}-élan-🚀` - const typed = parseJsonOutput<{ result: ComputerActionResult }>( - ( - await runOrcaCli([ - 'computer', - 'type-text', - '--app', - 'gedit', - '--text', - marker, - '--restore-window', - '--no-screenshot', - '--json' - ]) - ).stdout - ) - expect(typed.result.action?.path).toBe('accessibility') - expect(typed.result.snapshot.treeText).toContain(marker.trim()) - }) - test('paste-text mutates the test-owned document', async () => { await focusGeditDocument() diff --git a/tests/e2e/computer-mac.e2e.ts b/tests/e2e/computer-mac.e2e.ts index 15781119242..2f289b1845a 100644 --- a/tests/e2e/computer-mac.e2e.ts +++ b/tests/e2e/computer-mac.e2e.ts @@ -74,78 +74,6 @@ describe.skipIf(!isMac || !e2eOptIn)('computer-use macOS e2e (TextEdit)', () => }) }) - test('paste-text and hotkey verify TextEdit text replacement', async () => { - const first = parseJsonOutput<{ result: ComputerActionResult }>( - ( - await runOrcaCli([ - 'computer', - 'paste-text', - '--app', - 'TextEdit', - '--text', - 'orca paste first', - '--no-screenshot', - '--json' - ]) - ).stdout - ) - expect(first.result.action?.path).toBe('accessibility') - expect(first.result.action?.verification?.state).toBe('verified') - - const selectAll = parseJsonOutput<{ result: ComputerActionResult }>( - ( - await runOrcaCli([ - 'computer', - 'hotkey', - '--app', - 'TextEdit', - '--key', - 'CmdOrCtrl+A', - '--no-screenshot', - '--json' - ]) - ).stdout - ) - expect(selectAll.result.action?.actionName).toBe('AXSelectAll') - expect(selectAll.result.action?.verification?.state).toBe('verified') - - const marker = `orca paste final ${Date.now()}` - const second = parseJsonOutput<{ result: ComputerActionResult }>( - ( - await runOrcaCli([ - 'computer', - 'paste-text', - '--app', - 'TextEdit', - '--text', - marker, - '--no-screenshot', - '--json' - ]) - ).stdout - ) - expect(second.result.action?.actionName).toBe('AXReplaceSelection') - expect(second.result.action?.verification).toEqual({ - state: 'verified', - property: 'focusedText' - }) - - const after = parseJsonOutput<{ result: ComputerSnapshotResult }>( - ( - await runOrcaCli([ - 'computer', - 'get-app-state', - '--app', - 'TextEdit', - '--no-screenshot', - '--json' - ]) - ).stdout - ) - expect(after.result.snapshot.treeText).toContain(marker) - expect(after.result.snapshot.treeText).not.toContain('orca paste first') - }) - test('accessibility text actions work when TextEdit is not frontmost', async () => { const before = parseJsonOutput<{ result: ComputerSnapshotResult }>( ( diff --git a/tests/e2e/computer-windows.e2e.ts b/tests/e2e/computer-windows.e2e.ts index 1ab45b697cf..88ac6293d68 100644 --- a/tests/e2e/computer-windows.e2e.ts +++ b/tests/e2e/computer-windows.e2e.ts @@ -15,11 +15,7 @@ import { runOrcaCli, stopOrcaRuntime } from './helpers/computer-driver' -import { - editableRolePattern, - focusNotepadDocument, - waitForNotepadText -} from './helpers/notepad-document' +import { editableRolePattern, focusNotepadDocument } from './helpers/notepad-document' import { closeWindowFixture, countMagentaPixelRatio, @@ -40,83 +36,6 @@ describe.skipIf(!isWindows || !e2eOptIn)('computer-use Windows e2e (Notepad)', ( await stopOrcaRuntime() }) - test('list-apps includes the test-owned Notepad process', async () => { - const result = await runOrcaCli(['computer', 'list-apps', '--json']) - const envelope = parseJsonOutput<{ result: ComputerListAppsResult }>(result.stdout) - const pid = Number.parseInt(getNotepadAppSelector().slice(4), 10) - const notepadApp = envelope.result.apps.find((app) => app.pid === pid) - - expect(notepadApp).toMatchObject({ pid, bundleId: 'notepad' }) - expect(notepadApp?.name.toLowerCase()).toBe('notepad') - }) - - test('list-windows returns a targetable Notepad window', async () => { - const app = getNotepadAppSelector() - const result = await runOrcaCli(['computer', 'list-windows', '--app', app, '--json']) - const envelope = parseJsonOutput<{ result: ComputerListWindowsResult }>(result.stdout) - - expect(envelope.result.windows).toHaveLength(1) - expect(envelope.result.windows[0]).toMatchObject({ - index: 0, - app: expect.objectContaining({ pid: Number.parseInt(app.slice(4), 10) }), - id: expect.any(Number), - title: expect.stringContaining('Notepad'), - width: expect.any(Number), - height: expect.any(Number) - }) - }) - - test('Notepad exposes a basic accessibility tree', async () => { - const result = await runOrcaCli([ - 'computer', - 'get-app-state', - '--app', - getNotepadAppSelector(), - '--json' - ]) - const envelope = parseJsonOutput<{ result: ComputerSnapshotResult }>(result.stdout) - - expect(envelope.result.snapshot.app.name.toLowerCase()).toBe('notepad') - expect(envelope.result.snapshot.window.title).toContain('Notepad') - expect(envelope.result.snapshot.elementCount).toBeGreaterThan(0) - expect(envelope.result.snapshot.coordinateSpace).toBe('window') - expect(envelope.result.snapshot.truncation?.truncated).toBe(false) - expect(envelope.result.screenshotStatus.state).toBe('captured') - expect(envelope.result.screenshot?.format).toBe('png') - expect(envelope.result.screenshot?.data).toBeUndefined() - expect(envelope.result.screenshot?.dataOmitted).toBe(true) - expect(envelope.result.screenshot?.path).toContain('orca-computer-use') - }) - - test('paste-text mutates the test-owned document', async () => { - const app = getNotepadAppSelector() - const marker = `orca-windows-paste-${Date.now()}` - await focusNotepadDocument(app) - const action = parseJsonOutput<{ result: ComputerActionResult }>( - ( - await runOrcaCli([ - 'computer', - 'paste-text', - '--app', - app, - '--text', - marker, - '--restore-window', - '--no-screenshot', - '--json' - ]) - ).stdout - ) - expect(action.result.action?.path).toBe('clipboard') - expect(action.result.action?.verification).toMatchObject({ - state: 'unverified', - reason: 'clipboard_paste' - }) - - const after = await waitForNotepadText(app, marker) - expect(after.snapshot.treeText).toContain(marker) - }) - test('Unicode payloads survive paste-text', async () => { const app = getNotepadAppSelector() const unicode = `orca unicode café Ω 漢字 ${Date.now()}` @@ -155,22 +74,17 @@ describe.skipIf(!isWindows || !e2eOptIn)('computer-use Windows e2e (Notepad)', ( '--json' ]) - const selectAll = parseJsonOutput<{ result: ComputerActionResult }>( - ( - await runOrcaCli([ - 'computer', - 'hotkey', - '--app', - app, - '--key', - 'CmdOrCtrl+A', - '--restore-window', - '--no-screenshot', - '--json' - ]) - ).stdout - ) - expect(selectAll.result.action?.actionName).toBe('hotkey') + await runOrcaCli([ + 'computer', + 'hotkey', + '--app', + app, + '--key', + 'CmdOrCtrl+A', + '--restore-window', + '--no-screenshot', + '--json' + ]) const marker = `orca-windows-replaced-${Date.now()}` const second = parseJsonOutput<{ result: ComputerActionResult }>( @@ -272,10 +186,9 @@ describe.skipIf(!isWindows || !e2eOptIn)('computer-use Windows e2e (Notepad)', ( } }) - test('a hung window fails fast with a timeout error and does not stall later calls', async () => { + test('a hung window reports a timeout error and later calls still succeed', async () => { const hung = await launchHungWindowFixture() try { - const started = Date.now() const failure = await runOrcaCli([ 'computer', 'get-app-state', @@ -289,8 +202,6 @@ describe.skipIf(!isWindows || !e2eOptIn)('computer-use Windows e2e (Notepad)', ( ) expect(failure).toMatch(/window_not_responding/) expect(failure).toMatch(/"code":\s*"action_timeout"/) - // The provider request timeout is 30 s; the hang probe answers within its 5 s bound. - expect(Date.now() - started).toBeLessThan(15_000) const apps = parseJsonOutput<{ result: ComputerListAppsResult }>( (await runOrcaCli(['computer', 'list-apps', '--json'])).stdout diff --git a/tests/e2e/helpers/notepad-document.ts b/tests/e2e/helpers/notepad-document.ts index 3b734cecf1b..b3f1b31c9de 100644 --- a/tests/e2e/helpers/notepad-document.ts +++ b/tests/e2e/helpers/notepad-document.ts @@ -1,28 +1,8 @@ -import { expect, vi } from 'vitest' +import { expect } from 'vitest' import type { ComputerSnapshotResult } from '../../../src/shared/runtime-types' import { findRoleIndex, parseJsonOutput, runOrcaCli } from './computer-driver' export const editableRolePattern = /^\s*(\d+)\s+(document|edit|text|pane)(?:\s|$)/im -const pasteMutationTimeoutMs = 5_000 - -// Poll after clipboard paste because Notepad’s accessibility snapshot can lag the visible change. -export async function waitForNotepadText( - app: string, - marker: string -): Promise { - return vi.waitFor( - async () => { - const snapshot = parseJsonOutput<{ result: ComputerSnapshotResult }>( - (await runOrcaCli(['computer', 'get-app-state', '--app', app, '--no-screenshot', '--json'])) - .stdout - ).result - expect(snapshot.snapshot.treeText).toContain(marker) - return snapshot - }, - { timeout: pasteMutationTimeoutMs, interval: 150 } - ) -} - export async function focusNotepadDocument(app: string): Promise { const snapshot = parseJsonOutput<{ result: ComputerSnapshotResult }>( ( From 1f37db301cf341dfe852d7baaf32a296febe8c2c Mon Sep 17 00:00:00 2001 From: "buf0-bot[bot]" <252831055+buf0-bot[bot]@users.noreply.github.com> Date: Fri, 13 Nov 2026 12:25:56 +0000 Subject: [PATCH 19/55] Update README downloads badge (#42012) Co-authored-by: buf0-bot[bot] <252831055+buf0-bot[bot]@users.noreply.github.com> --- 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 b7020a28499..c085e4dac9a 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 175m + + downloads: 176m @@ -15,7 +15,7 @@ downloads downloads - 175m - 175m + 176m + 176m From e81a89c5ffe3e8bf8abcc962630ee1fdbc2139ad Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 13 Nov 2026 04:31:03 -0800 Subject: [PATCH 20/55] refactor: group Jira and Linear task-page modules by domain (#42009) * refactor: group Jira and Linear task-page modules by domain * refactor: remove orphaned Linear refresh test entry --- config/focus-setting-read-baseline.txt | 20 +- config/react-compiler-bailout-baseline.txt | 36 +- config/react-compiler-uncompiled-files.txt | 2 +- .../scripts/ci/sharding/ci-shard-timings.json | 23 +- .../check-changed-code-quality.mjs | 2 +- config/tsconfig.tc.web.json | 1 - config/tsconfig.test.pane-launch.json | 1 - config/tsconfig.web.json | 1 - package.json | 2 +- .../task-page-jira-favourite-fixture.ts | 119 ----- .../src/components/jira-key-or-text-search.ts | 2 +- .../src/components/task-page/Surface.tsx | 6 +- .../src/components/task-page/TaskPage.tsx | 30 +- .../github/issue-status/StatusCell.tsx | 2 +- .../github/listing/AssigneesCell.tsx | 2 +- .../use-task-page-github-list-pending.ts | 2 +- .../use-task-page-github-list-projection.ts | 2 +- ...sk-page-github-work-item-mutation-types.ts | 23 + .../use-task-page-github-mutation-state.ts | 2 +- .../github/pull-requests/MergeCell.tsx | 2 +- .../github/pull-requests/ReviewCell.tsx | 2 +- .../task-page-github-reviewer-actions.ts | 2 +- .../use-task-page-pr-check-hydration.ts | 2 +- .../src/components/task-page/jira/Content.tsx | 12 +- .../src/components/task-page/jira/Filters.tsx | 6 +- .../jira/IssueAssigneeField.test.tsx | 68 --- .../task-page-jira-board-move-dialog.tsx | 6 +- .../{ => boards}/task-page-jira-board-move.ts | 4 +- .../task-page-jira-board-sections.ts | 4 +- .../task-page-jira-board-transitions.ts | 4 +- .../task-page-jira-board-view.tsx | 12 +- .../{ => boards}/task-page-jira-board.tsx | 2 +- .../{ => creation}/IssueAssigneeField.tsx | 4 +- .../jira/{ => creation}/IssueDialog.tsx | 4 +- .../jira/{ => creation}/IssueTitleField.tsx | 2 +- .../jira/{ => creation}/IssueUserField.tsx | 2 +- .../task-page-jira-create-fields.ts | 2 +- .../use-task-page-jira-creation-metadata.ts | 0 .../use-task-page-jira-creation-projects.ts | 4 +- .../use-task-page-jira-creation-state.ts | 6 +- .../use-task-page-jira-issue-creation.ts | 4 +- ...task-page-jira-summary-generation.test.tsx | 4 +- .../use-task-page-jira-summary-generation.ts | 8 +- .../TaskPageJiraFavouriteFilters.tsx | 2 +- .../TaskPageJiraSavedFilters.tsx | 4 +- .../TaskPageJiraStatusFilters.tsx | 6 +- .../task-page-jira-status-catalog.ts | 6 +- .../use-task-page-jira-favourite-filters.ts | 4 +- .../use-task-page-jira-project-scope.test.tsx | 2 +- .../use-task-page-jira-project-scope.ts | 13 +- .../use-task-page-jira-quick-filters.test.tsx | 59 +++ .../use-task-page-jira-quick-filters.ts | 4 +- .../use-task-page-jira-saved-filters.test.tsx | 58 +++ .../use-task-page-jira-saved-filters.ts | 6 +- .../task-page-jira-cache-selectors.ts | 4 +- .../listing/task-page-jira-error-banner.tsx | 52 ++ .../task-page-jira-issue-cells.tsx | 4 +- .../task-page-jira-issue-list.tsx | 10 +- .../task-page-jira-linked-issues.ts | 6 +- .../task-page-jira-load-state.ts | 4 +- .../{ => listing}/task-page-jira-search.ts | 9 +- .../task-page-jira-sort-controls.tsx | 4 +- .../task-page-jira-status-order.ts | 4 +- .../task-page-jira-status-tone.ts | 0 .../task-page-jira-text-fallback-notice.tsx | 0 .../use-task-page-jira-list-effects.ts | 20 +- .../use-task-page-jira-list-projection.ts | 8 +- .../task-page-jira-board-transitions.test.ts | 93 ---- ...ask-page-jira-favourite-selection.test.tsx | 89 ---- .../jira/task-page-jira-grouping.test.ts | 93 ---- .../jira/task-page-jira-quick-copy.test.tsx | 122 ----- .../jira/use-task-page-jira-list-state.ts | 14 +- .../use-task-page-jira-quick-filters.test.tsx | 114 ----- .../use-task-page-jira-saved-filters.test.tsx | 149 ------ .../components/task-page/linear/Content.tsx | 10 +- .../components/task-page/linear/IssueRows.tsx | 9 - .../task-page/linear/IssueTable.test.tsx | 83 ---- .../linear/{ => creation}/IssueAttributes.tsx | 2 +- .../linear/{ => creation}/IssueDialog.tsx | 2 +- .../linear/{ => creation}/ProjectDialog.tsx | 2 +- .../linear/{ => creation}/ProjectFields.tsx | 2 +- .../linear-attribute-count-labels.ts | 0 .../use-task-page-linear-creation-state.ts | 6 +- .../use-task-page-linear-issue-creation.ts | 2 +- .../use-task-page-linear-project-creation.ts | 2 +- ...ask-page-linear-refresh-lifecycle.test.tsx | 58 +++ ...task-page-linear-refresh-test-fixtures.tsx | 210 +++++++++ .../linear/{ => issues}/IssueBoard.tsx | 8 +- .../linear/{ => issues}/IssueBoardColumns.tsx | 6 +- .../linear/{ => issues}/IssueList.tsx | 8 +- .../linear/{ => issues}/IssueTable.tsx | 6 +- .../linear/{ => issues}/IssueToolbar.tsx | 4 +- .../task-page-linear-cache-selectors.ts | 6 +- .../task-page-linear-in-orca-issues.test.ts | 0 .../task-page-linear-in-orca-issues.ts | 14 +- .../task-page-linear-issue-empty-state.ts | 2 +- .../issues/task-page-linear-issue-grouping.ts | 109 +++++ .../task-page-linear-issue-request.ts | 6 +- .../task-page-linear-state-cell.tsx} | 90 +--- .../task-page-linear-team-selection.ts | 2 +- .../use-task-page-linear-board.ts | 36 +- .../use-task-page-linear-filter-selection.ts | 8 +- .../use-task-page-linear-in-orca-effects.ts | 4 +- .../use-task-page-linear-list-effects.ts | 22 +- .../use-task-page-linear-list-presentation.ts | 42 +- .../use-task-page-linear-list-projection.ts | 8 +- .../use-task-page-linear-list-selection.ts | 10 +- .../linear/{ => projects}/ProjectList.tsx | 2 +- .../linear/{ => projects}/ProjectOverview.tsx | 2 +- ...use-task-page-linear-collection-effects.ts | 10 +- ...page-linear-committed-publication.test.tsx | 361 -------------- .../task-page-linear-detail-query.test.tsx | 118 ----- .../task-page-linear-jira-list-model.tsx | 199 -------- .../task-page-linear-refresh-entry.test.tsx | 190 -------- ...ask-page-linear-refresh-lifecycle.test.tsx | 197 -------- ...task-page-linear-refresh-test-fixtures.tsx | 446 ------------------ .../task-page-linear-refresh-writers.test.tsx | 140 ------ ...k-page-linear-selection-decisions.test.tsx | 98 ---- .../linear/use-task-page-linear-view-state.ts | 8 +- .../linear/{ => views}/CustomViewList.tsx | 2 +- .../linear/{ => views}/CustomViewProjects.tsx | 2 +- ...se-task-page-linear-custom-view-effects.ts | 42 +- .../use-task-page-composer-actions.ts | 2 +- .../task-page/use-task-page-detail-routing.ts | 2 +- .../task-page/use-task-page-global-effects.ts | 2 +- .../task-page/use-task-page-source-display.ts | 2 +- .../use-task-creation-draft-retention.test.ts | 401 +--------------- ...ask-page-linear-refresh-authority.test.tsx | 119 ----- ...page-linear-refresh-authority.unit.test.ts | 2 - 129 files changed, 958 insertions(+), 3575 deletions(-) delete mode 100644 src/renderer/src/components/__mocks__/task-page-jira-favourite-fixture.ts delete mode 100644 src/renderer/src/components/task-page/jira/IssueAssigneeField.test.tsx rename src/renderer/src/components/task-page/jira/{ => boards}/task-page-jira-board-move-dialog.tsx (95%) rename src/renderer/src/components/task-page/jira/{ => boards}/task-page-jira-board-move.ts (89%) rename src/renderer/src/components/task-page/jira/{ => boards}/task-page-jira-board-sections.ts (95%) rename src/renderer/src/components/task-page/jira/{ => boards}/task-page-jira-board-transitions.ts (92%) rename src/renderer/src/components/task-page/jira/{ => boards}/task-page-jira-board-view.tsx (94%) rename src/renderer/src/components/task-page/jira/{ => boards}/task-page-jira-board.tsx (99%) rename src/renderer/src/components/task-page/jira/{ => creation}/IssueAssigneeField.tsx (96%) rename src/renderer/src/components/task-page/jira/{ => creation}/IssueDialog.tsx (98%) rename src/renderer/src/components/task-page/jira/{ => creation}/IssueTitleField.tsx (97%) rename src/renderer/src/components/task-page/jira/{ => creation}/IssueUserField.tsx (92%) rename src/renderer/src/components/task-page/jira/{ => creation}/task-page-jira-create-fields.ts (98%) rename src/renderer/src/components/task-page/jira/{ => creation}/use-task-page-jira-creation-metadata.ts (100%) rename src/renderer/src/components/task-page/jira/{ => creation}/use-task-page-jira-creation-projects.ts (98%) rename src/renderer/src/components/task-page/jira/{ => creation}/use-task-page-jira-creation-state.ts (97%) rename src/renderer/src/components/task-page/jira/{ => creation}/use-task-page-jira-issue-creation.ts (96%) rename src/renderer/src/components/task-page/jira/{ => creation}/use-task-page-jira-summary-generation.test.tsx (98%) rename src/renderer/src/components/task-page/jira/{ => creation}/use-task-page-jira-summary-generation.ts (96%) rename src/renderer/src/components/task-page/jira/{ => filters}/TaskPageJiraFavouriteFilters.tsx (98%) rename src/renderer/src/components/task-page/jira/{ => filters}/TaskPageJiraSavedFilters.tsx (98%) rename src/renderer/src/components/task-page/jira/{ => filters}/TaskPageJiraStatusFilters.tsx (94%) rename src/renderer/src/components/task-page/jira/{ => filters}/task-page-jira-status-catalog.ts (87%) rename src/renderer/src/components/task-page/jira/{ => filters}/use-task-page-jira-favourite-filters.ts (96%) rename src/renderer/src/components/task-page/jira/{ => filters}/use-task-page-jira-project-scope.test.tsx (97%) rename src/renderer/src/components/task-page/jira/{ => filters}/use-task-page-jira-project-scope.ts (79%) create mode 100644 src/renderer/src/components/task-page/jira/filters/use-task-page-jira-quick-filters.test.tsx rename src/renderer/src/components/task-page/jira/{ => filters}/use-task-page-jira-quick-filters.ts (96%) create mode 100644 src/renderer/src/components/task-page/jira/filters/use-task-page-jira-saved-filters.test.tsx rename src/renderer/src/components/task-page/jira/{ => filters}/use-task-page-jira-saved-filters.ts (96%) rename src/renderer/src/components/task-page/jira/{ => listing}/task-page-jira-cache-selectors.ts (91%) create mode 100644 src/renderer/src/components/task-page/jira/listing/task-page-jira-error-banner.tsx rename src/renderer/src/components/task-page/jira/{ => listing}/task-page-jira-issue-cells.tsx (97%) rename src/renderer/src/components/task-page/jira/{ => listing}/task-page-jira-issue-list.tsx (97%) rename src/renderer/src/components/task-page/jira/{ => listing}/task-page-jira-linked-issues.ts (89%) rename src/renderer/src/components/task-page/jira/{ => listing}/task-page-jira-load-state.ts (95%) rename src/renderer/src/components/task-page/jira/{ => listing}/task-page-jira-search.ts (87%) rename src/renderer/src/components/task-page/jira/{ => listing}/task-page-jira-sort-controls.tsx (98%) rename src/renderer/src/components/task-page/jira/{ => listing}/task-page-jira-status-order.ts (95%) rename src/renderer/src/components/task-page/jira/{ => listing}/task-page-jira-status-tone.ts (100%) rename src/renderer/src/components/task-page/jira/{ => listing}/task-page-jira-text-fallback-notice.tsx (100%) rename src/renderer/src/components/task-page/jira/{ => listing}/use-task-page-jira-list-effects.ts (92%) rename src/renderer/src/components/task-page/jira/{ => listing}/use-task-page-jira-list-projection.ts (93%) delete mode 100644 src/renderer/src/components/task-page/jira/task-page-jira-board-transitions.test.ts delete mode 100644 src/renderer/src/components/task-page/jira/task-page-jira-favourite-selection.test.tsx delete mode 100644 src/renderer/src/components/task-page/jira/task-page-jira-grouping.test.ts delete mode 100644 src/renderer/src/components/task-page/jira/task-page-jira-quick-copy.test.tsx delete mode 100644 src/renderer/src/components/task-page/jira/use-task-page-jira-quick-filters.test.tsx delete mode 100644 src/renderer/src/components/task-page/jira/use-task-page-jira-saved-filters.test.tsx delete mode 100644 src/renderer/src/components/task-page/linear/IssueRows.tsx delete mode 100644 src/renderer/src/components/task-page/linear/IssueTable.test.tsx rename src/renderer/src/components/task-page/linear/{ => creation}/IssueAttributes.tsx (99%) rename src/renderer/src/components/task-page/linear/{ => creation}/IssueDialog.tsx (98%) rename src/renderer/src/components/task-page/linear/{ => creation}/ProjectDialog.tsx (98%) rename src/renderer/src/components/task-page/linear/{ => creation}/ProjectFields.tsx (99%) rename src/renderer/src/components/task-page/linear/{ => creation}/linear-attribute-count-labels.ts (100%) rename src/renderer/src/components/task-page/linear/{ => creation}/use-task-page-linear-creation-state.ts (98%) rename src/renderer/src/components/task-page/linear/{ => creation}/use-task-page-linear-issue-creation.ts (99%) rename src/renderer/src/components/task-page/linear/{ => creation}/use-task-page-linear-project-creation.ts (97%) create mode 100644 src/renderer/src/components/task-page/linear/issue-refresh/task-page-linear-refresh-lifecycle.test.tsx create mode 100644 src/renderer/src/components/task-page/linear/issue-refresh/task-page-linear-refresh-test-fixtures.tsx rename src/renderer/src/components/task-page/linear/{ => issues}/IssueBoard.tsx (96%) rename src/renderer/src/components/task-page/linear/{ => issues}/IssueBoardColumns.tsx (98%) rename src/renderer/src/components/task-page/linear/{ => issues}/IssueList.tsx (95%) rename src/renderer/src/components/task-page/linear/{ => issues}/IssueTable.tsx (98%) rename src/renderer/src/components/task-page/linear/{ => issues}/IssueToolbar.tsx (98%) rename src/renderer/src/components/task-page/linear/{ => issues}/task-page-linear-cache-selectors.ts (84%) rename src/renderer/src/components/task-page/linear/{ => issues}/task-page-linear-in-orca-issues.test.ts (100%) rename src/renderer/src/components/task-page/linear/{ => issues}/task-page-linear-in-orca-issues.ts (93%) rename src/renderer/src/components/task-page/linear/{ => issues}/task-page-linear-issue-empty-state.ts (94%) create mode 100644 src/renderer/src/components/task-page/linear/issues/task-page-linear-issue-grouping.ts rename src/renderer/src/components/task-page/linear/{ => issues}/task-page-linear-issue-request.ts (92%) rename src/renderer/src/components/task-page/linear/{task-page-linear-issue-model.tsx => issues/task-page-linear-state-cell.tsx} (69%) rename src/renderer/src/components/task-page/linear/{ => issues}/task-page-linear-team-selection.ts (91%) rename src/renderer/src/components/task-page/linear/{ => issues}/use-task-page-linear-board.ts (88%) rename src/renderer/src/components/task-page/linear/{ => issues}/use-task-page-linear-filter-selection.ts (92%) rename src/renderer/src/components/task-page/linear/{ => issues}/use-task-page-linear-in-orca-effects.ts (96%) rename src/renderer/src/components/task-page/linear/{ => issues}/use-task-page-linear-list-effects.ts (92%) rename src/renderer/src/components/task-page/linear/{ => issues}/use-task-page-linear-list-presentation.ts (76%) rename src/renderer/src/components/task-page/linear/{ => issues}/use-task-page-linear-list-projection.ts (97%) rename src/renderer/src/components/task-page/linear/{ => issues}/use-task-page-linear-list-selection.ts (96%) rename src/renderer/src/components/task-page/linear/{ => projects}/ProjectList.tsx (96%) rename src/renderer/src/components/task-page/linear/{ => projects}/ProjectOverview.tsx (96%) rename src/renderer/src/components/task-page/linear/{ => projects}/use-task-page-linear-collection-effects.ts (93%) delete mode 100644 src/renderer/src/components/task-page/linear/task-page-linear-committed-publication.test.tsx delete mode 100644 src/renderer/src/components/task-page/linear/task-page-linear-detail-query.test.tsx delete mode 100644 src/renderer/src/components/task-page/linear/task-page-linear-jira-list-model.tsx delete mode 100644 src/renderer/src/components/task-page/linear/task-page-linear-refresh-entry.test.tsx delete mode 100644 src/renderer/src/components/task-page/linear/task-page-linear-refresh-lifecycle.test.tsx delete mode 100644 src/renderer/src/components/task-page/linear/task-page-linear-refresh-test-fixtures.tsx delete mode 100644 src/renderer/src/components/task-page/linear/task-page-linear-refresh-writers.test.tsx delete mode 100644 src/renderer/src/components/task-page/linear/task-page-linear-selection-decisions.test.tsx rename src/renderer/src/components/task-page/linear/{ => views}/CustomViewList.tsx (96%) rename src/renderer/src/components/task-page/linear/{ => views}/CustomViewProjects.tsx (97%) rename src/renderer/src/components/task-page/linear/{ => views}/use-task-page-linear-custom-view-effects.ts (86%) delete mode 100644 tests/e2e/task-page-linear-refresh-authority.test.tsx delete mode 100644 tests/e2e/task-page-linear-refresh-authority.unit.test.ts diff --git a/config/focus-setting-read-baseline.txt b/config/focus-setting-read-baseline.txt index 84559a65d66..491e47fa748 100644 --- a/config/focus-setting-read-baseline.txt +++ b/config/focus-setting-read-baseline.txt @@ -156,18 +156,18 @@ 1 src/renderer/src/components/tab-bar/windows-shell-menu-visibility.ts 3 src/renderer/src/components/tab-group/useTabGroupCreationCommands.ts 11 src/renderer/src/components/task-page/TaskPage.tsx -2 src/renderer/src/components/task-page/jira/IssueAssigneeField.tsx -1 src/renderer/src/components/task-page/jira/task-page-jira-status-order.ts -4 src/renderer/src/components/task-page/jira/use-task-page-jira-creation-metadata.ts -4 src/renderer/src/components/task-page/jira/use-task-page-jira-issue-creation.ts -1 src/renderer/src/components/task-page/jira/use-task-page-jira-list-effects.ts +2 src/renderer/src/components/task-page/jira/creation/IssueAssigneeField.tsx +1 src/renderer/src/components/task-page/jira/listing/task-page-jira-status-order.ts +4 src/renderer/src/components/task-page/jira/creation/use-task-page-jira-creation-metadata.ts +4 src/renderer/src/components/task-page/jira/creation/use-task-page-jira-issue-creation.ts +1 src/renderer/src/components/task-page/jira/listing/use-task-page-jira-list-effects.ts 1 src/renderer/src/components/task-page/linear/Filters.tsx -4 src/renderer/src/components/task-page/linear/task-page-linear-issue-model.tsx -8 src/renderer/src/components/task-page/linear/use-task-page-linear-board.ts +4 src/renderer/src/components/task-page/linear/issues/task-page-linear-state-cell.tsx +8 src/renderer/src/components/task-page/linear/issues/use-task-page-linear-board.ts 4 src/renderer/src/components/task-page/linear/use-task-page-linear-context-state.ts -6 src/renderer/src/components/task-page/linear/use-task-page-linear-creation-state.ts -4 src/renderer/src/components/task-page/linear/use-task-page-linear-issue-creation.ts -2 src/renderer/src/components/task-page/linear/use-task-page-linear-project-creation.ts +6 src/renderer/src/components/task-page/linear/creation/use-task-page-linear-creation-state.ts +4 src/renderer/src/components/task-page/linear/creation/use-task-page-linear-issue-creation.ts +2 src/renderer/src/components/task-page/linear/creation/use-task-page-linear-project-creation.ts 5 src/renderer/src/components/task-page/linear/use-task-page-linear-view-state.ts 3 src/renderer/src/components/task-page/use-task-page-detail-routing.ts 2 src/renderer/src/components/task-page/use-task-page-provider-metadata.ts diff --git a/config/react-compiler-bailout-baseline.txt b/config/react-compiler-bailout-baseline.txt index a52ba10321a..62b0b0c36d6 100644 --- a/config/react-compiler-bailout-baseline.txt +++ b/config/react-compiler-bailout-baseline.txt @@ -592,26 +592,26 @@ 3 src/renderer/src/components/task-page/github/listing/use-task-page-github-search-pagination.ts 4 src/renderer/src/components/task-page/github/cache/use-task-page-work-items-cache-selection.ts 1 src/renderer/src/components/task-page/gitlab/use-task-page-gitlab-loading.ts -2 src/renderer/src/components/task-page/jira/task-page-jira-board-view.tsx -1 src/renderer/src/components/task-page/jira/use-task-page-jira-creation-projects.ts -33 src/renderer/src/components/task-page/jira/use-task-page-jira-creation-state.ts -2 src/renderer/src/components/task-page/jira/use-task-page-jira-favourite-filters.ts -2 src/renderer/src/components/task-page/jira/use-task-page-jira-issue-creation.ts -1 src/renderer/src/components/task-page/jira/use-task-page-jira-list-effects.ts -2 src/renderer/src/components/task-page/jira/use-task-page-jira-quick-filters.ts -2 src/renderer/src/components/task-page/jira/use-task-page-jira-saved-filters.ts -2 src/renderer/src/components/task-page/jira/use-task-page-jira-summary-generation.ts -1 src/renderer/src/components/task-page/linear/IssueTable.tsx -7 src/renderer/src/components/task-page/linear/use-task-page-linear-board.ts +2 src/renderer/src/components/task-page/jira/boards/task-page-jira-board-view.tsx +1 src/renderer/src/components/task-page/jira/creation/use-task-page-jira-creation-projects.ts +33 src/renderer/src/components/task-page/jira/creation/use-task-page-jira-creation-state.ts +2 src/renderer/src/components/task-page/jira/filters/use-task-page-jira-favourite-filters.ts +2 src/renderer/src/components/task-page/jira/creation/use-task-page-jira-issue-creation.ts +1 src/renderer/src/components/task-page/jira/listing/use-task-page-jira-list-effects.ts +2 src/renderer/src/components/task-page/jira/filters/use-task-page-jira-quick-filters.ts +2 src/renderer/src/components/task-page/jira/filters/use-task-page-jira-saved-filters.ts +2 src/renderer/src/components/task-page/jira/creation/use-task-page-jira-summary-generation.ts +1 src/renderer/src/components/task-page/linear/issues/IssueTable.tsx +7 src/renderer/src/components/task-page/linear/issues/use-task-page-linear-board.ts 3 src/renderer/src/components/task-page/linear/use-task-page-linear-context-actions.ts 1 src/renderer/src/components/task-page/linear/use-task-page-linear-context-state.ts -4 src/renderer/src/components/task-page/linear/use-task-page-linear-filter-selection.ts -1 src/renderer/src/components/task-page/linear/use-task-page-linear-issue-creation.ts -1 src/renderer/src/components/task-page/linear/use-task-page-linear-list-effects.ts -6 src/renderer/src/components/task-page/linear/use-task-page-linear-list-presentation.ts -13 src/renderer/src/components/task-page/linear/use-task-page-linear-list-projection.ts -20 src/renderer/src/components/task-page/linear/use-task-page-linear-list-selection.ts -2 src/renderer/src/components/task-page/linear/use-task-page-linear-project-creation.ts +4 src/renderer/src/components/task-page/linear/issues/use-task-page-linear-filter-selection.ts +1 src/renderer/src/components/task-page/linear/creation/use-task-page-linear-issue-creation.ts +1 src/renderer/src/components/task-page/linear/issues/use-task-page-linear-list-effects.ts +6 src/renderer/src/components/task-page/linear/issues/use-task-page-linear-list-presentation.ts +13 src/renderer/src/components/task-page/linear/issues/use-task-page-linear-list-projection.ts +20 src/renderer/src/components/task-page/linear/issues/use-task-page-linear-list-selection.ts +2 src/renderer/src/components/task-page/linear/creation/use-task-page-linear-project-creation.ts 1 src/renderer/src/components/task-page/linear/use-task-page-linear-view-state.ts 10 src/renderer/src/components/task-page/use-task-page-composer-actions.ts 3 src/renderer/src/components/task-page/use-task-page-detail-routing.ts diff --git a/config/react-compiler-uncompiled-files.txt b/config/react-compiler-uncompiled-files.txt index a78f7b1b07a..c1570bdeeab 100644 --- a/config/react-compiler-uncompiled-files.txt +++ b/config/react-compiler-uncompiled-files.txt @@ -13,7 +13,7 @@ src/renderer/src/components/settings/skills/linear/LinearAgentSkillPane.tsx src/renderer/src/components/settings/tasks/use-task-source-provider-readiness.ts src/renderer/src/components/sidebar/workspace-card/details/use-worktree-card-review-details.ts src/renderer/src/components/task-page/jira/Content.tsx -src/renderer/src/components/task-page/jira/use-task-page-jira-list-projection.ts +src/renderer/src/components/task-page/jira/listing/use-task-page-jira-list-projection.ts src/renderer/src/components/task-page/jira/use-task-page-jira-list-state.ts src/renderer/src/hooks/use-integration-provider-status-refresh.ts diff --git a/config/scripts/ci/sharding/ci-shard-timings.json b/config/scripts/ci/sharding/ci-shard-timings.json index 081ff049122..0618b09dd67 100644 --- a/config/scripts/ci/sharding/ci-shard-timings.json +++ b/config/scripts/ci/sharding/ci-shard-timings.json @@ -5092,26 +5092,16 @@ "src/renderer/src/components/task-page/github/use-task-page-github-issue-creation.test.tsx": 1769, "src/renderer/src/components/task-page/gitlab/use-task-page-gitlab-loading.test.tsx": 1504, "src/renderer/src/components/task-page/jira/Content.board-owner.test.tsx": 2582, - "src/renderer/src/components/task-page/jira/IssueAssigneeField.test.tsx": 1122, "src/renderer/src/components/task-page/jira/task-page-jira-board-detail-integration.test.tsx": 2192, - "src/renderer/src/components/task-page/jira/task-page-jira-board-transitions.test.ts": 69, "src/renderer/src/components/task-page/jira/task-page-jira-board-view.test.tsx": 2287, - "src/renderer/src/components/task-page/jira/task-page-jira-favourite-selection.test.tsx": 3326, - "src/renderer/src/components/task-page/jira/task-page-jira-grouping.test.ts": 2324, "src/renderer/src/components/task-page/jira/task-page-jira-linked-issues.test.ts": 204, - "src/renderer/src/components/task-page/jira/task-page-jira-quick-copy.test.tsx": 3457, - "src/renderer/src/components/task-page/jira/use-task-page-jira-project-scope.test.tsx": 2642, - "src/renderer/src/components/task-page/jira/use-task-page-jira-quick-filters.test.tsx": 1466, - "src/renderer/src/components/task-page/jira/use-task-page-jira-saved-filters.test.tsx": 1501, - "src/renderer/src/components/task-page/jira/use-task-page-jira-summary-generation.test.tsx": 571, - "src/renderer/src/components/task-page/linear/IssueTable.test.tsx": 11230, - "src/renderer/src/components/task-page/linear/task-page-linear-committed-publication.test.tsx": 1978, + "src/renderer/src/components/task-page/jira/filters/use-task-page-jira-project-scope.test.tsx": 2642, + "src/renderer/src/components/task-page/jira/filters/use-task-page-jira-quick-filters.test.tsx": 1466, + "src/renderer/src/components/task-page/jira/filters/use-task-page-jira-saved-filters.test.tsx": 1501, + "src/renderer/src/components/task-page/jira/creation/use-task-page-jira-summary-generation.test.tsx": 571, "src/renderer/src/components/task-page/linear/task-page-linear-creation-lifetime.test.tsx": 3315, - "src/renderer/src/components/task-page/linear/task-page-linear-in-orca-issues.test.ts": 430, - "src/renderer/src/components/task-page/linear/task-page-linear-refresh-entry.test.tsx": 2217, - "src/renderer/src/components/task-page/linear/task-page-linear-refresh-lifecycle.test.tsx": 1814, - "src/renderer/src/components/task-page/linear/task-page-linear-refresh-writers.test.tsx": 2810, - "src/renderer/src/components/task-page/linear/task-page-linear-selection-decisions.test.tsx": 1018, + "src/renderer/src/components/task-page/linear/issues/task-page-linear-in-orca-issues.test.ts": 430, + "src/renderer/src/components/task-page/linear/issue-refresh/task-page-linear-refresh-lifecycle.test.tsx": 1814, "src/renderer/src/components/task-page/task-page-cache-selectors.test.ts": 108, "src/renderer/src/components/task-page/task-page-initial-selection-scaling.test.tsx": 1953, "src/renderer/src/components/task-page/task-page-work-item-pagination.test.ts": 63, @@ -6940,7 +6930,6 @@ "tests/e2e/structured-attention-read-retirement.unit.test.ts": 6109, "tests/e2e/structured-chat-tab-lifetime.unit.test.ts": 5616, "tests/e2e/sustained-agent-typing-load-scripts.unit.test.ts": 2169, - "tests/e2e/task-page-linear-refresh-authority.unit.test.ts": 5548, "tests/e2e/terminal-perf-presentation.unit.test.ts": 60, "tests/e2e/worktree-status-warning.unit.test.ts": 2331, "tests/tools/omp-close-lifecycle.test.mjs": 403, diff --git a/config/scripts/preflight/changed-checks/check-changed-code-quality.mjs b/config/scripts/preflight/changed-checks/check-changed-code-quality.mjs index 00753773695..2052fc083a1 100644 --- a/config/scripts/preflight/changed-checks/check-changed-code-quality.mjs +++ b/config/scripts/preflight/changed-checks/check-changed-code-quality.mjs @@ -77,7 +77,7 @@ const SUPPRESSED_REACT_DOCTOR_DIAGNOSTICS = new Map([ 'react-doctor(no-adjust-state-on-prop-change)', new Set([ 'src/renderer/src/components/task-page/github/issue-creation/use-task-page-github-issue-draft.ts', - 'src/renderer/src/components/task-page/jira/use-task-page-jira-creation-state.ts' + 'src/renderer/src/components/task-page/jira/creation/use-task-page-jira-creation-state.ts' ]) ], [ diff --git a/config/tsconfig.tc.web.json b/config/tsconfig.tc.web.json index 95529130196..e8b9f6350b4 100644 --- a/config/tsconfig.tc.web.json +++ b/config/tsconfig.tc.web.json @@ -1,7 +1,6 @@ { "extends": "./tsconfig.web.json", "exclude": [ - "../src/renderer/src/components/task-page/linear/task-page-linear-detail-query.test.tsx", "../src/renderer/src/runtime/provider-accounts/observations/provider-account-observation.test-fixture.ts", "../src/shared/**/*.test.ts", "../src/shared/**/*.test.tsx", diff --git a/config/tsconfig.test.pane-launch.json b/config/tsconfig.test.pane-launch.json index cdffd47c2df..cff73d0a79e 100644 --- a/config/tsconfig.test.pane-launch.json +++ b/config/tsconfig.test.pane-launch.json @@ -2,7 +2,6 @@ "extends": "./tsconfig.web.json", "include": [ "../src/main/runtime/orchestration/terminal-authority/structured-sessions/structured-chat-coordinator-mail.test.ts", - "../src/renderer/src/components/task-page/linear/task-page-linear-detail-query.test.tsx", "../src/renderer/src/runtime/observed-provider-accounts.test.tsx", "../src/renderer/src/components/status-bar/ClaudeSwitcherMenu.observation.test.tsx", "../src/renderer/src/runtime/provider-accounts/observations/provider-account-observation.test-fixture.ts", diff --git a/config/tsconfig.web.json b/config/tsconfig.web.json index b8af840abf1..d34ab3969b3 100644 --- a/config/tsconfig.web.json +++ b/config/tsconfig.web.json @@ -11,7 +11,6 @@ "../src/shared/**/*" ], "exclude": [ - "../src/renderer/src/components/task-page/linear/task-page-linear-detail-query.test.tsx", "../src/renderer/src/runtime/provider-accounts/observations/provider-account-observation.test-fixture.ts" ], "compilerOptions": { diff --git a/package.json b/package.json index cf4dd2f8e49..c022ea14cf1 100644 --- a/package.json +++ b/package.json @@ -441,7 +441,7 @@ { "files": [ "src/renderer/src/components/task-page/github/issue-creation/use-task-page-github-issue-draft.ts", - "src/renderer/src/components/task-page/jira/use-task-page-jira-creation-state.ts" + "src/renderer/src/components/task-page/jira/creation/use-task-page-jira-creation-state.ts" ], "rules": [ "react-doctor/no-adjust-state-on-prop-change" diff --git a/src/renderer/src/components/__mocks__/task-page-jira-favourite-fixture.ts b/src/renderer/src/components/__mocks__/task-page-jira-favourite-fixture.ts deleted file mode 100644 index b724951f850..00000000000 --- a/src/renderer/src/components/__mocks__/task-page-jira-favourite-fixture.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { useMemo, useRef } from 'react' -import { vi, type Mock } from 'vitest' -import type { ExecutionHostId } from '../../../../shared/execution-host' -import type { JiraIssue } from '../../../../shared/jira/jira-types' -import type { JiraSavedFilterState } from '../../../../shared/jira/jira-saved-filters' -import type { TaskPageLinearViewStateModel } from '../task-page/linear/use-task-page-linear-view-state' -import type { TaskPageLinearCollectionEffectsModel } from '../task-page/linear/use-task-page-linear-collection-effects' -import { useTaskPageJiraListState } from '../task-page/jira/use-task-page-jira-list-state' -import { useTaskPageJiraListEffects } from '../task-page/jira/use-task-page-jira-list-effects' - -const favouriteTransport: { - catalogue: Mock - readSaved: Mock - mutateSaved: Mock - readQuick: Mock - list: Mock - search: Mock - resume: Mock -} = vi.hoisted(() => ({ - catalogue: vi.fn(), - readSaved: vi.fn(), - mutateSaved: vi.fn(), - readQuick: vi.fn(), - list: vi.fn(), - search: vi.fn(), - resume: vi.fn() -})) -export { favouriteTransport } -vi.mock('@/runtime/jira/runtime-jira-favourite-filters-client', () => ({ - jiraListFavouriteFiltersForTarget: favouriteTransport.catalogue -})) -vi.mock('@/runtime/jira/runtime-jira-saved-filter-state', () => ({ - readJiraSavedFilterState: favouriteTransport.readSaved, - mutateJiraSavedFilters: favouriteTransport.mutateSaved -})) -vi.mock('@/runtime/jira/runtime-jira-quick-filter-state', () => ({ - readJiraQuickFilterState: favouriteTransport.readQuick -})) -vi.mock('@/runtime/jira/runtime-jira-detail-client', () => ({ - jiraListPrioritiesForTarget: vi.fn() -})) -vi.mock('@/runtime/jira/runtime-jira-client', () => ({ - jiraGetProjectStatusOrder: vi.fn() -})) - -export const favourite = { - id: 'fav-1', - name: 'My Jira favourite', - jql: 'filter = 12345 ORDER BY updated DESC', - siteId: 'site-a' -} -export const named = { id: 'named', name: 'Custom named filter', jql: 'project = CUSTOM' } -export const emptySaved: JiraSavedFilterState = { filters: [named], activeFilterId: null } -const emptyIssues: JiraIssue[] = [] -const noop = () => {} - -export function resetFavouriteTransport() { - vi.resetAllMocks() - favouriteTransport.catalogue.mockImplementation(async (_target, siteId: string) => ({ - supported: true, - filters: [{ ...favourite, siteId }], - truncated: false - })) - favouriteTransport.readSaved.mockResolvedValue(emptySaved) - favouriteTransport.mutateSaved.mockImplementation(async (_target, mutation) => ({ - ...emptySaved, - activeFilterId: mutation.kind === 'select' ? mutation.id : null - })) - favouriteTransport.readQuick.mockResolvedValue({ statuses: [] }) - favouriteTransport.list.mockResolvedValue(emptyIssues) - favouriteTransport.search.mockResolvedValue(emptyIssues) -} - -export type FavouriteFixtureProps = { hostId?: ExecutionHostId; siteId?: string; enabled?: boolean } -export function useFavouriteTaskPage({ - hostId = 'runtime:owner-a', - siteId = 'site-a', - enabled = true -}: FavouriteFixtureProps = {}) { - const sourceContext = useMemo( - () => ({ - kind: 'task-source' as const, - provider: 'jira' as const, - projectId: 'folder-project', - hostId, - providerIdentity: { provider: 'jira' as const, siteId: siteId === 'all' ? null : siteId } - }), - [hostId, siteId] - ) - const model = { - accountBackedTaskSourceHostId: hostId, - jiraTaskSourceContext: sourceContext, - jiraTaskSourceScopeKey: JSON.stringify([hostId, siteId]), - taskSource: enabled ? 'jira' : 'github', - taskResumeApplied: true, - jiraConnected: true, - selectedJiraSiteId: siteId, - repos: [], - selectedRepos: [], - pageData: {}, - settings: { activeRuntimeEnvironmentId: 'different-focused-server' }, - jiraSearchPersistReadyRef: useRef(true), - selectedJiraIssueKey: null, - selectedJiraIssueFallback: null, - setSelectedJiraIssueKey: noop, - setSelectedJiraIssueFallback: noop, - setTaskResumeState: favouriteTransport.resume, - linkedJiraIssueRefs: [], - linkedJiraIssueRefsSignature: '[]', - listJiraIssues: favouriteTransport.list, - searchJiraIssues: favouriteTransport.search - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The actual Jira hooks only read the supplied fields from the preceding provider pipeline. - const state = useTaskPageJiraListState(model as unknown as TaskPageLinearViewStateModel) - const effects = { ...state, displayedJiraIssues: state.jiraIssues } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: All fields read by the actual Jira list effect are supplied; unrelated provider stages are omitted. - useTaskPageJiraListEffects(effects as unknown as TaskPageLinearCollectionEffectsModel) - return state -} diff --git a/src/renderer/src/components/jira-key-or-text-search.ts b/src/renderer/src/components/jira-key-or-text-search.ts index 9d5950ed62d..df6e586bad1 100644 --- a/src/renderer/src/components/jira-key-or-text-search.ts +++ b/src/renderer/src/components/jira-key-or-text-search.ts @@ -3,7 +3,7 @@ import { buildJiraIssueKeyJql, buildJiraTextMatchJql } from '../../../shared/jira/jira-search-input-jql' -import { getJiraBadRequestReason } from './task-page/jira/task-page-jira-load-state' +import { getJiraBadRequestReason } from './task-page/jira/listing/task-page-jira-load-state' export async function searchJiraIssuesByKeyOrText( input: string, diff --git a/src/renderer/src/components/task-page/Surface.tsx b/src/renderer/src/components/task-page/Surface.tsx index 736001ade30..2e2f2e28cd6 100644 --- a/src/renderer/src/components/task-page/Surface.tsx +++ b/src/renderer/src/components/task-page/Surface.tsx @@ -1,9 +1,9 @@ import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions' import { TaskPageFrame } from './Frame' import { TaskPageGitHubIssueDialog } from './github/issue-creation/IssueDialog' -import { TaskPageLinearProjectDialog } from './linear/ProjectDialog' -import { TaskPageLinearIssueDialog } from './linear/IssueDialog' -import { TaskPageJiraIssueDialog } from './jira/IssueDialog' +import { TaskPageLinearProjectDialog } from './linear/creation/ProjectDialog' +import { TaskPageLinearIssueDialog } from './linear/creation/IssueDialog' +import { TaskPageJiraIssueDialog } from './jira/creation/IssueDialog' import { TaskPageGitLabDialog } from './gitlab/Dialog' import { TaskPageLinearConnectDialog } from './linear/ConnectDialog' import { TaskPageJiraConnectDialog } from './jira/ConnectDialog' diff --git a/src/renderer/src/components/task-page/TaskPage.tsx b/src/renderer/src/components/task-page/TaskPage.tsx index c8d42e8d023..8f37666a72d 100644 --- a/src/renderer/src/components/task-page/TaskPage.tsx +++ b/src/renderer/src/components/task-page/TaskPage.tsx @@ -14,14 +14,14 @@ import { useTaskPageJiraListState } from './jira/use-task-page-jira-list-state' import { useTaskPageResumeRestoration } from './use-task-page-resume-restoration' import { useTaskPageProviderMetadata } from './use-task-page-provider-metadata' import { useTaskPageGitLabLoading } from './gitlab/use-task-page-gitlab-loading' -import { useTaskPageLinearListSelection } from './linear/use-task-page-linear-list-selection' -import { useTaskPageLinearListProjection } from './linear/use-task-page-linear-list-projection' -import { useTaskPageLinearBoard } from './linear/use-task-page-linear-board' -import { useTaskPageJiraListProjection } from './jira/use-task-page-jira-list-projection' -import { useTaskPageLinearCreationState } from './linear/use-task-page-linear-creation-state' +import { useTaskPageLinearListSelection } from './linear/issues/use-task-page-linear-list-selection' +import { useTaskPageLinearListProjection } from './linear/issues/use-task-page-linear-list-projection' +import { useTaskPageLinearBoard } from './linear/issues/use-task-page-linear-board' +import { useTaskPageJiraListProjection } from './jira/listing/use-task-page-jira-list-projection' +import { useTaskPageLinearCreationState } from './linear/creation/use-task-page-linear-creation-state' import { useTaskPageGitHubMutationState } from './github/mutations/use-task-page-github-mutation-state' -import { useTaskPageJiraCreationState } from './jira/use-task-page-jira-creation-state' -import { useTaskPageJiraCreationMetadata } from './jira/use-task-page-jira-creation-metadata' +import { useTaskPageJiraCreationState } from './jira/creation/use-task-page-jira-creation-state' +import { useTaskPageJiraCreationMetadata } from './jira/creation/use-task-page-jira-creation-metadata' import { useTaskPageGitHubListProjection } from './github/listing/use-task-page-github-list-projection' import { useTaskPageGitHubSearchPagination } from './github/listing/use-task-page-github-search-pagination' import { useTaskPageGitHubLandingRefresh } from './github/refresh/use-task-page-github-landing-refresh' @@ -29,15 +29,15 @@ import { useTaskPageGitHubQuietRefresh } from './github/refresh/use-task-page-gi import { useTaskPageSearchActions } from './use-task-page-search-actions' import { useTaskPageWorkspaceActions } from './use-task-page-workspace-actions' import { useTaskPageGitHubIssueCreation } from './github/issue-creation/use-task-page-github-issue-creation' -import { useTaskPageLinearProjectCreation } from './linear/use-task-page-linear-project-creation' -import { useTaskPageLinearIssueCreation } from './linear/use-task-page-linear-issue-creation' -import { useTaskPageJiraIssueCreation } from './jira/use-task-page-jira-issue-creation' -import { useTaskPageJiraSummaryGeneration } from './jira/use-task-page-jira-summary-generation' +import { useTaskPageLinearProjectCreation } from './linear/creation/use-task-page-linear-project-creation' +import { useTaskPageLinearIssueCreation } from './linear/creation/use-task-page-linear-issue-creation' +import { useTaskPageJiraIssueCreation } from './jira/creation/use-task-page-jira-issue-creation' +import { useTaskPageJiraSummaryGeneration } from './jira/creation/use-task-page-jira-summary-generation' import { useTaskPageGlobalEffects } from './use-task-page-global-effects' -import { useTaskPageLinearListEffects } from './linear/use-task-page-linear-list-effects' -import { useTaskPageLinearInOrcaEffects } from './linear/use-task-page-linear-in-orca-effects' -import { useTaskPageLinearCollectionEffects } from './linear/use-task-page-linear-collection-effects' -import { useTaskPageJiraListEffects } from './jira/use-task-page-jira-list-effects' +import { useTaskPageLinearListEffects } from './linear/issues/use-task-page-linear-list-effects' +import { useTaskPageLinearInOrcaEffects } from './linear/issues/use-task-page-linear-in-orca-effects' +import { useTaskPageLinearCollectionEffects } from './linear/projects/use-task-page-linear-collection-effects' +import { useTaskPageJiraListEffects } from './jira/listing/use-task-page-jira-list-effects' import { useTaskPageComposerActions } from './use-task-page-composer-actions' import { TaskPageSurface } from './Surface' diff --git a/src/renderer/src/components/task-page/github/issue-status/StatusCell.tsx b/src/renderer/src/components/task-page/github/issue-status/StatusCell.tsx index a08d0c80fac..715877eb6ae 100644 --- a/src/renderer/src/components/task-page/github/issue-status/StatusCell.tsx +++ b/src/renderer/src/components/task-page/github/issue-status/StatusCell.tsx @@ -28,7 +28,7 @@ import { TaskPageGitHubWorkItemStateBadge } from '@/components/task-page/github/ import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover' import { cn } from '@/lib/class-names' import { CircleDot, ChevronDown, Copy, CheckCircle2, Ban, ChevronRight } from 'lucide-react' -import type { TaskPageGitHubWorkItemMutationRunner } from '../../linear/task-page-linear-jira-list-model' +import type { TaskPageGitHubWorkItemMutationRunner } from '../mutations/task-page-github-work-item-mutation-types' import { TaskPageGitHubDuplicatePicker } from './DuplicatePicker' import { useGitHubDuplicateIssueCandidates } from '@/components/github/github-duplicate-issue-candidates' diff --git a/src/renderer/src/components/task-page/github/listing/AssigneesCell.tsx b/src/renderer/src/components/task-page/github/listing/AssigneesCell.tsx index a573bc3441b..01b9751b7a5 100644 --- a/src/renderer/src/components/task-page/github/listing/AssigneesCell.tsx +++ b/src/renderer/src/components/task-page/github/listing/AssigneesCell.tsx @@ -16,7 +16,7 @@ import { githubProjectHost } from '../../../../../../shared/github/projects/proj import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover' import { cn } from '@/lib/class-names' import { LoaderCircle, ChevronDown, Check } from 'lucide-react' -import type { TaskPageGitHubWorkItemMutationRunner } from '../../linear/task-page-linear-jira-list-model' +import type { TaskPageGitHubWorkItemMutationRunner } from '../mutations/task-page-github-work-item-mutation-types' import { GitHubAssigneeAvatar } from './Avatars' import { GitHubUserAvatar } from '@/components/github/github-user-avatar' import { codeHostErrorText } from '@/lib/source-control/remote/code-host-error-copy' diff --git a/src/renderer/src/components/task-page/github/listing/use-task-page-github-list-pending.ts b/src/renderer/src/components/task-page/github/listing/use-task-page-github-list-pending.ts index 60c4063fb2e..ff26f96b099 100644 --- a/src/renderer/src/components/task-page/github/listing/use-task-page-github-list-pending.ts +++ b/src/renderer/src/components/task-page/github/listing/use-task-page-github-list-pending.ts @@ -1,5 +1,5 @@ import { useAppStore } from '@/store/index' -import type { TaskPageJiraCreationMetadataModel } from '../../jira/use-task-page-jira-creation-metadata' +import type { TaskPageJiraCreationMetadataModel } from '../../jira/creation/use-task-page-jira-creation-metadata' // Why the list projection's input model: the landing run's later-stage model carries these same fields. type LandingRequestInput = Pick< diff --git a/src/renderer/src/components/task-page/github/listing/use-task-page-github-list-projection.ts b/src/renderer/src/components/task-page/github/listing/use-task-page-github-list-projection.ts index a45c6318b84..d0760ce4678 100644 --- a/src/renderer/src/components/task-page/github/listing/use-task-page-github-list-projection.ts +++ b/src/renderer/src/components/task-page/github/listing/use-task-page-github-list-projection.ts @@ -1,6 +1,6 @@ import { useCallback, useMemo } from 'react' import { useGitHubTaskItems } from '@/hooks/useGitHubTaskItems' -import type { TaskPageJiraCreationMetadataModel } from '../../jira/use-task-page-jira-creation-metadata' +import type { TaskPageJiraCreationMetadataModel } from '../../jira/creation/use-task-page-jira-creation-metadata' import type { GitHubWorkItem } from '../../../../../../shared/github/work-items/work-item-types' import { taskPageGitHubItemKey } from '@/components/task-page/github/mutations/task-page-github-work-item-mutation-keys' import { deriveAdvertisedTotalPages } from '../../../../../../shared/work-items/work-item-empty-page' diff --git a/src/renderer/src/components/task-page/github/mutations/task-page-github-work-item-mutation-types.ts b/src/renderer/src/components/task-page/github/mutations/task-page-github-work-item-mutation-types.ts index e09ec3e4cb8..0fd7f0622c4 100644 --- a/src/renderer/src/components/task-page/github/mutations/task-page-github-work-item-mutation-types.ts +++ b/src/renderer/src/components/task-page/github/mutations/task-page-github-work-item-mutation-types.ts @@ -32,3 +32,26 @@ export type BeginTaskPageGitHubWorkItemMutationResult = { families: string[] key: TaskPageGitHubMutationKey } + +export type TaskPageGitHubWorkItemMutationRunner = { + run: (input: { + item: GitHubWorkItem + intent: TaskPageGitHubMutationIntent + sourceContext?: TaskSourceContext | null + mutate: () => Promise<{ + ok?: boolean + error?: + | string + | { + message?: string + } + } | void> + successToast?: string + errorToast: string + }) => Promise<'confirmed' | 'rolled_back' | 'stale'> + isIntentPending: (input: { + item: GitHubWorkItem + intent: TaskPageGitHubMutationIntent + sourceContext?: TaskSourceContext | null + }) => boolean +} diff --git a/src/renderer/src/components/task-page/github/mutations/use-task-page-github-mutation-state.ts b/src/renderer/src/components/task-page/github/mutations/use-task-page-github-mutation-state.ts index cd0a1f6e7a8..7a7d10bfa68 100644 --- a/src/renderer/src/components/task-page/github/mutations/use-task-page-github-mutation-state.ts +++ b/src/renderer/src/components/task-page/github/mutations/use-task-page-github-mutation-state.ts @@ -1,4 +1,4 @@ -import type { TaskPageLinearCreationStateModel } from '../../linear/use-task-page-linear-creation-state' +import type { TaskPageLinearCreationStateModel } from '../../linear/creation/use-task-page-linear-creation-state' import { getGitHubTaskKind } from '../../../../../../shared/work-items/task-github-search' import { useMemo, useLayoutEffect, useEffect, useCallback, useRef } from 'react' import { parseTaskQuery } from '../../../../../../shared/work-items/task-query' diff --git a/src/renderer/src/components/task-page/github/pull-requests/MergeCell.tsx b/src/renderer/src/components/task-page/github/pull-requests/MergeCell.tsx index 66d3830ecff..2c66b87a296 100644 --- a/src/renderer/src/components/task-page/github/pull-requests/MergeCell.tsx +++ b/src/renderer/src/components/task-page/github/pull-requests/MergeCell.tsx @@ -25,7 +25,7 @@ import { import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' import { cn } from '@/lib/class-names' import { LoaderCircle, GitMerge, ChevronDown, ExternalLink } from 'lucide-react' -import type { TaskPageGitHubWorkItemMutationRunner } from '../../linear/task-page-linear-jira-list-model' +import type { TaskPageGitHubWorkItemMutationRunner } from '../mutations/task-page-github-work-item-mutation-types' import { resolveTaskPullRequestRepo } from './task-page-github-review-model' import { openAppHttpLink } from '@/lib/browser/navigation/app-http-link-open' export function PRMergeCell({ diff --git a/src/renderer/src/components/task-page/github/pull-requests/ReviewCell.tsx b/src/renderer/src/components/task-page/github/pull-requests/ReviewCell.tsx index 175ce368376..3798f9603e1 100644 --- a/src/renderer/src/components/task-page/github/pull-requests/ReviewCell.tsx +++ b/src/renderer/src/components/task-page/github/pull-requests/ReviewCell.tsx @@ -20,7 +20,7 @@ import { import { cn } from '@/lib/class-names' import { ChevronDown } from 'lucide-react' import { Popover, PopoverTrigger } from '@/components/ui/popover' -import type { TaskPageGitHubWorkItemMutationRunner } from '../../linear/task-page-linear-jira-list-model' +import type { TaskPageGitHubWorkItemMutationRunner } from '../mutations/task-page-github-work-item-mutation-types' import { mergeReviewerSuggestions, resolveTaskPullRequestRepo diff --git a/src/renderer/src/components/task-page/github/pull-requests/task-page-github-reviewer-actions.ts b/src/renderer/src/components/task-page/github/pull-requests/task-page-github-reviewer-actions.ts index 46bf6f0376c..cf1f380fb8e 100644 --- a/src/renderer/src/components/task-page/github/pull-requests/task-page-github-reviewer-actions.ts +++ b/src/renderer/src/components/task-page/github/pull-requests/task-page-github-reviewer-actions.ts @@ -15,7 +15,7 @@ import type { GitHubRepositoryIdentity as GitHubOwnerRepo } from '../../../../.. import type { GitHubWorkItem } from '../../../../../../shared/github/work-items/work-item-types' import type { Repo } from '../../../../../../shared/repo-types' import type { TaskSourceContext } from '../../../../../../shared/work-items/task-source-context' -import type { TaskPageGitHubWorkItemMutationRunner } from '../../linear/task-page-linear-jira-list-model' +import type { TaskPageGitHubWorkItemMutationRunner } from '../mutations/task-page-github-work-item-mutation-types' import { buildRequestedReviewUsers } from './task-page-github-review-model' type ReviewerActionsInput = { diff --git a/src/renderer/src/components/task-page/github/pull-requests/use-task-page-pr-check-hydration.ts b/src/renderer/src/components/task-page/github/pull-requests/use-task-page-pr-check-hydration.ts index ce313a40bb7..e38a5fc6f82 100644 --- a/src/renderer/src/components/task-page/github/pull-requests/use-task-page-pr-check-hydration.ts +++ b/src/renderer/src/components/task-page/github/pull-requests/use-task-page-pr-check-hydration.ts @@ -25,7 +25,7 @@ import { } from '../../../../../../shared/github/checks/complete-pr-check-owner' import { getWorkItemRepo } from '@/lib/work-items/work-item-repo-row' import type { GitHubWorkItem } from '../../../../../../shared/github/work-items/work-item-types' -import type { TaskPageJiraCreationMetadataModel } from '../../jira/use-task-page-jira-creation-metadata' +import type { TaskPageJiraCreationMetadataModel } from '../../jira/creation/use-task-page-jira-creation-metadata' import { deriveTaskPagePRCheckSummary } from '../../task-page-pr-check-summary' import { PR_CHECKS_EAGER_PREFETCH_LIMIT } from '../../task-page-source-context' import { getTaskPageRepoSourceContext } from '../../../../lib/work-items/task-repo-source-context' diff --git a/src/renderer/src/components/task-page/jira/Content.tsx b/src/renderer/src/components/task-page/jira/Content.tsx index 4a7ca204a0c..3600962a014 100644 --- a/src/renderer/src/components/task-page/jira/Content.tsx +++ b/src/renderer/src/components/task-page/jira/Content.tsx @@ -7,14 +7,14 @@ import { useTranslate } from '@/i18n/use-translate' import { Button } from '@/components/ui/button' import { jiraListGridStyle } from '../../jira-list-columns' import { JiraListColumnsMenu } from '../../jira-list-columns-menu' -import { TaskPageJiraSortControls } from './task-page-jira-sort-controls' -import { TaskPageJiraErrorBanner } from '../linear/task-page-linear-jira-list-model' -import { TaskPageJiraTextFallbackNotice } from './task-page-jira-text-fallback-notice' -import { TaskPageJiraIssueList } from '@/components/task-page/jira/task-page-jira-issue-list' -import { TaskPageJiraBoardView } from '@/components/task-page/jira/task-page-jira-board-view' +import { TaskPageJiraSortControls } from './listing/task-page-jira-sort-controls' +import { TaskPageJiraErrorBanner } from './listing/task-page-jira-error-banner' +import { TaskPageJiraTextFallbackNotice } from './listing/task-page-jira-text-fallback-notice' +import { TaskPageJiraIssueList } from '@/components/task-page/jira/listing/task-page-jira-issue-list' +import { TaskPageJiraBoardView } from '@/components/task-page/jira/boards/task-page-jira-board-view' import { TaskPageJiraViewControls } from '@/components/task-page/jira/task-page-jira-view-controls' import { formatRelativeTime } from '../task-page-source-context' -import { getJiraStatusTone } from '@/components/task-page/jira/task-page-jira-status-tone' +import { getJiraStatusTone } from '@/components/task-page/jira/listing/task-page-jira-status-tone' import JiraIssueWorkspace from '@/components/JiraIssueWorkspace' import { TaskPageLinearContent } from '../linear/Content' import { diff --git a/src/renderer/src/components/task-page/jira/Filters.tsx b/src/renderer/src/components/task-page/jira/Filters.tsx index 94087bc020e..5d999ef3812 100644 --- a/src/renderer/src/components/task-page/jira/Filters.tsx +++ b/src/renderer/src/components/task-page/jira/Filters.tsx @@ -1,6 +1,6 @@ -import { TaskPageJiraStatusFilters } from './TaskPageJiraStatusFilters' -import { TaskPageJiraSavedFilters } from './TaskPageJiraSavedFilters' -import { TaskPageJiraFavouriteFilters } from './TaskPageJiraFavouriteFilters' +import { TaskPageJiraStatusFilters } from './filters/TaskPageJiraStatusFilters' +import { TaskPageJiraSavedFilters } from './filters/TaskPageJiraSavedFilters' +import { TaskPageJiraFavouriteFilters } from './filters/TaskPageJiraFavouriteFilters' import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions' import { cn } from '@/lib/class-names' import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' diff --git a/src/renderer/src/components/task-page/jira/IssueAssigneeField.test.tsx b/src/renderer/src/components/task-page/jira/IssueAssigneeField.test.tsx deleted file mode 100644 index 55a723ed074..00000000000 --- a/src/renderer/src/components/task-page/jira/IssueAssigneeField.test.tsx +++ /dev/null @@ -1,68 +0,0 @@ -// @vitest-environment happy-dom -import type { ComponentProps } from 'react' -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' -import { afterEach, expect, it, vi } from 'vitest' -import { TaskPageJiraIssueAssigneeField } from './IssueAssigneeField' - -const search = vi.hoisted(() => vi.fn()) -vi.mock('@/runtime/jira/runtime-jira-user-fields-client', () => ({ - jiraListAssignableUsersForProject: search -})) -afterEach(cleanup) - -it('drops old provider candidates before another host with identical project IDs can select them', async () => { - const onSelect = vi.fn() - const model: ComponentProps['model'] = { - settings: null, - jiraTaskSourceContext: null, - jiraStatusCurrent: true, - jiraStatus: { - connected: true, - activeSiteId: 'same-site', - viewer: { accountId: 'local-me', displayName: 'Local me', email: null } - }, - jiraCreateFields: [{ key: 'assignee', name: 'Assignee', required: false }], - newJiraIssueTargetProject: { id: '100', key: 'PRJ', name: 'Project', siteId: 'same-site' }, - newJiraIssueAssignee: null, - setNewJiraIssueAssignee: onSelect, - newJiraIssueSubmitting: false, - providerRuntimeContextKey: 'local' - } - search.mockResolvedValueOnce([{ accountId: 'local-user', displayName: 'Local user' }]) - const { rerender } = render() - fireEvent.click(screen.getByRole('button', { name: 'Assignee' })) - await screen.findByRole('button', { name: 'Local user' }) - expect(screen.getByRole('button', { name: 'Assign to me (Local me)' })).toBeTruthy() - expect(search).toHaveBeenCalledWith( - { kind: 'default-scope', settings: null }, - 'PRJ', - '', - 'same-site' - ) - - search.mockReturnValueOnce(new Promise(() => {})) - rerender( - - ) - expect(screen.queryByRole('button', { name: 'Local user' })).toBeNull() - fireEvent.click(screen.getByRole('button', { name: 'Assignee' })) - await waitFor(() => expect(search).toHaveBeenCalledTimes(2)) - expect(screen.queryByRole('button', { name: 'Local user' })).toBeNull() - expect(screen.queryByRole('button', { name: 'Assign to me (Local me)' })).toBeNull() - rerender( - - ) - fireEvent.click(screen.getByRole('button', { name: 'Assign to me (Remote me)' })) - expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ accountId: 'remote-me' })) -}) diff --git a/src/renderer/src/components/task-page/jira/task-page-jira-board-move-dialog.tsx b/src/renderer/src/components/task-page/jira/boards/task-page-jira-board-move-dialog.tsx similarity index 95% rename from src/renderer/src/components/task-page/jira/task-page-jira-board-move-dialog.tsx rename to src/renderer/src/components/task-page/jira/boards/task-page-jira-board-move-dialog.tsx index 682e1babaf4..b849151157a 100644 --- a/src/renderer/src/components/task-page/jira/task-page-jira-board-move-dialog.tsx +++ b/src/renderer/src/components/task-page/jira/boards/task-page-jira-board-move-dialog.tsx @@ -8,13 +8,13 @@ import { DialogTitle } from '@/components/ui/dialog' import { useTranslate } from '@/i18n/use-translate' -import { JiraTransitionFieldsForm } from '../../jira-transition-fields-form' -import { classifyTransitionRequirements } from '../../../../../shared/jira/jira-transition-fields' +import { JiraTransitionFieldsForm } from '../../../jira-transition-fields-form' +import { classifyTransitionRequirements } from '../../../../../../shared/jira/jira-transition-fields' import type { JiraIssue, JiraIssueUpdate, JiraTransition -} from '../../../../../shared/jira/jira-types' +} from '../../../../../../shared/jira/jira-types' import { openAppHttpLink } from '@/lib/browser/navigation/app-http-link-open' export type JiraBoardMovePrompt = { diff --git a/src/renderer/src/components/task-page/jira/task-page-jira-board-move.ts b/src/renderer/src/components/task-page/jira/boards/task-page-jira-board-move.ts similarity index 89% rename from src/renderer/src/components/task-page/jira/task-page-jira-board-move.ts rename to src/renderer/src/components/task-page/jira/boards/task-page-jira-board-move.ts index 5bd213a77c9..36569d2c881 100644 --- a/src/renderer/src/components/task-page/jira/task-page-jira-board-move.ts +++ b/src/renderer/src/components/task-page/jira/boards/task-page-jira-board-move.ts @@ -3,9 +3,9 @@ import type { JiraIssueUpdate, JiraMutationResult, JiraTransition -} from '../../../../../shared/jira/jira-types' +} from '../../../../../../shared/jira/jira-types' import type { TaskPageJiraBoardSection } from './task-page-jira-board-sections' -import type { RuntimeClientTarget } from '../../../../../shared/runtime-client-target' +import type { RuntimeClientTarget } from '../../../../../../shared/runtime-client-target' import { findJiraBoardSectionTransitions, loadTaskPageJiraIssueTransitions diff --git a/src/renderer/src/components/task-page/jira/task-page-jira-board-sections.ts b/src/renderer/src/components/task-page/jira/boards/task-page-jira-board-sections.ts similarity index 95% rename from src/renderer/src/components/task-page/jira/task-page-jira-board-sections.ts rename to src/renderer/src/components/task-page/jira/boards/task-page-jira-board-sections.ts index bdc8520dfcf..89d4d026aa0 100644 --- a/src/renderer/src/components/task-page/jira/task-page-jira-board-sections.ts +++ b/src/renderer/src/components/task-page/jira/boards/task-page-jira-board-sections.ts @@ -1,5 +1,5 @@ -import type { JiraIssue, JiraProjectStatusOrder } from '../../../../../shared/jira/jira-types' -import { groupJiraIssuesByStatus } from './task-page-jira-issue-list' +import type { JiraIssue, JiraProjectStatusOrder } from '../../../../../../shared/jira/jira-types' +import { groupJiraIssuesByStatus } from '../listing/task-page-jira-issue-list' export type TaskPageJiraBoardSection = { key: string diff --git a/src/renderer/src/components/task-page/jira/task-page-jira-board-transitions.ts b/src/renderer/src/components/task-page/jira/boards/task-page-jira-board-transitions.ts similarity index 92% rename from src/renderer/src/components/task-page/jira/task-page-jira-board-transitions.ts rename to src/renderer/src/components/task-page/jira/boards/task-page-jira-board-transitions.ts index 08978ea9e58..645c56e9d86 100644 --- a/src/renderer/src/components/task-page/jira/task-page-jira-board-transitions.ts +++ b/src/renderer/src/components/task-page/jira/boards/task-page-jira-board-transitions.ts @@ -1,5 +1,5 @@ -import type { JiraIssue, JiraTransition } from '../../../../../shared/jira/jira-types' -import type { RuntimeClientTarget } from '../../../../../shared/runtime-client-target' +import type { JiraIssue, JiraTransition } from '../../../../../../shared/jira/jira-types' +import type { RuntimeClientTarget } from '../../../../../../shared/runtime-client-target' import { jiraListTransitionsForTarget } from '@/runtime/jira/runtime-jira-detail-client' import { getRuntimeTargetHostId } from '@/runtime/rpc/targets/runtime-client-target' import { createMetadataRequestStore, loadMetadata } from '@/hooks/metadata-request-cache' diff --git a/src/renderer/src/components/task-page/jira/task-page-jira-board-view.tsx b/src/renderer/src/components/task-page/jira/boards/task-page-jira-board-view.tsx similarity index 94% rename from src/renderer/src/components/task-page/jira/task-page-jira-board-view.tsx rename to src/renderer/src/components/task-page/jira/boards/task-page-jira-board-view.tsx index fbe6b20685e..6ea2209f254 100644 --- a/src/renderer/src/components/task-page/jira/task-page-jira-board-view.tsx +++ b/src/renderer/src/components/task-page/jira/boards/task-page-jira-board-view.tsx @@ -3,21 +3,21 @@ import { useAppStore } from '@/store/index' import { getEnvironmentRuntimeContextKey } from '@/runtime/hosts/observations/provider-runtime-context' import { runtimeTargetEnvironmentId } from '@/runtime/rpc/targets/runtime-client-target' import { useTranslate } from '@/i18n/use-translate' -import { classifyTransitionRequirements } from '../../../../../shared/jira/jira-transition-fields' +import { classifyTransitionRequirements } from '../../../../../../shared/jira/jira-transition-fields' import type { JiraIssue, JiraIssueUpdate, JiraProjectStatusOrder, JiraTransition -} from '../../../../../shared/jira/jira-types' -import type { TaskSourceContext } from '../../../../../shared/work-items/task-source-context' -import type { RuntimeClientTarget } from '../../../../../shared/runtime-client-target' +} from '../../../../../../shared/jira/jira-types' +import type { TaskSourceContext } from '../../../../../../shared/work-items/task-source-context' +import type { RuntimeClientTarget } from '../../../../../../shared/runtime-client-target' import { TaskPageJiraBoard, jiraBoardIssueRefKey } from './task-page-jira-board' import type { TaskPageJiraBoardSection } from './task-page-jira-board-sections' import { JiraBoardMoveDialog, type JiraBoardMovePrompt } from './task-page-jira-board-move-dialog' import { applyJiraBoardMove, getJiraBoardMoveTransitions } from './task-page-jira-board-move' -import { formatRelativeTime } from '../task-page-source-context' -import { enqueueJiraIssueMutation, jiraIssueRequestScope } from '../../jira-issue-mutation-queue' +import { formatRelativeTime } from '../../task-page-source-context' +import { enqueueJiraIssueMutation, jiraIssueRequestScope } from '../../../jira-issue-mutation-queue' type Props = { issues: JiraIssue[] diff --git a/src/renderer/src/components/task-page/jira/task-page-jira-board.tsx b/src/renderer/src/components/task-page/jira/boards/task-page-jira-board.tsx similarity index 99% rename from src/renderer/src/components/task-page/jira/task-page-jira-board.tsx rename to src/renderer/src/components/task-page/jira/boards/task-page-jira-board.tsx index a2e6183a195..f86846a7024 100644 --- a/src/renderer/src/components/task-page/jira/task-page-jira-board.tsx +++ b/src/renderer/src/components/task-page/jira/boards/task-page-jira-board.tsx @@ -17,7 +17,7 @@ import { writeJiraBoardIssueDragData } from '@/lib/work-items/jira/jira-board-drag-payload' import type { JiraBoardIssueDragRef } from '@/lib/work-items/jira/jira-board-drag-payload' -import type { JiraIssue, JiraProjectStatusOrder } from '../../../../../shared/jira/jira-types' +import type { JiraIssue, JiraProjectStatusOrder } from '../../../../../../shared/jira/jira-types' import { buildJiraBoardSections, type TaskPageJiraBoardSection diff --git a/src/renderer/src/components/task-page/jira/IssueAssigneeField.tsx b/src/renderer/src/components/task-page/jira/creation/IssueAssigneeField.tsx similarity index 96% rename from src/renderer/src/components/task-page/jira/IssueAssigneeField.tsx rename to src/renderer/src/components/task-page/jira/creation/IssueAssigneeField.tsx index 051628eab53..1d81be293df 100644 --- a/src/renderer/src/components/task-page/jira/IssueAssigneeField.tsx +++ b/src/renderer/src/components/task-page/jira/creation/IssueAssigneeField.tsx @@ -1,8 +1,8 @@ import { useCallback } from 'react' -import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions' +import type { TaskPageComposerActionsModel } from '../../use-task-page-composer-actions' import { getJiraSelfUser } from '@/components/jira-self-user' import { JiraUserPicker, type JiraUserPickerFixedOption } from '@/components/jira-user-picker' -import { hasJiraAssigneeCreateField } from '@/components/task-page/jira/task-page-jira-create-fields' +import { hasJiraAssigneeCreateField } from '@/components/task-page/jira/creation/task-page-jira-create-fields' import { getJiraProjectSelectionKey } from '@/components/task-page/jira/task-page-jira-project-selection' import { jiraListAssignableUsersForProject } from '@/runtime/jira/runtime-jira-user-fields-client' import { useTranslate } from '@/i18n/use-translate' diff --git a/src/renderer/src/components/task-page/jira/IssueDialog.tsx b/src/renderer/src/components/task-page/jira/creation/IssueDialog.tsx similarity index 98% rename from src/renderer/src/components/task-page/jira/IssueDialog.tsx rename to src/renderer/src/components/task-page/jira/creation/IssueDialog.tsx index b2d8aa673b2..aa7ad3ab009 100644 --- a/src/renderer/src/components/task-page/jira/IssueDialog.tsx +++ b/src/renderer/src/components/task-page/jira/creation/IssueDialog.tsx @@ -1,4 +1,4 @@ -import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions' +import type { TaskPageComposerActionsModel } from '../../use-task-page-composer-actions' import { Dialog, DialogContent, @@ -34,7 +34,7 @@ import { Input } from '@/components/ui/input' import { getJiraCreateAllowedValueLabel, isJiraScalarUserCreateField -} from '@/components/task-page/jira/task-page-jira-create-fields' +} from '@/components/task-page/jira/creation/task-page-jira-create-fields' import { TaskPageJiraIssueAssigneeField } from './IssueAssigneeField' import { TaskPageJiraIssueUserField } from './IssueUserField' export function TaskPageJiraIssueDialog({ diff --git a/src/renderer/src/components/task-page/jira/IssueTitleField.tsx b/src/renderer/src/components/task-page/jira/creation/IssueTitleField.tsx similarity index 97% rename from src/renderer/src/components/task-page/jira/IssueTitleField.tsx rename to src/renderer/src/components/task-page/jira/creation/IssueTitleField.tsx index e5c99a559c8..1f859e72c24 100644 --- a/src/renderer/src/components/task-page/jira/IssueTitleField.tsx +++ b/src/renderer/src/components/task-page/jira/creation/IssueTitleField.tsx @@ -1,4 +1,4 @@ -import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions' +import type { TaskPageComposerActionsModel } from '../../use-task-page-composer-actions' import { Input } from '@/components/ui/input' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useTranslate } from '@/i18n/use-translate' diff --git a/src/renderer/src/components/task-page/jira/IssueUserField.tsx b/src/renderer/src/components/task-page/jira/creation/IssueUserField.tsx similarity index 92% rename from src/renderer/src/components/task-page/jira/IssueUserField.tsx rename to src/renderer/src/components/task-page/jira/creation/IssueUserField.tsx index 4243aa53873..aaf545134c1 100644 --- a/src/renderer/src/components/task-page/jira/IssueUserField.tsx +++ b/src/renderer/src/components/task-page/jira/creation/IssueUserField.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' import { JiraUserPicker } from '@/components/jira-user-picker' import type { RowLessSource as RuntimeJiraSettings } from '@/runtime/hosts/selection/default-creation-host' -import type { JiraUser } from '../../../../../shared/jira/jira-types' +import type { JiraUser } from '../../../../../../shared/jira/jira-types' export function TaskPageJiraIssueUserField({ providerSettings, diff --git a/src/renderer/src/components/task-page/jira/task-page-jira-create-fields.ts b/src/renderer/src/components/task-page/jira/creation/task-page-jira-create-fields.ts similarity index 98% rename from src/renderer/src/components/task-page/jira/task-page-jira-create-fields.ts rename to src/renderer/src/components/task-page/jira/creation/task-page-jira-create-fields.ts index 75728349eb3..eac52fd8e92 100644 --- a/src/renderer/src/components/task-page/jira/task-page-jira-create-fields.ts +++ b/src/renderer/src/components/task-page/jira/creation/task-page-jira-create-fields.ts @@ -1,5 +1,5 @@ import { buildJiraCreateTextAdf } from '@/components/jira-create-adf' -import type { JiraCreateField } from '../../../../../shared/jira/jira-types' +import type { JiraCreateField } from '../../../../../../shared/jira/jira-types' // 'assignee' is system here because the dialog renders a dedicated picker for it. const JIRA_CREATE_SYSTEM_FIELD_KEYS = new Set([ diff --git a/src/renderer/src/components/task-page/jira/use-task-page-jira-creation-metadata.ts b/src/renderer/src/components/task-page/jira/creation/use-task-page-jira-creation-metadata.ts similarity index 100% rename from src/renderer/src/components/task-page/jira/use-task-page-jira-creation-metadata.ts rename to src/renderer/src/components/task-page/jira/creation/use-task-page-jira-creation-metadata.ts diff --git a/src/renderer/src/components/task-page/jira/use-task-page-jira-creation-projects.ts b/src/renderer/src/components/task-page/jira/creation/use-task-page-jira-creation-projects.ts similarity index 98% rename from src/renderer/src/components/task-page/jira/use-task-page-jira-creation-projects.ts rename to src/renderer/src/components/task-page/jira/creation/use-task-page-jira-creation-projects.ts index 4ef8cb4afd8..6672fd53548 100644 --- a/src/renderer/src/components/task-page/jira/use-task-page-jira-creation-projects.ts +++ b/src/renderer/src/components/task-page/jira/creation/use-task-page-jira-creation-projects.ts @@ -1,9 +1,9 @@ -import { useJiraCreateAssignee } from '../../use-jira-create-assignee' +import { useJiraCreateAssignee } from '../../../use-jira-create-assignee' import React, { useCallback, useEffect, useMemo } from 'react' import { useUiLanguageRevision } from '@/i18n/use-ui-language-revision' import { filterJiraProjectPickerProjects } from '@/components/jira-project-picker-filter' -import { isVisibleJiraCreateField } from '@/components/task-page/jira/task-page-jira-create-fields' +import { isVisibleJiraCreateField } from '@/components/task-page/jira/creation/task-page-jira-create-fields' import { jiraProjectDisplayLabelComparator, getJiraProjectSelectionKey diff --git a/src/renderer/src/components/task-page/jira/use-task-page-jira-creation-state.ts b/src/renderer/src/components/task-page/jira/creation/use-task-page-jira-creation-state.ts similarity index 97% rename from src/renderer/src/components/task-page/jira/use-task-page-jira-creation-state.ts rename to src/renderer/src/components/task-page/jira/creation/use-task-page-jira-creation-state.ts index f7075dc9606..9b01f898f0c 100644 --- a/src/renderer/src/components/task-page/jira/use-task-page-jira-creation-state.ts +++ b/src/renderer/src/components/task-page/jira/creation/use-task-page-jira-creation-state.ts @@ -1,8 +1,8 @@ -import type { TaskPageGitHubMutationStateModel } from '../github/mutations/use-task-page-github-mutation-state' +import type { TaskPageGitHubMutationStateModel } from '../../github/mutations/use-task-page-github-mutation-state' import { useState, useRef, useEffect } from 'react' -import type { JiraIssueType, JiraCreateField } from '../../../../../shared/jira/jira-types' +import type { JiraIssueType, JiraCreateField } from '../../../../../../shared/jira/jira-types' import { useTaskCreationDraftRetention } from '@/components/use-task-creation-draft-retention' -import { writeNewJiraIssueDraft } from '../task-page-draft-storage' +import { writeNewJiraIssueDraft } from '../../task-page-draft-storage' import { useTaskPageJiraCreationProjects } from './use-task-page-jira-creation-projects' export function useTaskPageJiraCreationStatePrelude(model: TaskPageGitHubMutationStateModel) { const { diff --git a/src/renderer/src/components/task-page/jira/use-task-page-jira-issue-creation.ts b/src/renderer/src/components/task-page/jira/creation/use-task-page-jira-issue-creation.ts similarity index 96% rename from src/renderer/src/components/task-page/jira/use-task-page-jira-issue-creation.ts rename to src/renderer/src/components/task-page/jira/creation/use-task-page-jira-issue-creation.ts index f7ee09e20de..6739ab0c53d 100644 --- a/src/renderer/src/components/task-page/jira/use-task-page-jira-issue-creation.ts +++ b/src/renderer/src/components/task-page/jira/creation/use-task-page-jira-issue-creation.ts @@ -2,12 +2,12 @@ import { taskProviderErrorMessage, taskProviderErrorText } from '@/lib/work-items/task-provider-error-copy' -import type { TaskPageLinearIssueCreationModel } from '../linear/use-task-page-linear-issue-creation' +import type { TaskPageLinearIssueCreationModel } from '../../linear/creation/use-task-page-linear-issue-creation' import { useCallback } from 'react' import { buildJiraCreateSubmission, hasJiraAssigneeCreateField -} from '@/components/task-page/jira/task-page-jira-create-fields' +} from '@/components/task-page/jira/creation/task-page-jira-create-fields' import { jiraCreateIssue } from '@/runtime/jira/runtime-jira-user-fields-client' import { jiraGetIssue } from '@/runtime/jira/runtime-jira-client' import { toast } from 'sonner' diff --git a/src/renderer/src/components/task-page/jira/use-task-page-jira-summary-generation.test.tsx b/src/renderer/src/components/task-page/jira/creation/use-task-page-jira-summary-generation.test.tsx similarity index 98% rename from src/renderer/src/components/task-page/jira/use-task-page-jira-summary-generation.test.tsx rename to src/renderer/src/components/task-page/jira/creation/use-task-page-jira-summary-generation.test.tsx index 8a78a86819a..f516032f699 100644 --- a/src/renderer/src/components/task-page/jira/use-task-page-jira-summary-generation.test.tsx +++ b/src/renderer/src/components/task-page/jira/creation/use-task-page-jira-summary-generation.test.tsx @@ -2,11 +2,11 @@ import { act, useState } from 'react' import { renderHook, cleanup } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { JiraIssueSummaryGenerationResult } from '../../../../../shared/jira/jira-issue-summary-generation' +import type { JiraIssueSummaryGenerationResult } from '../../../../../../shared/jira/jira-issue-summary-generation' import { LOCAL_EXECUTION_HOST_ID, toRuntimeExecutionHostId -} from '../../../../../shared/execution-host' +} from '../../../../../../shared/execution-host' import { useTaskPageJiraSummaryGeneration } from './use-task-page-jira-summary-generation' const mocks = vi.hoisted(() => ({ generate: vi.fn(), cancel: vi.fn(), error: vi.fn() })) diff --git a/src/renderer/src/components/task-page/jira/use-task-page-jira-summary-generation.ts b/src/renderer/src/components/task-page/jira/creation/use-task-page-jira-summary-generation.ts similarity index 96% rename from src/renderer/src/components/task-page/jira/use-task-page-jira-summary-generation.ts rename to src/renderer/src/components/task-page/jira/creation/use-task-page-jira-summary-generation.ts index 6193651324f..0a0efcecde8 100644 --- a/src/renderer/src/components/task-page/jira/use-task-page-jira-summary-generation.ts +++ b/src/renderer/src/components/task-page/jira/creation/use-task-page-jira-summary-generation.ts @@ -3,16 +3,16 @@ import { taskProviderErrorText } from '@/lib/work-items/task-provider-error-copy' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { createNonSecureContextUuid as createBrowserUuid } from '../../../../../shared/non-secure-context-uuid' +import { createNonSecureContextUuid as createBrowserUuid } from '../../../../../../shared/non-secure-context-uuid' import { isWebClientLocation } from '@/app-shell/platform/web-client-location' import { ipcOrRuntimeTargetForHostId } from '@/runtime/rpc/targets/runtime-client-target' import { generateRuntimeJiraIssueSummary, cancelRuntimeJiraIssueSummary } from '@/runtime/jira/runtime-jira-summary-generation-client' -import type { RuntimeClientTarget } from '../../../../../shared/runtime-client-target' -import type { JiraIssueSummaryGenerationRequest } from '../../../../../shared/jira/jira-issue-summary-generation' -import { getTaskSourceCacheScope } from '../../../../../shared/work-items/task-source-context' +import type { RuntimeClientTarget } from '../../../../../../shared/runtime-client-target' +import type { JiraIssueSummaryGenerationRequest } from '../../../../../../shared/jira/jira-issue-summary-generation' +import { getTaskSourceCacheScope } from '../../../../../../shared/work-items/task-source-context' import { translate } from '@/i18n/i18n' import type { TaskPageJiraIssueCreationModel } from './use-task-page-jira-issue-creation' diff --git a/src/renderer/src/components/task-page/jira/TaskPageJiraFavouriteFilters.tsx b/src/renderer/src/components/task-page/jira/filters/TaskPageJiraFavouriteFilters.tsx similarity index 98% rename from src/renderer/src/components/task-page/jira/TaskPageJiraFavouriteFilters.tsx rename to src/renderer/src/components/task-page/jira/filters/TaskPageJiraFavouriteFilters.tsx index fd6f6f70b7f..6001a5cde4c 100644 --- a/src/renderer/src/components/task-page/jira/TaskPageJiraFavouriteFilters.tsx +++ b/src/renderer/src/components/task-page/jira/filters/TaskPageJiraFavouriteFilters.tsx @@ -10,7 +10,7 @@ import { CommandItem, CommandEmpty } from '@/components/ui/command' -import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions' +import type { TaskPageComposerActionsModel } from '../../use-task-page-composer-actions' export function TaskPageJiraFavouriteFilters({ model diff --git a/src/renderer/src/components/task-page/jira/TaskPageJiraSavedFilters.tsx b/src/renderer/src/components/task-page/jira/filters/TaskPageJiraSavedFilters.tsx similarity index 98% rename from src/renderer/src/components/task-page/jira/TaskPageJiraSavedFilters.tsx rename to src/renderer/src/components/task-page/jira/filters/TaskPageJiraSavedFilters.tsx index 93ec324453f..1516976151a 100644 --- a/src/renderer/src/components/task-page/jira/TaskPageJiraSavedFilters.tsx +++ b/src/renderer/src/components/task-page/jira/filters/TaskPageJiraSavedFilters.tsx @@ -1,4 +1,4 @@ -import { isUtf8ByteLengthWithinLimit } from '../../../../../shared/utf8-byte-limits' +import { isUtf8ByteLengthWithinLimit } from '../../../../../../shared/utf8-byte-limits' import { useState } from 'react' import { Check, ListFilter, Pencil, Plus, Trash2 } from 'lucide-react' import { @@ -6,7 +6,7 @@ import { MAX_JIRA_SAVED_FILTER_NAME_LENGTH, MAX_JIRA_SAVED_FILTERS, type JiraSavedFilter -} from '../../../../../shared/jira/jira-saved-filters' +} from '../../../../../../shared/jira/jira-saved-filters' import { useTranslate } from '@/i18n/use-translate' import { Command, diff --git a/src/renderer/src/components/task-page/jira/TaskPageJiraStatusFilters.tsx b/src/renderer/src/components/task-page/jira/filters/TaskPageJiraStatusFilters.tsx similarity index 94% rename from src/renderer/src/components/task-page/jira/TaskPageJiraStatusFilters.tsx rename to src/renderer/src/components/task-page/jira/filters/TaskPageJiraStatusFilters.tsx index 90901e76c61..32342f7ba56 100644 --- a/src/renderer/src/components/task-page/jira/TaskPageJiraStatusFilters.tsx +++ b/src/renderer/src/components/task-page/jira/filters/TaskPageJiraStatusFilters.tsx @@ -2,13 +2,13 @@ import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' import { useTranslate } from '@/i18n/use-translate' -import { buildJiraPresetJql } from '../../../../../shared/jira/jira-preset-jql' +import { buildJiraPresetJql } from '../../../../../../shared/jira/jira-preset-jql' import { getJiraStatusCatalogScopeKey, includeSelectedJiraStatuses } from './task-page-jira-status-catalog' -import { restrictJiraQueryToProject } from '../../../../../shared/jira/jira-project-reference' -import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions' +import { restrictJiraQueryToProject } from '../../../../../../shared/jira/jira-project-reference' +import type { TaskPageComposerActionsModel } from '../../use-task-page-composer-actions' export function TaskPageJiraStatusFilters({ model diff --git a/src/renderer/src/components/task-page/jira/task-page-jira-status-catalog.ts b/src/renderer/src/components/task-page/jira/filters/task-page-jira-status-catalog.ts similarity index 87% rename from src/renderer/src/components/task-page/jira/task-page-jira-status-catalog.ts rename to src/renderer/src/components/task-page/jira/filters/task-page-jira-status-catalog.ts index 79b9eeb958f..3d1b6b9e113 100644 --- a/src/renderer/src/components/task-page/jira/task-page-jira-status-catalog.ts +++ b/src/renderer/src/components/task-page/jira/filters/task-page-jira-status-catalog.ts @@ -1,6 +1,6 @@ -import type { JiraIssue } from '../../../../../shared/jira/jira-types' -import type { JiraProjectReference } from '../../../../../shared/jira/jira-project-reference' -import { getJiraProjectSelectionKey } from './task-page-jira-project-selection' +import type { JiraIssue } from '../../../../../../shared/jira/jira-types' +import type { JiraProjectReference } from '../../../../../../shared/jira/jira-project-reference' +import { getJiraProjectSelectionKey } from '../task-page-jira-project-selection' export function getJiraStatusCatalogScopeKey( sourceScopeKey: string, diff --git a/src/renderer/src/components/task-page/jira/use-task-page-jira-favourite-filters.ts b/src/renderer/src/components/task-page/jira/filters/use-task-page-jira-favourite-filters.ts similarity index 96% rename from src/renderer/src/components/task-page/jira/use-task-page-jira-favourite-filters.ts rename to src/renderer/src/components/task-page/jira/filters/use-task-page-jira-favourite-filters.ts index bd5ebab979c..24b1f3618ea 100644 --- a/src/renderer/src/components/task-page/jira/use-task-page-jira-favourite-filters.ts +++ b/src/renderer/src/components/task-page/jira/filters/use-task-page-jira-favourite-filters.ts @@ -4,8 +4,8 @@ import { jiraListFavouriteFiltersForTarget, type JiraFavouriteFiltersResult } from '@/runtime/jira/runtime-jira-favourite-filters-client' -import type { JiraFavouriteFilter } from '../../../../../shared/jira/jira-favourite-filters' -import type { TaskPageLinearViewStateModel } from '../linear/use-task-page-linear-view-state' +import type { JiraFavouriteFilter } from '../../../../../../shared/jira/jira-favourite-filters' +import type { TaskPageLinearViewStateModel } from '../../linear/use-task-page-linear-view-state' import { translate } from '@/i18n/i18n' import { useTranslate } from '@/i18n/use-translate' diff --git a/src/renderer/src/components/task-page/jira/use-task-page-jira-project-scope.test.tsx b/src/renderer/src/components/task-page/jira/filters/use-task-page-jira-project-scope.test.tsx similarity index 97% rename from src/renderer/src/components/task-page/jira/use-task-page-jira-project-scope.test.tsx rename to src/renderer/src/components/task-page/jira/filters/use-task-page-jira-project-scope.test.tsx index ad095a2911e..30f03b92a4e 100644 --- a/src/renderer/src/components/task-page/jira/use-task-page-jira-project-scope.test.tsx +++ b/src/renderer/src/components/task-page/jira/filters/use-task-page-jira-project-scope.test.tsx @@ -3,7 +3,7 @@ import { act, cleanup, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from '@/store/index' -import type { Repo } from '../../../../../shared/repo-types' +import type { Repo } from '../../../../../../shared/repo-types' import { useTaskPageJiraProjectScope } from './use-task-page-jira-project-scope' const { readSavedFilters, mutateSavedFilters, readQuickFilters, writeQuickFilters } = vi.hoisted( diff --git a/src/renderer/src/components/task-page/jira/use-task-page-jira-project-scope.ts b/src/renderer/src/components/task-page/jira/filters/use-task-page-jira-project-scope.ts similarity index 79% rename from src/renderer/src/components/task-page/jira/use-task-page-jira-project-scope.ts rename to src/renderer/src/components/task-page/jira/filters/use-task-page-jira-project-scope.ts index 4f562a9d885..152fb3156a0 100644 --- a/src/renderer/src/components/task-page/jira/use-task-page-jira-project-scope.ts +++ b/src/renderer/src/components/task-page/jira/filters/use-task-page-jira-project-scope.ts @@ -1,9 +1,12 @@ import { useCallback, useState } from 'react' -import type { Repo } from '../../../../../shared/repo-types' -import type { JiraProjectReference } from '../../../../../shared/jira/jira-project-reference' -import { getRepoExecutionHostId, type ExecutionHostId } from '../../../../../shared/execution-host' -import { resolveJiraSourceHostId } from '../../../../../shared/jira/jira-source-host' -import { getRepoHostIdentity } from '../../../../../shared/repo-host-identity' +import type { Repo } from '../../../../../../shared/repo-types' +import type { JiraProjectReference } from '../../../../../../shared/jira/jira-project-reference' +import { + getRepoExecutionHostId, + type ExecutionHostId +} from '../../../../../../shared/execution-host' +import { resolveJiraSourceHostId } from '../../../../../../shared/jira/jira-source-host' +import { getRepoHostIdentity } from '../../../../../../shared/repo-host-identity' export function useTaskPageJiraProjectScope( repos: readonly Repo[], diff --git a/src/renderer/src/components/task-page/jira/filters/use-task-page-jira-quick-filters.test.tsx b/src/renderer/src/components/task-page/jira/filters/use-task-page-jira-quick-filters.test.tsx new file mode 100644 index 00000000000..22a609c5a82 --- /dev/null +++ b/src/renderer/src/components/task-page/jira/filters/use-task-page-jira-quick-filters.test.tsx @@ -0,0 +1,59 @@ +// @vitest-environment happy-dom +import { act, cleanup, renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { useTaskPageJiraQuickFilters } from './use-task-page-jira-quick-filters' +import type { JiraQuickFilterState } from '../../../../../../shared/jira/jira-quick-filters' +import { toRuntimeExecutionHostId } from '../../../../../../shared/execution-host' +const { read, write } = vi.hoisted(() => ({ read: vi.fn(), write: vi.fn() })) +vi.mock('@/runtime/jira/runtime-jira-quick-filter-state', () => ({ + readJiraQuickFilterState: read, + writeJiraQuickFilterState: write +})) +beforeEach(() => { + vi.resetAllMocks() +}) +afterEach(cleanup) +function model( + host: string | null, + accountHost = 'account-owner' +): Parameters[0] { + return { + accountBackedTaskSourceHostId: toRuntimeExecutionHostId(accountHost), + taskSource: 'jira', + taskResumeApplied: true, + jiraTaskSourceContext: host + ? { + kind: 'task-source', + provider: 'jira', + projectId: 'project', + hostId: toRuntimeExecutionHostId(host) + } + : null + } +} +it('ignores a late mutation reply after changing Jira execution hosts', async () => { + read + .mockResolvedValueOnce({ statuses: ['Blocked'] }) + .mockResolvedValueOnce({ statuses: ['Open'] }) + let finish: (value: JiraQuickFilterState) => void = () => {} + write.mockReturnValue( + new Promise((resolve) => { + finish = resolve + }) + ) + const { result, rerender } = renderHook(({ host }) => useTaskPageJiraQuickFilters(model(host)), { + initialProps: { host: 'a' } + }) + await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false)) + let pending: Promise = Promise.resolve() + act(() => { + pending = result.current.selectJiraStatuses(['In Progress']) + }) + rerender({ host: 'b' }) + await waitFor(() => expect(result.current.selectedJiraStatuses).toEqual(['Open'])) + await act(async () => { + finish({ statuses: ['In Progress'] }) + await pending + }) + expect(result.current.selectedJiraStatuses).toEqual(['Open']) +}) diff --git a/src/renderer/src/components/task-page/jira/use-task-page-jira-quick-filters.ts b/src/renderer/src/components/task-page/jira/filters/use-task-page-jira-quick-filters.ts similarity index 96% rename from src/renderer/src/components/task-page/jira/use-task-page-jira-quick-filters.ts rename to src/renderer/src/components/task-page/jira/filters/use-task-page-jira-quick-filters.ts index 643fc68755e..20975f4b884 100644 --- a/src/renderer/src/components/task-page/jira/use-task-page-jira-quick-filters.ts +++ b/src/renderer/src/components/task-page/jira/filters/use-task-page-jira-quick-filters.ts @@ -7,8 +7,8 @@ import { import { normalizeJiraQuickFilterState, type JiraQuickFilterState -} from '../../../../../shared/jira/jira-quick-filters' -import type { TaskPageLinearViewStateModel } from '../linear/use-task-page-linear-view-state' +} from '../../../../../../shared/jira/jira-quick-filters' +import type { TaskPageLinearViewStateModel } from '../../linear/use-task-page-linear-view-state' import { translate } from '@/i18n/i18n' import { useTranslate } from '@/i18n/use-translate' diff --git a/src/renderer/src/components/task-page/jira/filters/use-task-page-jira-saved-filters.test.tsx b/src/renderer/src/components/task-page/jira/filters/use-task-page-jira-saved-filters.test.tsx new file mode 100644 index 00000000000..9d536b63eb9 --- /dev/null +++ b/src/renderer/src/components/task-page/jira/filters/use-task-page-jira-saved-filters.test.tsx @@ -0,0 +1,58 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { act, cleanup, renderHook, waitFor } from '@testing-library/react' + +import { useTaskPageJiraSavedFilters } from './use-task-page-jira-saved-filters' +import { toRuntimeExecutionHostId } from '../../../../../../shared/execution-host' +const { read, mutate } = vi.hoisted(() => ({ read: vi.fn(), mutate: vi.fn() })) +vi.mock('@/runtime/jira/runtime-jira-saved-filter-state', () => ({ + readJiraSavedFilterState: read, + mutateJiraSavedFilters: mutate +})) +afterEach(cleanup) +beforeEach(() => { + vi.resetAllMocks() +}) +const filter = { id: 'mine', name: 'My work', jql: 'project = APP' } +const initial = { filters: [filter], activeFilterId: filter.id } +function model( + host: string | null, + accountHost = 'account-owner' +): Parameters[0] { + return { + taskSource: 'jira', + taskResumeApplied: true, + accountBackedTaskSourceHostId: toRuntimeExecutionHostId(accountHost), + jiraTaskSourceContext: host + ? { + kind: 'task-source', + provider: 'jira', + projectId: 'project', + hostId: toRuntimeExecutionHostId(host) + } + : null + } +} +it('ignores an old host read that completes after switching execution hosts', async () => { + let resolveOld: (value: typeof initial) => void = () => {} + read + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveOld = resolve + }) + ) + .mockResolvedValueOnce({ filters: [], activeFilterId: null }) + const setQuery = vi.fn() + const { result, rerender } = renderHook( + ({ host }) => useTaskPageJiraSavedFilters(model(host), setQuery), + { initialProps: { host: 'host-a' } } + ) + rerender({ host: 'host-b' }) + await waitFor(() => expect(result.current.jiraSavedFiltersReady).toBe(true)) + await act(async () => { + resolveOld(initial) + }) + expect(result.current.jiraSavedFilters).toEqual([]) + expect(setQuery).not.toHaveBeenCalledWith(filter.jql) +}) diff --git a/src/renderer/src/components/task-page/jira/use-task-page-jira-saved-filters.ts b/src/renderer/src/components/task-page/jira/filters/use-task-page-jira-saved-filters.ts similarity index 96% rename from src/renderer/src/components/task-page/jira/use-task-page-jira-saved-filters.ts rename to src/renderer/src/components/task-page/jira/filters/use-task-page-jira-saved-filters.ts index 33a251c9baa..249b7f66ec7 100644 --- a/src/renderer/src/components/task-page/jira/use-task-page-jira-saved-filters.ts +++ b/src/renderer/src/components/task-page/jira/filters/use-task-page-jira-saved-filters.ts @@ -1,4 +1,4 @@ -import { createNonSecureContextUuid } from '../../../../../shared/non-secure-context-uuid' +import { createNonSecureContextUuid } from '../../../../../../shared/non-secure-context-uuid' import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { ipcOrRuntimeTargetForHostId } from '@/runtime/rpc/targets/runtime-client-target' import { @@ -9,8 +9,8 @@ import type { JiraSavedFilterMutation, JiraSavedFilter, JiraSavedFilterState -} from '../../../../../shared/jira/jira-saved-filters' -import type { TaskPageLinearViewStateModel } from '../linear/use-task-page-linear-view-state' +} from '../../../../../../shared/jira/jira-saved-filters' +import type { TaskPageLinearViewStateModel } from '../../linear/use-task-page-linear-view-state' import { translate } from '@/i18n/i18n' import { useTranslate } from '@/i18n/use-translate' diff --git a/src/renderer/src/components/task-page/jira/task-page-jira-cache-selectors.ts b/src/renderer/src/components/task-page/jira/listing/task-page-jira-cache-selectors.ts similarity index 91% rename from src/renderer/src/components/task-page/jira/task-page-jira-cache-selectors.ts rename to src/renderer/src/components/task-page/jira/listing/task-page-jira-cache-selectors.ts index 3cd7c652cf2..1a82b394c33 100644 --- a/src/renderer/src/components/task-page/jira/task-page-jira-cache-selectors.ts +++ b/src/renderer/src/components/task-page/jira/listing/task-page-jira-cache-selectors.ts @@ -2,8 +2,8 @@ import type { CacheEntry } from '@/store/github/cache/cache-model' import { getTaskSourceCacheScope, type TaskSourceContext -} from '../../../../../shared/work-items/task-source-context' -import type { JiraIssue } from '../../../../../shared/jira/jira-types' +} from '../../../../../../shared/work-items/task-source-context' +import type { JiraIssue } from '../../../../../../shared/jira/jira-types' type JiraIssueCache = Record> type JiraSearchCache = Record> diff --git a/src/renderer/src/components/task-page/jira/listing/task-page-jira-error-banner.tsx b/src/renderer/src/components/task-page/jira/listing/task-page-jira-error-banner.tsx new file mode 100644 index 00000000000..66c7e3348d0 --- /dev/null +++ b/src/renderer/src/components/task-page/jira/listing/task-page-jira-error-banner.tsx @@ -0,0 +1,52 @@ +import { useTranslate } from '../../../../i18n/use-translate' +import type { TaskPageJiraLoadError } from './task-page-jira-load-state' +import React from 'react' +import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '../../../ui/collapsible' +import { AlertCircle, ChevronDown, ChevronRight } from 'lucide-react' +import { Button } from '../../../ui/button' + +export function TaskPageJiraErrorBanner({ + error, + open, + onOpenChange +}: { + error: TaskPageJiraLoadError + open: boolean + onOpenChange: (open: boolean) => void +}): React.JSX.Element { + const t = useTranslate() + return ( + +
+ +
+
{error.title}
+ {error.details ? ( + <> + + + + +
+ {error.details} +
+
+ + ) : null} +
+
+ + ) +} diff --git a/src/renderer/src/components/task-page/jira/task-page-jira-issue-cells.tsx b/src/renderer/src/components/task-page/jira/listing/task-page-jira-issue-cells.tsx similarity index 97% rename from src/renderer/src/components/task-page/jira/task-page-jira-issue-cells.tsx rename to src/renderer/src/components/task-page/jira/listing/task-page-jira-issue-cells.tsx index 62d2d04066a..9aa443b1006 100644 --- a/src/renderer/src/components/task-page/jira/task-page-jira-issue-cells.tsx +++ b/src/renderer/src/components/task-page/jira/listing/task-page-jira-issue-cells.tsx @@ -4,12 +4,12 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { translate } from '@/i18n/i18n' import { useIntlLocale, useTranslate, type Translate } from '@/i18n/use-translate' import { cn } from '@/lib/class-names' -import type { JiraIssue } from '../../../../../shared/jira/jira-types' +import type { JiraIssue } from '../../../../../../shared/jira/jira-types' import { formatJiraEstimate, formatJiraStoryPoints, type JiraListColumnId -} from '../../jira-list-columns' +} from '../../../jira-list-columns' import { getJiraPriorityTone } from './task-page-jira-status-tone' const MUTED_CELL = 'block truncate text-[12px] text-muted-foreground' diff --git a/src/renderer/src/components/task-page/jira/task-page-jira-issue-list.tsx b/src/renderer/src/components/task-page/jira/listing/task-page-jira-issue-list.tsx similarity index 97% rename from src/renderer/src/components/task-page/jira/task-page-jira-issue-list.tsx rename to src/renderer/src/components/task-page/jira/listing/task-page-jira-issue-list.tsx index c54a848b1dc..ff2f91ea43b 100644 --- a/src/renderer/src/components/task-page/jira/task-page-jira-issue-list.tsx +++ b/src/renderer/src/components/task-page/jira/listing/task-page-jira-issue-list.tsx @@ -6,12 +6,12 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/component import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useTranslate } from '@/i18n/use-translate' import { cn } from '@/lib/class-names' -import { JiraIssueRowCopyMenu } from '../../JiraIssueRowCopyMenu' -import { JiraLinkedWorkspaceAction } from '../../JiraLinkedWorkspaceAction' -import type { Worktree } from '../../../../../shared/worktree/types' -import { jiraListColumnVisibility, type JiraListColumn } from '../../jira-list-columns' +import { JiraIssueRowCopyMenu } from '../../../JiraIssueRowCopyMenu' +import { JiraLinkedWorkspaceAction } from '../../../JiraLinkedWorkspaceAction' +import type { Worktree } from '../../../../../../shared/worktree/types' +import { jiraListColumnVisibility, type JiraListColumn } from '../../../jira-list-columns' import { JiraIssueCell, JiraPriorityText, unassignedLabel } from './task-page-jira-issue-cells' -import type { JiraIssue, JiraProjectStatusOrder } from '../../../../../shared/jira/jira-types' +import type { JiraIssue, JiraProjectStatusOrder } from '../../../../../../shared/jira/jira-types' import { openAppHttpLink } from '@/lib/browser/navigation/app-http-link-open' export type TaskPageJiraIssueSection = { diff --git a/src/renderer/src/components/task-page/jira/task-page-jira-linked-issues.ts b/src/renderer/src/components/task-page/jira/listing/task-page-jira-linked-issues.ts similarity index 89% rename from src/renderer/src/components/task-page/jira/task-page-jira-linked-issues.ts rename to src/renderer/src/components/task-page/jira/listing/task-page-jira-linked-issues.ts index f7dd8ccabf2..7a1b78d8815 100644 --- a/src/renderer/src/components/task-page/jira/task-page-jira-linked-issues.ts +++ b/src/renderer/src/components/task-page/jira/listing/task-page-jira-linked-issues.ts @@ -1,7 +1,7 @@ -import type { JiraIssue } from '../../../../../shared/jira/jira-types' +import type { JiraIssue } from '../../../../../../shared/jira/jira-types' import type { LinkedJiraIssueRef } from '@/lib/work-items/jira/jira-issue-workspace-attachment' -import type { TaskSourceContext } from '../../../../../shared/work-items/task-source-context' -import { readLinkedTaskIssuesWithLimit } from '../../read-linked-task-issues' +import type { TaskSourceContext } from '../../../../../../shared/work-items/task-source-context' +import { readLinkedTaskIssuesWithLimit } from '../../../read-linked-task-issues' import { createTaskPageJiraLoadFailureState } from './task-page-jira-load-state' import { translate } from '@/i18n/i18n' diff --git a/src/renderer/src/components/task-page/jira/task-page-jira-load-state.ts b/src/renderer/src/components/task-page/jira/listing/task-page-jira-load-state.ts similarity index 95% rename from src/renderer/src/components/task-page/jira/task-page-jira-load-state.ts rename to src/renderer/src/components/task-page/jira/listing/task-page-jira-load-state.ts index e5940978fdb..7fe760f476e 100644 --- a/src/renderer/src/components/task-page/jira/task-page-jira-load-state.ts +++ b/src/renderer/src/components/task-page/jira/listing/task-page-jira-load-state.ts @@ -1,5 +1,5 @@ -import type { JiraIssue } from '../../../../../shared/jira/jira-types' -import { parseJiraStatusError } from '../../../../../shared/jira/jira-status-error' +import type { JiraIssue } from '../../../../../../shared/jira/jira-types' +import { parseJiraStatusError } from '../../../../../../shared/jira/jira-status-error' import { translate } from '@/i18n/i18n' export type TaskPageJiraLoadError = { diff --git a/src/renderer/src/components/task-page/jira/task-page-jira-search.ts b/src/renderer/src/components/task-page/jira/listing/task-page-jira-search.ts similarity index 87% rename from src/renderer/src/components/task-page/jira/task-page-jira-search.ts rename to src/renderer/src/components/task-page/jira/listing/task-page-jira-search.ts index 80e3669481c..34b7d68fbe4 100644 --- a/src/renderer/src/components/task-page/jira/task-page-jira-search.ts +++ b/src/renderer/src/components/task-page/jira/listing/task-page-jira-search.ts @@ -1,6 +1,9 @@ -import type { JiraIssue } from '../../../../../shared/jira/jira-types' -import { buildJiraTextMatchJql, mayBeJql } from '../../../../../shared/jira/jira-search-input-jql' -import { searchJiraIssuesByKeyOrText } from '../../jira-key-or-text-search' +import type { JiraIssue } from '../../../../../../shared/jira/jira-types' +import { + buildJiraTextMatchJql, + mayBeJql +} from '../../../../../../shared/jira/jira-search-input-jql' +import { searchJiraIssuesByKeyOrText } from '../../../jira-key-or-text-search' import { getJiraBadRequestReason } from './task-page-jira-load-state' export type TaskPageJiraJqlRejection = { diff --git a/src/renderer/src/components/task-page/jira/task-page-jira-sort-controls.tsx b/src/renderer/src/components/task-page/jira/listing/task-page-jira-sort-controls.tsx similarity index 98% rename from src/renderer/src/components/task-page/jira/task-page-jira-sort-controls.tsx rename to src/renderer/src/components/task-page/jira/listing/task-page-jira-sort-controls.tsx index bbd2312c42f..57babdf9f10 100644 --- a/src/renderer/src/components/task-page/jira/task-page-jira-sort-controls.tsx +++ b/src/renderer/src/components/task-page/jira/listing/task-page-jira-sort-controls.tsx @@ -15,8 +15,8 @@ import { getJiraListColumnLabel, jiraListColumnVisibility, type JiraListColumn -} from '../../jira-list-columns' -import type { JiraIssueSortColumn, JiraIssueSortDirection } from '../../jira-issue-sorter' +} from '../../../jira-list-columns' +import type { JiraIssueSortColumn, JiraIssueSortDirection } from '../../../jira-issue-sorter' type TaskPageJiraSortControlsProps = { columns: readonly JiraListColumn[] diff --git a/src/renderer/src/components/task-page/jira/task-page-jira-status-order.ts b/src/renderer/src/components/task-page/jira/listing/task-page-jira-status-order.ts similarity index 95% rename from src/renderer/src/components/task-page/jira/task-page-jira-status-order.ts rename to src/renderer/src/components/task-page/jira/listing/task-page-jira-status-order.ts index adef776958e..48d3bfabb27 100644 --- a/src/renderer/src/components/task-page/jira/task-page-jira-status-order.ts +++ b/src/renderer/src/components/task-page/jira/listing/task-page-jira-status-order.ts @@ -1,5 +1,5 @@ -import type { JiraIssue, JiraProjectStatusOrder } from '../../../../../shared/jira/jira-types' -import type { RuntimeClientTarget } from '../../../../../shared/runtime-client-target' +import type { JiraIssue, JiraProjectStatusOrder } from '../../../../../../shared/jira/jira-types' +import type { RuntimeClientTarget } from '../../../../../../shared/runtime-client-target' import { runtimeTargetEnvironmentId } from '@/runtime/rpc/targets/runtime-client-target' import { getEnvironmentRuntimeContextKey } from '@/runtime/hosts/observations/provider-runtime-context' import { jiraGetProjectStatusOrder } from '@/runtime/jira/runtime-jira-client' diff --git a/src/renderer/src/components/task-page/jira/task-page-jira-status-tone.ts b/src/renderer/src/components/task-page/jira/listing/task-page-jira-status-tone.ts similarity index 100% rename from src/renderer/src/components/task-page/jira/task-page-jira-status-tone.ts rename to src/renderer/src/components/task-page/jira/listing/task-page-jira-status-tone.ts diff --git a/src/renderer/src/components/task-page/jira/task-page-jira-text-fallback-notice.tsx b/src/renderer/src/components/task-page/jira/listing/task-page-jira-text-fallback-notice.tsx similarity index 100% rename from src/renderer/src/components/task-page/jira/task-page-jira-text-fallback-notice.tsx rename to src/renderer/src/components/task-page/jira/listing/task-page-jira-text-fallback-notice.tsx diff --git a/src/renderer/src/components/task-page/jira/use-task-page-jira-list-effects.ts b/src/renderer/src/components/task-page/jira/listing/use-task-page-jira-list-effects.ts similarity index 92% rename from src/renderer/src/components/task-page/jira/use-task-page-jira-list-effects.ts rename to src/renderer/src/components/task-page/jira/listing/use-task-page-jira-list-effects.ts index 011c0d589a9..f51132496cc 100644 --- a/src/renderer/src/components/task-page/jira/use-task-page-jira-list-effects.ts +++ b/src/renderer/src/components/task-page/jira/listing/use-task-page-jira-list-effects.ts @@ -1,26 +1,26 @@ import { translate } from '@/i18n/i18n' import { isRuntimeProviderSearchQueryWithinLimit } from '@/runtime/issue-search/runtime-provider-search-bounds' -import { withCurrentSprintClause } from '../../../../../shared/jira/jira-jql-predicate' +import { withCurrentSprintClause } from '../../../../../../shared/jira/jira-jql-predicate' import { buildJiraStatusCatalog, getJiraStatusCatalogScopeKey -} from './task-page-jira-status-catalog' -import type { TaskPageLinearCollectionEffectsModel } from '../linear/use-task-page-linear-collection-effects' +} from '../filters/task-page-jira-status-catalog' +import type { TaskPageLinearCollectionEffectsModel } from '../../linear/projects/use-task-page-linear-collection-effects' import { useEffect, useMemo, useRef } from 'react' import { getSingleJiraProjectScope, getTaskPageJiraStatusOrderScopeKey, getTaskPageJiraStatusOrderRuntimeScopeKey, loadTaskPageJiraProjectStatusOrder -} from '@/components/task-page/jira/task-page-jira-status-order' -import { createTaskPageJiraLoadFailureState } from '@/components/task-page/jira/task-page-jira-load-state' -import { searchTaskPageJiraIssues } from '@/components/task-page/jira/task-page-jira-search' -import { JIRA_ITEM_LIMIT } from '../task-page-source-context' -import { useTaskSearchDebouncedApply } from '../use-task-search-debounced-apply' +} from '@/components/task-page/jira/listing/task-page-jira-status-order' +import { createTaskPageJiraLoadFailureState } from '@/components/task-page/jira/listing/task-page-jira-load-state' +import { searchTaskPageJiraIssues } from '@/components/task-page/jira/listing/task-page-jira-search' +import { JIRA_ITEM_LIMIT } from '../../task-page-source-context' +import { useTaskSearchDebouncedApply } from '../../use-task-search-debounced-apply' import { readLinkedJiraIssues } from './task-page-jira-linked-issues' import { ipcOrRuntimeTargetForHostId } from '@/runtime/rpc/targets/runtime-client-target' -import { buildJiraPresetJql } from '../../../../../shared/jira/jira-preset-jql' -import { restrictJiraQueryToProject } from '../../../../../shared/jira/jira-project-reference' +import { buildJiraPresetJql } from '../../../../../../shared/jira/jira-preset-jql' +import { restrictJiraQueryToProject } from '../../../../../../shared/jira/jira-project-reference' export function useTaskPageJiraListEffects(model: TaskPageLinearCollectionEffectsModel) { const { accountBackedTaskSourceHostId, diff --git a/src/renderer/src/components/task-page/jira/use-task-page-jira-list-projection.ts b/src/renderer/src/components/task-page/jira/listing/use-task-page-jira-list-projection.ts similarity index 93% rename from src/renderer/src/components/task-page/jira/use-task-page-jira-list-projection.ts rename to src/renderer/src/components/task-page/jira/listing/use-task-page-jira-list-projection.ts index 5e5afd56182..82ff87c1ec0 100644 --- a/src/renderer/src/components/task-page/jira/use-task-page-jira-list-projection.ts +++ b/src/renderer/src/components/task-page/jira/listing/use-task-page-jira-list-projection.ts @@ -1,11 +1,11 @@ -import type { TaskPageLinearBoardModel } from '../linear/use-task-page-linear-board' +import type { TaskPageLinearBoardModel } from '../../linear/issues/use-task-page-linear-board' import { useMemo, useCallback } from 'react' -import { findTaskPageJiraIssue } from '@/components/task-page/jira/task-page-jira-cache-selectors' +import { findTaskPageJiraIssue } from '@/components/task-page/jira/listing/task-page-jira-cache-selectors' import { getSingleJiraProjectScope, getTaskPageJiraStatusOrderScopeKey, getTaskPageJiraStatusOrderRuntimeScopeKey -} from '@/components/task-page/jira/task-page-jira-status-order' +} from '@/components/task-page/jira/listing/task-page-jira-status-order' import { ipcOrRuntimeTargetForHostId } from '@/runtime/rpc/targets/runtime-client-target' import { buildJiraIssueWorkspaceAttachmentIndex, @@ -13,7 +13,7 @@ import { findJiraIssueWorkspaceAttachmentsInIndex } from '@/lib/work-items/jira/jira-issue-workspace-attachment' import { filterLinkedJiraIssues } from './task-page-jira-linked-issues' -import { sortJiraIssues } from '../../jira-issue-sorter' +import { sortJiraIssues } from '../../../jira-issue-sorter' export function useTaskPageJiraListProjection(model: TaskPageLinearBoardModel) { const { accountBackedTaskSourceHostId, diff --git a/src/renderer/src/components/task-page/jira/task-page-jira-board-transitions.test.ts b/src/renderer/src/components/task-page/jira/task-page-jira-board-transitions.test.ts deleted file mode 100644 index 54a512bc239..00000000000 --- a/src/renderer/src/components/task-page/jira/task-page-jira-board-transitions.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { JiraIssue, JiraStatus, JiraTransition } from '../../../../../shared/jira/jira-types' -import type { RuntimeClientTarget } from '../../../../../shared/runtime-client-target' - -const mocks = vi.hoisted(() => ({ - jiraListTransitionsForTarget: vi.fn() -})) - -vi.mock('@/runtime/jira/runtime-jira-detail-client', () => ({ - jiraListTransitionsForTarget: mocks.jiraListTransitionsForTarget -})) -vi.mock('@/runtime/jira/runtime-jira-detail-client', () => ({ - jiraListTransitionsForTarget: mocks.jiraListTransitionsForTarget -})) - -const { loadTaskPageJiraIssueTransitions } = await import('./task-page-jira-board-transitions') -const target: RuntimeClientTarget = { kind: 'local' } - -function status(id: string, name: string): JiraStatus { - return { id, name, categoryKey: 'indeterminate', categoryName: 'In Progress' } -} - -function transition(id: string, to: JiraStatus): JiraTransition { - return { id, name: `To ${to.name}`, to } -} - -function issue(key: string, statusValue: JiraStatus, siteId?: string): JiraIssue { - return { - id: key, - key, - siteId, - title: key, - url: `https://example.atlassian.net/browse/${key}`, - project: { id: 'p1', key: 'STA', name: 'Stably' }, - issueType: { id: 't1', name: 'Task' }, - status: statusValue, - labels: [], - updatedAt: '2026-08-07T12:00:00.000Z', - createdAt: '2026-08-07T12:00:00.000Z' - } -} - -describe('loadTaskPageJiraIssueTransitions', () => { - beforeEach(() => { - mocks.jiraListTransitionsForTarget.mockReset() - }) - - it('surfaces a failed transitions request and allows an explicit retry', async () => { - mocks.jiraListTransitionsForTarget.mockRejectedValue(new Error('offline')) - await expect( - loadTaskPageJiraIssueTransitions( - target, - 'scope', - issue('STA-500', status('2', 'In Progress')) - ) - ).rejects.toThrow('offline') - mocks.jiraListTransitionsForTarget.mockResolvedValue([]) - await expect( - loadTaskPageJiraIssueTransitions( - target, - 'scope', - issue('STA-500', status('2', 'In Progress')), - true - ) - ).resolves.toEqual([]) - expect(mocks.jiraListTransitionsForTarget).toHaveBeenCalledTimes(2) - }) - - it('keeps concurrent local and remote metadata apart even with the same scope', async () => { - const remote: RuntimeClientTarget = { kind: 'environment', environmentId: 'owner' } - const currentIssue = issue('STA-OWNER', status('2', 'In Progress'), 'site-a') - const localAction = transition('local-done', status('3', 'Done')) - const remoteAction = transition('remote-done', status('3', 'Done')) - mocks.jiraListTransitionsForTarget.mockImplementation((owner: RuntimeClientTarget) => - Promise.resolve(owner.kind === 'local' ? [localAction] : [remoteAction]) - ) - await expect( - Promise.all([ - loadTaskPageJiraIssueTransitions(target, 'same-scope', currentIssue), - loadTaskPageJiraIssueTransitions(remote, 'same-scope', currentIssue) - ]) - ).resolves.toEqual([[localAction], [remoteAction]]) - await expect( - loadTaskPageJiraIssueTransitions(remote, 'same-scope', currentIssue) - ).resolves.toEqual([remoteAction]) - expect(mocks.jiraListTransitionsForTarget).toHaveBeenCalledTimes(2) - expect(mocks.jiraListTransitionsForTarget).toHaveBeenCalledWith( - remote, - currentIssue.key, - currentIssue.siteId - ) - }) -}) diff --git a/src/renderer/src/components/task-page/jira/task-page-jira-favourite-selection.test.tsx b/src/renderer/src/components/task-page/jira/task-page-jira-favourite-selection.test.tsx deleted file mode 100644 index 364a38f2b47..00000000000 --- a/src/renderer/src/components/task-page/jira/task-page-jira-favourite-selection.test.tsx +++ /dev/null @@ -1,89 +0,0 @@ -// @vitest-environment happy-dom -import { act, cleanup, renderHook, waitFor } from '@testing-library/react' -import { afterEach, beforeEach, expect, it } from 'vitest' - -import { - favouriteTransport as transport, - favourite, - named, - emptySaved, - resetFavouriteTransport, - useFavouriteTaskPage, - type FavouriteFixtureProps -} from '../../__mocks__/task-page-jira-favourite-fixture' - -afterEach(cleanup) -beforeEach(() => { - localStorage.clear() - resetFavouriteTransport() -}) - -async function ready(result: { current: ReturnType }) { - act(() => result.current.loadJiraFavouriteFilters()) - await waitFor(() => { - expect(result.current.jiraSavedFiltersLoading).toBe(false) - expect(result.current.jiraFavouriteFiltersLoading).toBe(false) - expect(result.current.jiraLoading).toBe(false) - }) -} - -it('waits for confirmed named deselection before changing query or linked mode', async () => { - transport.readSaved.mockResolvedValue({ ...emptySaved, activeFilterId: named.id }) - const { result } = renderHook(() => useFavouriteTaskPage()) - await ready(result) - act(() => result.current.setJiraHasWorkspace(true)) - const deferred = Promise.withResolvers() - transport.mutateSaved.mockReturnValueOnce(deferred.promise) - let applying = Promise.resolve(false) - act(() => { - applying = result.current.applyJiraFavouriteFilter(result.current.jiraFavouriteFilters[0]) - }) - expect(result.current.jiraFavouriteFilter).toBeNull() - expect(result.current.jiraSearchInput).toBe(named.jql) - expect(result.current.jiraHasWorkspace).toBe(true) - await act(async () => { - deferred.resolve(emptySaved) - expect(await applying).toBe(true) - }) - expect(result.current.jiraHasWorkspace).toBe(false) - expect(result.current.jiraSearchInput).toBe('') - expect(result.current.jiraFavouriteFilter?.id).toBe(favourite.id) - expect(transport.mutateSaved).toHaveBeenCalledExactlyOnceWith( - { kind: 'environment', environmentId: 'owner-a' }, - { kind: 'select', id: null } - ) -}) - -it.each(['site', 'owner', 'disabled'] as const)( - 'fences a delayed handoff across %s A→B→A', - async (kind) => { - transport.readSaved.mockResolvedValue({ ...emptySaved, activeFilterId: named.id }) - const { result, rerender } = renderHook( - (props: FavouriteFixtureProps) => useFavouriteTaskPage(props), - { - initialProps: {} - } - ) - await ready(result) - const deferred = Promise.withResolvers() - transport.mutateSaved.mockReturnValueOnce(deferred.promise) - let applying = Promise.resolve(false) - act(() => { - applying = result.current.applyJiraFavouriteFilter(result.current.jiraFavouriteFilters[0]) - }) - const other: FavouriteFixtureProps = { - hostId: kind === 'owner' ? 'runtime:owner-b' : 'runtime:owner-a', - siteId: kind === 'site' ? 'site-b' : 'site-a', - enabled: kind !== 'disabled' - } - rerender(other) - rerender({ hostId: 'runtime:owner-a', siteId: 'site-a', enabled: true }) - await ready(result) - await act(async () => { - deferred.resolve(emptySaved) - expect(await applying).toBe(false) - }) - expect(result.current.jiraFavouriteFilter).toBeNull() - expect(result.current.jiraSearchInput).not.toContain('12345') - } -) diff --git a/src/renderer/src/components/task-page/jira/task-page-jira-grouping.test.ts b/src/renderer/src/components/task-page/jira/task-page-jira-grouping.test.ts deleted file mode 100644 index 4aa765c9cb8..00000000000 --- a/src/renderer/src/components/task-page/jira/task-page-jira-grouping.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { defaultJiraListColumnIds, visibleJiraListColumns } from '../../jira-list-columns' -// @vitest-environment happy-dom - -import '@testing-library/jest-dom/vitest' - -import React from 'react' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, render, screen } from '@testing-library/react' -import userEvent from '@testing-library/user-event' - -import { TooltipProvider } from '@/components/ui/tooltip' - -import { TaskPageJiraIssueList } from './task-page-jira-issue-list' - -import type { JiraIssue } from '../../../../../shared/jira/jira-types' - -const { jiraGetProjectStatusOrderMock } = vi.hoisted(() => ({ - jiraGetProjectStatusOrderMock: vi.fn() -})) - -vi.mock('@/runtime/jira/runtime-jira-client', () => ({ - jiraGetProjectStatusOrder: (...args: unknown[]) => jiraGetProjectStatusOrderMock(...args) -})) - -afterEach(() => { - cleanup() - vi.restoreAllMocks() - jiraGetProjectStatusOrderMock.mockReset() -}) - -function jiraIssue( - key: string, - title: string, - statusId: string, - statusName: string, - options: { priority?: string; projectKey?: string; siteId?: string } = {} -): JiraIssue { - const siteId = options.siteId ?? 'site-1' - const projectKey = options.projectKey ?? 'ALP' - return { - id: `${siteId}:${key}`, - key, - title, - url: `https://example.atlassian.net/browse/${key}`, - siteId, - siteName: 'Example Jira', - project: { id: projectKey, key: projectKey, name: projectKey, siteId }, - issueType: { id: '10001', name: 'Bug' }, - status: { id: statusId, name: statusName, categoryKey: 'new', categoryName: statusName }, - priority: options.priority ? { id: options.priority, name: options.priority } : undefined, - labels: [], - createdAt: '2026-01-01T00:00:00.000Z', - updatedAt: '2026-01-01T00:00:00.000Z' - } -} - -describe('Jira issue status grouping', () => { - it('collapses and expands a status group through its accessible trigger', async () => { - const user = userEvent.setup() - render( - React.createElement( - TooltipProvider, - null, - React.createElement(TaskPageJiraIssueList, { - columns: visibleJiraListColumns(defaultJiraListColumnIds()), - formatUpdatedAt: () => 'today', - getStatusTone: () => 'border-border', - issues: [ - jiraIssue('ALP-1', 'First issue', '1', 'To Do'), - jiraIssue('ALP-2', 'Second issue', '1', 'To Do') - ], - onOpenIssue: vi.fn(), - onStartWorkspace: vi.fn(), - selectedIssue: null, - showSiteContext: false, - statusOrder: null - }) - ) - ) - - const trigger = screen.getByRole('button', { name: 'To Do 2' }) - expect(trigger).toHaveAttribute('data-variant', 'ghost') - expect(trigger).toHaveAttribute('aria-expanded', 'true') - expect(screen.getByText('First issue')).toBeInTheDocument() - - await user.click(trigger) - expect(trigger).toHaveAttribute('aria-expanded', 'false') - expect(screen.queryByText('First issue')).not.toBeInTheDocument() - - await user.click(trigger) - expect(screen.getByText('First issue')).toBeInTheDocument() - }) -}) diff --git a/src/renderer/src/components/task-page/jira/task-page-jira-quick-copy.test.tsx b/src/renderer/src/components/task-page/jira/task-page-jira-quick-copy.test.tsx deleted file mode 100644 index f74d5515a43..00000000000 --- a/src/renderer/src/components/task-page/jira/task-page-jira-quick-copy.test.tsx +++ /dev/null @@ -1,122 +0,0 @@ -// @vitest-environment happy-dom -import '@testing-library/jest-dom/vitest' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { cleanup, render, screen, waitFor } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { TooltipProvider } from '@/components/ui/tooltip' -import { TaskPageJiraIssueList } from './task-page-jira-issue-list' -import { defaultJiraListColumnIds, visibleJiraListColumns } from '../../jira-list-columns' -import type { JiraIssue } from '../../../../../shared/jira/jira-types' -const writeClipboardText = vi.fn().mockResolvedValue(undefined) -const issue: JiraIssue = { - id: '1', - key: 'APP-1', - title: 'Fix login', - url: 'https://acme.atlassian.net/browse/APP-1', - project: { id: '1', key: 'APP', name: 'App' }, - issueType: { id: '1', name: 'Bug' }, - status: { id: '1', name: 'Open', categoryKey: 'new', categoryName: 'Open' }, - labels: [], - createdAt: '2026-10-01', - updatedAt: '2026-10-01' -} -const onOpenIssue = vi.fn() -function renderList() { - return render( - - 'Today'} - getStatusTone={() => ''} - onOpenIssue={onOpenIssue} - onStartWorkspace={vi.fn()} - /> - - ) -} -beforeEach(() => { - Object.assign(window, { api: { ui: { writeClipboardText } } }) - vi.clearAllMocks() - localStorage.clear() -}) -afterEach(cleanup) -describe('Jira row quick copy', () => { - it('offers the same copy actions in the row context menu', async () => { - renderList() - const user = userEvent.setup() - await user.pointer({ keys: '[MouseRight]', target: screen.getByText('Fix login') }) - await user.click(screen.getByRole('menuitem', { name: 'Copy URL' })) - expect(writeClipboardText).toHaveBeenCalledWith(issue.url) - expect(onOpenIssue).not.toHaveBeenCalled() - }) - - it('renders a template preview with additional instructions and copies it', async () => { - renderList() - const user = userEvent.setup() - await user.click(screen.getByRole('button', { name: 'Copy context for APP-1' })) - await user.click(screen.getByRole('menuitem', { name: 'Copy prompt from template' })) - await user.type( - screen.getByLabelText('Additional instructions (optional)'), - 'Cover SSH behavior.' - ) - const preview = screen.getByLabelText('Preview') - expect(preview).toBeInstanceOf(HTMLTextAreaElement) - if (!(preview instanceof HTMLTextAreaElement)) { - throw new Error('Expected a textarea preview') - } - expect(preview.value).toContain('Cover SSH behavior.') - await user.click(screen.getByRole('button', { name: 'Copy prompt' })) - expect(writeClipboardText).toHaveBeenCalledWith(expect.stringContaining('APP-1')) - expect(writeClipboardText).toHaveBeenCalledWith(expect.stringContaining('Cover SSH behavior.')) - expect( - screen.queryByRole('dialog', { name: 'Copy prompt from template' }) - ).not.toBeInTheDocument() - await waitFor(() => - expect(screen.getByRole('button', { name: 'Copy context for APP-1' })).toHaveFocus() - ) - }) - - it('reuses a custom template after closing and reopening the picker', async () => { - renderList() - const user = userEvent.setup() - async function openCustomTemplate() { - await user.click(screen.getByRole('button', { name: 'Copy context for APP-1' })) - await user.click(screen.getByRole('menuitem', { name: 'Copy prompt from template' })) - await user.click(screen.getByRole('combobox', { name: 'Template' })) - await user.click(screen.getByRole('option', { name: 'Custom template' })) - } - await openCustomTemplate() - await user.clear(screen.getByLabelText('Template text')) - await user.type(screen.getByLabelText('Template text'), 'Review $jira-issue') - await user.click(screen.getByRole('button', { name: 'Cancel' })) - await openCustomTemplate() - expect(screen.getByLabelText('Template text')).toHaveValue('Review $jira-issue') - await user.click(screen.getByRole('button', { name: 'Copy prompt' })) - expect(writeClipboardText).toHaveBeenCalledWith( - `Review Complete Jira issue APP-1: Fix login\n\n${issue.url}` - ) - }) - - it('keeps the template picker open when the clipboard write fails', async () => { - writeClipboardText.mockRejectedValueOnce(new Error('Clipboard unavailable')) - renderList() - const user = userEvent.setup() - await user.click(screen.getByRole('button', { name: 'Copy context for APP-1' })) - await user.click(screen.getByRole('menuitem', { name: 'Copy prompt from template' })) - await user.click(screen.getByRole('button', { name: 'Copy prompt' })) - expect(screen.getByRole('dialog', { name: 'Copy prompt from template' })).toBeInTheDocument() - }) - - it('copies the key from the row without opening the issue', async () => { - renderList() - const user = userEvent.setup() - await user.click(screen.getByRole('button', { name: 'Copy context for APP-1' })) - await user.click(screen.getByRole('menuitem', { name: 'Copy key' })) - expect(writeClipboardText).toHaveBeenCalledWith('APP-1') - expect(onOpenIssue).not.toHaveBeenCalled() - }) -}) diff --git a/src/renderer/src/components/task-page/jira/use-task-page-jira-list-state.ts b/src/renderer/src/components/task-page/jira/use-task-page-jira-list-state.ts index c41c110238e..c251c470fca 100644 --- a/src/renderer/src/components/task-page/jira/use-task-page-jira-list-state.ts +++ b/src/renderer/src/components/task-page/jira/use-task-page-jira-list-state.ts @@ -1,7 +1,7 @@ -import { useTaskPageJiraQuickFilters } from './use-task-page-jira-quick-filters' -import type { JiraStatusCatalogItem } from './task-page-jira-status-catalog' -import { useTaskPageJiraSavedFilters } from './use-task-page-jira-saved-filters' -import { useTaskPageJiraFavouriteFilters } from './use-task-page-jira-favourite-filters' +import { useTaskPageJiraQuickFilters } from './filters/use-task-page-jira-quick-filters' +import type { JiraStatusCatalogItem } from './filters/task-page-jira-status-catalog' +import { useTaskPageJiraSavedFilters } from './filters/use-task-page-jira-saved-filters' +import { useTaskPageJiraFavouriteFilters } from './filters/use-task-page-jira-favourite-filters' import type { JiraFavouriteFilter } from '../../../../../shared/jira/jira-favourite-filters' import type { JiraSavedFilter } from '../../../../../shared/jira/jira-saved-filters' import type { TaskPageLinearViewStateModel } from '../linear/use-task-page-linear-view-state' @@ -11,8 +11,8 @@ import type { JiraProjectStatusOrder, JiraPriority } from '../../../../../shared/jira/jira-types' -import type { TaskPageJiraLoadError } from '@/components/task-page/jira/task-page-jira-load-state' -import type { TaskPageJiraJqlRejection } from '@/components/task-page/jira/task-page-jira-search' +import type { TaskPageJiraLoadError } from '@/components/task-page/jira/listing/task-page-jira-load-state' +import type { TaskPageJiraJqlRejection } from '@/components/task-page/jira/listing/task-page-jira-search' import type { JiraPresetId } from '@/components/task-page/task-page-localized-options' import type { JiraIssueSortColumn, @@ -35,7 +35,7 @@ import { } from '@/runtime/rpc/targets/runtime-client-target' import { getEnvironmentRuntimeContextKey } from '@/runtime/hosts/observations/provider-runtime-context' import { getTaskSourceCacheScope } from '../../../../../shared/work-items/task-source-context' -import { useTaskPageJiraProjectScope } from './use-task-page-jira-project-scope' +import { useTaskPageJiraProjectScope } from './filters/use-task-page-jira-project-scope' const EMPTY_JIRA_PRIORITIES: JiraPrioritiesBySite = new Map() export function useTaskPageJiraListState(model: TaskPageLinearViewStateModel) { diff --git a/src/renderer/src/components/task-page/jira/use-task-page-jira-quick-filters.test.tsx b/src/renderer/src/components/task-page/jira/use-task-page-jira-quick-filters.test.tsx deleted file mode 100644 index cdcdb449843..00000000000 --- a/src/renderer/src/components/task-page/jira/use-task-page-jira-quick-filters.test.tsx +++ /dev/null @@ -1,114 +0,0 @@ -// @vitest-environment happy-dom -import { act, cleanup, renderHook, waitFor } from '@testing-library/react' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { useTaskPageJiraQuickFilters } from './use-task-page-jira-quick-filters' -import type { JiraQuickFilterState } from '../../../../../shared/jira/jira-quick-filters' -import { toRuntimeExecutionHostId } from '../../../../../shared/execution-host' -const { read, write } = vi.hoisted(() => ({ read: vi.fn(), write: vi.fn() })) -vi.mock('@/runtime/jira/runtime-jira-quick-filter-state', () => ({ - readJiraQuickFilterState: read, - writeJiraQuickFilterState: write -})) -beforeEach(() => { - vi.resetAllMocks() -}) -afterEach(cleanup) -function model( - host: string | null, - accountHost = 'account-owner' -): Parameters[0] { - return { - accountBackedTaskSourceHostId: toRuntimeExecutionHostId(accountHost), - taskSource: 'jira', - taskResumeApplied: true, - jiraTaskSourceContext: host - ? { - kind: 'task-source', - provider: 'jira', - projectId: 'project', - hostId: toRuntimeExecutionHostId(host) - } - : null - } -} -it('ignores a late mutation reply after changing Jira execution hosts', async () => { - read - .mockResolvedValueOnce({ statuses: ['Blocked'] }) - .mockResolvedValueOnce({ statuses: ['Open'] }) - let finish: (value: JiraQuickFilterState) => void = () => {} - write.mockReturnValue( - new Promise((resolve) => { - finish = resolve - }) - ) - const { result, rerender } = renderHook(({ host }) => useTaskPageJiraQuickFilters(model(host)), { - initialProps: { host: 'a' } - }) - await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false)) - let pending: Promise = Promise.resolve() - act(() => { - pending = result.current.selectJiraStatuses(['In Progress']) - }) - rerender({ host: 'b' }) - await waitFor(() => expect(result.current.selectedJiraStatuses).toEqual(['Open'])) - await act(async () => { - finish({ statuses: ['In Progress'] }) - await pending - }) - expect(result.current.selectedJiraStatuses).toEqual(['Open']) -}) -it.each(['success', 'failure'] as const)( - 'keeps a new owner visit pending after an old %s settlement', - async (outcome) => { - read.mockResolvedValue({ statuses: ['Open'] }) - let finishOld: (state: JiraQuickFilterState) => void = () => {} - let failOld: (reason: Error) => void = () => {} - let finishNew: (state: JiraQuickFilterState) => void = () => {} - write - .mockReturnValueOnce( - new Promise((resolve, reject) => { - finishOld = resolve - failOld = reject - }) - ) - .mockReturnValueOnce( - new Promise((resolve) => { - finishNew = resolve - }) - ) - const { result, rerender } = renderHook( - ({ host }) => useTaskPageJiraQuickFilters(model(host)), - { initialProps: { host: 'a' } } - ) - await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false)) - let oldWrite = Promise.resolve() - act(() => { - oldWrite = result.current.selectJiraStatuses(['Closed']).catch(() => {}) - }) - rerender({ host: 'b' }) - await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false)) - rerender({ host: 'a' }) - await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false)) - let newWrite = Promise.resolve() - act(() => { - newWrite = result.current.selectJiraStatuses(['In Progress']) - }) - await act(async () => { - if (outcome === 'success') { - finishOld({ statuses: ['Closed'] }) - } else { - failOld(new Error('Old request failed')) - } - await oldWrite - }) - expect(result.current.jiraQuickFiltersPending).toBe(true) - expect(result.current.selectedJiraStatuses).toEqual(['Open']) - expect(result.current.jiraQuickFiltersError).toBeNull() - await act(async () => { - finishNew({ statuses: ['In Progress'] }) - await newWrite - }) - expect(result.current.selectedJiraStatuses).toEqual(['In Progress']) - expect(result.current.jiraQuickFiltersPending).toBe(false) - } -) diff --git a/src/renderer/src/components/task-page/jira/use-task-page-jira-saved-filters.test.tsx b/src/renderer/src/components/task-page/jira/use-task-page-jira-saved-filters.test.tsx deleted file mode 100644 index 8853cfba223..00000000000 --- a/src/renderer/src/components/task-page/jira/use-task-page-jira-saved-filters.test.tsx +++ /dev/null @@ -1,149 +0,0 @@ -// @vitest-environment happy-dom -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { act, cleanup, renderHook, waitFor } from '@testing-library/react' -import { startTransition, Suspense } from 'react' -import { useTaskPageJiraSavedFilters } from './use-task-page-jira-saved-filters' -import { toRuntimeExecutionHostId } from '../../../../../shared/execution-host' -const { read, mutate } = vi.hoisted(() => ({ read: vi.fn(), mutate: vi.fn() })) -vi.mock('@/runtime/jira/runtime-jira-saved-filter-state', () => ({ - readJiraSavedFilterState: read, - mutateJiraSavedFilters: mutate -})) -afterEach(cleanup) -beforeEach(() => { - vi.resetAllMocks() -}) -const filter = { id: 'mine', name: 'My work', jql: 'project = APP' } -const initial = { filters: [filter], activeFilterId: filter.id } -function model( - host: string | null, - accountHost = 'account-owner' -): Parameters[0] { - return { - taskSource: 'jira', - taskResumeApplied: true, - accountBackedTaskSourceHostId: toRuntimeExecutionHostId(accountHost), - jiraTaskSourceContext: host - ? { - kind: 'task-source', - provider: 'jira', - projectId: 'project', - hostId: toRuntimeExecutionHostId(host) - } - : null - } -} -it('ignores an old host read that completes after switching execution hosts', async () => { - let resolveOld: (value: typeof initial) => void = () => {} - read - .mockImplementationOnce( - () => - new Promise((resolve) => { - resolveOld = resolve - }) - ) - .mockResolvedValueOnce({ filters: [], activeFilterId: null }) - const setQuery = vi.fn() - const { result, rerender } = renderHook( - ({ host }) => useTaskPageJiraSavedFilters(model(host), setQuery), - { initialProps: { host: 'host-a' } } - ) - rerender({ host: 'host-b' }) - await waitFor(() => expect(result.current.jiraSavedFiltersReady).toBe(true)) - await act(async () => { - resolveOld(initial) - }) - expect(result.current.jiraSavedFilters).toEqual([]) - expect(setQuery).not.toHaveBeenCalledWith(filter.jql) -}) -it.each(['success', 'failure'] as const)( - 'keeps a new visit to the owner pending after an old write settles with %s', - async (settlement) => { - read.mockResolvedValue(initial) - let settleOld = () => {} - let settleNew = () => {} - mutate - .mockImplementationOnce( - () => - new Promise((resolve, reject) => { - settleOld = () => - settlement === 'success' - ? resolve({ filters: [], activeFilterId: null }) - : reject(new Error('Old host disconnected')) - }) - ) - .mockImplementationOnce( - () => - new Promise((resolve) => { - settleNew = () => resolve(initial) - }) - ) - const setQuery = vi.fn() - const { result, rerender } = renderHook( - ({ host }) => useTaskPageJiraSavedFilters(model(host), setQuery), - { initialProps: { host: 'host-a' } } - ) - await waitFor(() => expect(result.current.jiraSavedFiltersReady).toBe(true)) - let oldWrite = Promise.resolve(false) - act(() => { - oldWrite = result.current.applyJiraSavedFilter(filter).catch(() => false) - }) - rerender({ host: 'host-b' }) - await waitFor(() => expect(result.current.jiraSavedFiltersLoading).toBe(false)) - rerender({ host: 'host-a' }) - await waitFor(() => expect(read).toHaveBeenCalledTimes(3)) - await waitFor(() => expect(result.current.jiraSavedFiltersLoading).toBe(false)) - let newWrite = Promise.resolve(false) - act(() => { - newWrite = result.current.applyJiraSavedFilter(filter) - }) - expect(result.current.jiraSavedFiltersPending).toBe(true) - setQuery.mockClear() - await act(async () => { - settleOld() - await oldWrite - }) - expect(result.current.jiraSavedFilters).toEqual([filter]) - expect(result.current.jiraSavedFiltersPending).toBe(true) - expect(result.current.jiraSavedFiltersError).toBeNull() - expect(setQuery).not.toHaveBeenCalled() - await act(async () => { - settleNew() - await newWrite - }) - expect(result.current.jiraSavedFiltersPending).toBe(false) - expect(setQuery).toHaveBeenCalledWith(filter.jql) - } -) -it('applies only the committed owner while another owner render is suspended', async () => { - read.mockResolvedValue(initial) - mutate.mockResolvedValue(initial) - const setQuery = vi.fn() - const suspended = new Promise(() => {}) - const { result, rerender } = renderHook( - ({ host, suspend }) => { - const filters = useTaskPageJiraSavedFilters(model(host), setQuery) - if (suspend) { - throw suspended - } - return filters - }, - { - initialProps: { host: 'host-a', suspend: false }, - wrapper: ({ children }) => {children} - } - ) - await waitFor(() => expect(result.current.jiraSavedFiltersReady).toBe(true)) - const committedAction = result.current.applyJiraSavedFilter - act(() => { - startTransition(() => rerender({ host: 'host-b', suspend: true })) - }) - await act(async () => { - expect(await committedAction(filter)).toBe(true) - }) - expect(mutate).toHaveBeenCalledWith( - { kind: 'environment', environmentId: 'host-a' }, - { kind: 'select', id: filter.id } - ) - expect(read).toHaveBeenCalledTimes(1) -}) diff --git a/src/renderer/src/components/task-page/linear/Content.tsx b/src/renderer/src/components/task-page/linear/Content.tsx index a50450d9d5c..f4a8b506b3c 100644 --- a/src/renderer/src/components/task-page/linear/Content.tsx +++ b/src/renderer/src/components/task-page/linear/Content.tsx @@ -4,11 +4,11 @@ import { LoaderCircle } from 'lucide-react' import { LinearIcon } from '@/components/task-page/task-page-localized-options' import { useTranslate } from '@/i18n/use-translate' import { Button } from '@/components/ui/button' -import { TaskPageLinearProjectOverview } from './ProjectOverview' -import { TaskPageLinearProjectList } from './ProjectList' -import { TaskPageLinearCustomViewList } from './CustomViewList' -import { TaskPageLinearCustomViewProjects } from './CustomViewProjects' -import { TaskPageLinearIssueList } from './IssueList' +import { TaskPageLinearProjectOverview } from './projects/ProjectOverview' +import { TaskPageLinearProjectList } from './projects/ProjectList' +import { TaskPageLinearCustomViewList } from './views/CustomViewList' +import { TaskPageLinearCustomViewProjects } from './views/CustomViewProjects' +import { TaskPageLinearIssueList } from './issues/IssueList' export function TaskPageLinearContent({ model }: { diff --git a/src/renderer/src/components/task-page/linear/IssueRows.tsx b/src/renderer/src/components/task-page/linear/IssueRows.tsx deleted file mode 100644 index ad47fc1d0c1..00000000000 --- a/src/renderer/src/components/task-page/linear/IssueRows.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions' -import { TaskPageLinearIssueBoard } from './IssueBoard' -export function TaskPageLinearIssueRows({ - model -}: { - model: TaskPageComposerActionsModel -}): React.JSX.Element | null { - return -} diff --git a/src/renderer/src/components/task-page/linear/IssueTable.test.tsx b/src/renderer/src/components/task-page/linear/IssueTable.test.tsx deleted file mode 100644 index 806f7f723fa..00000000000 --- a/src/renderer/src/components/task-page/linear/IssueTable.test.tsx +++ /dev/null @@ -1,83 +0,0 @@ -// @vitest-environment happy-dom - -import { cleanup, render, screen } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { afterEach, expect, it, vi } from 'vitest' -import type { LinearIssue } from '../../../../../shared/linear/issue-types' -import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions' -import { TooltipProvider } from '@/components/ui/tooltip' -import { TaskPageLinearIssueTable } from './IssueTable' -import type { LinearIssueListRow } from './task-page-linear-issue-model' - -vi.mock('./task-page-linear-issue-model', () => ({ LinearStateCell: () => null })) - -afterEach(cleanup) - -function issue(id: string): LinearIssue { - return { - id, - identifier: `COR-${id}`, - title: `Issue ${id}`, - url: `https://linear.app/core/issue/COR-${id}`, - state: { name: 'Todo', type: 'unstarted', color: '#000' }, - team: { id: 'core', name: 'Core', key: 'COR' }, - labels: [], - labelIds: [], - priority: 0, - updatedAt: '2026-10-01T00:00:00Z' - } -} - -const first = issue('1') -const second = issue('2') -const third = issue('3') -const rows: LinearIssueListRow[] = [ - { type: 'section', key: 'status:Todo', label: 'Todo', count: 2 }, - { type: 'issue', issue: first }, - { type: 'issue', issue: second }, - { type: 'section', key: 'status:Done', label: 'Done', count: 1 }, - { type: 'issue', issue: third } -] - -function table(linearIssueListRows = rows) { - const fixture = { - selectedLinearWorkspaceId: 'all', - linearTaskSourceContext: null, - selectedLinearIssueId: null, - openLinearDetailPage: vi.fn(), - linearIssueAttachmentIndex: new Map(), - effectiveLinearDisplayProperties: new Set(), - linearIssueGridStyle: {}, - linearIssueListRows, - handleOpenOrUseLinearItem: vi.fn() - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture supplies every model field read by this table. - const model = fixture as unknown as TaskPageComposerActionsModel - return ( - - - - ) -} - -it('tracks the group key through refreshed rows and leaves ungrouped issues visible', async () => { - const user = userEvent.setup() - const view = render(table()) - await user.click(screen.getByRole('button', { name: 'Todo 2' })) - const refreshed: LinearIssueListRow[] = [ - ...rows.slice(3), - { type: 'section', key: 'status:Todo', label: 'Todo', count: 3 }, - ...rows.slice(1, 3), - { type: 'issue', issue: issue('4') } - ] - view.rerender(table(refreshed)) - expect(screen.getByRole('button', { name: 'Todo 3', expanded: false })).toBeTruthy() - expect(screen.queryByText('Issue 4')).toBeNull() - expect(screen.getByText('Issue 3')).toBeTruthy() - - view.rerender(table([{ type: 'issue', issue: first }])) - expect(screen.getByText('Issue 1')).toBeTruthy() - view.unmount() - render(table()) - expect(screen.getByRole('button', { name: 'Todo 2', expanded: true })).toBeTruthy() -}) diff --git a/src/renderer/src/components/task-page/linear/IssueAttributes.tsx b/src/renderer/src/components/task-page/linear/creation/IssueAttributes.tsx similarity index 99% rename from src/renderer/src/components/task-page/linear/IssueAttributes.tsx rename to src/renderer/src/components/task-page/linear/creation/IssueAttributes.tsx index bcceb1610e3..e359edb4f01 100644 --- a/src/renderer/src/components/task-page/linear/IssueAttributes.tsx +++ b/src/renderer/src/components/task-page/linear/creation/IssueAttributes.tsx @@ -1,4 +1,4 @@ -import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions' +import type { TaskPageComposerActionsModel } from '../../use-task-page-composer-actions' import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover' import { useTranslate } from '@/i18n/use-translate' import { linearLabelCountLabel } from './linear-attribute-count-labels' diff --git a/src/renderer/src/components/task-page/linear/IssueDialog.tsx b/src/renderer/src/components/task-page/linear/creation/IssueDialog.tsx similarity index 98% rename from src/renderer/src/components/task-page/linear/IssueDialog.tsx rename to src/renderer/src/components/task-page/linear/creation/IssueDialog.tsx index 02da2829b72..638d9219427 100644 --- a/src/renderer/src/components/task-page/linear/IssueDialog.tsx +++ b/src/renderer/src/components/task-page/linear/creation/IssueDialog.tsx @@ -1,5 +1,5 @@ import { ImeInput } from '@/lib/text-input/ime/ime-text-field' -import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions' +import type { TaskPageComposerActionsModel } from '../../use-task-page-composer-actions' import { Dialog, DialogContent } from '@/components/ui/dialog' import { isScreenSubmitShortcut } from '@/lib/keyboard/screen-submit-shortcut' import { useTranslate } from '@/i18n/use-translate' diff --git a/src/renderer/src/components/task-page/linear/ProjectDialog.tsx b/src/renderer/src/components/task-page/linear/creation/ProjectDialog.tsx similarity index 98% rename from src/renderer/src/components/task-page/linear/ProjectDialog.tsx rename to src/renderer/src/components/task-page/linear/creation/ProjectDialog.tsx index bed683d4d8b..027429d9668 100644 --- a/src/renderer/src/components/task-page/linear/ProjectDialog.tsx +++ b/src/renderer/src/components/task-page/linear/creation/ProjectDialog.tsx @@ -1,4 +1,4 @@ -import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions' +import type { TaskPageComposerActionsModel } from '../../use-task-page-composer-actions' import { Dialog, DialogContent, diff --git a/src/renderer/src/components/task-page/linear/ProjectFields.tsx b/src/renderer/src/components/task-page/linear/creation/ProjectFields.tsx similarity index 99% rename from src/renderer/src/components/task-page/linear/ProjectFields.tsx rename to src/renderer/src/components/task-page/linear/creation/ProjectFields.tsx index 95098699104..1304a4ad433 100644 --- a/src/renderer/src/components/task-page/linear/ProjectFields.tsx +++ b/src/renderer/src/components/task-page/linear/creation/ProjectFields.tsx @@ -1,5 +1,5 @@ import { ImeInput } from '@/lib/text-input/ime/ime-text-field' -import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions' +import type { TaskPageComposerActionsModel } from '../../use-task-page-composer-actions' import { useTranslate } from '@/i18n/use-translate' import { linearLabelCountLabel, linearMemberCountLabel } from './linear-attribute-count-labels' import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover' diff --git a/src/renderer/src/components/task-page/linear/linear-attribute-count-labels.ts b/src/renderer/src/components/task-page/linear/creation/linear-attribute-count-labels.ts similarity index 100% rename from src/renderer/src/components/task-page/linear/linear-attribute-count-labels.ts rename to src/renderer/src/components/task-page/linear/creation/linear-attribute-count-labels.ts diff --git a/src/renderer/src/components/task-page/linear/use-task-page-linear-creation-state.ts b/src/renderer/src/components/task-page/linear/creation/use-task-page-linear-creation-state.ts similarity index 98% rename from src/renderer/src/components/task-page/linear/use-task-page-linear-creation-state.ts rename to src/renderer/src/components/task-page/linear/creation/use-task-page-linear-creation-state.ts index 8780dbe2cf9..24882cf576d 100644 --- a/src/renderer/src/components/task-page/linear/use-task-page-linear-creation-state.ts +++ b/src/renderer/src/components/task-page/linear/creation/use-task-page-linear-creation-state.ts @@ -1,4 +1,4 @@ -import type { TaskPageJiraListProjectionModel } from '../jira/use-task-page-jira-list-projection' +import type { TaskPageJiraListProjectionModel } from '../../jira/listing/use-task-page-jira-list-projection' import { useState, useMemo, useEffect, useReducer, type SetStateAction } from 'react' import { useTeamMembers, useTeamLabels, useTeamStates } from '@/hooks/useIssueMetadata' import { @@ -10,9 +10,9 @@ import { completeLinearIssueForm, handoffLinearIssueForm as handoff } from '@/components/use-task-creation-draft-retention' -import { useNewLinearIssueProjects } from '../../use-new-linear-issue-projects' +import { useNewLinearIssueProjects } from '../../../use-new-linear-issue-projects' import { useContextualTour } from '@/components/contextual-tours/use-contextual-tour' -import { writeNewLinearIssueDraft, writeNewLinearProjectDraft } from '../task-page-draft-storage' +import { writeNewLinearIssueDraft, writeNewLinearProjectDraft } from '../../task-page-draft-storage' import { defaultScopeSource } from '@/runtime/hosts/selection/default-creation-host' type FormAction = (value: LinearIssueForm, target: string) => LinearIssueForm export function useLinearIssueCreationForm( diff --git a/src/renderer/src/components/task-page/linear/use-task-page-linear-issue-creation.ts b/src/renderer/src/components/task-page/linear/creation/use-task-page-linear-issue-creation.ts similarity index 99% rename from src/renderer/src/components/task-page/linear/use-task-page-linear-issue-creation.ts rename to src/renderer/src/components/task-page/linear/creation/use-task-page-linear-issue-creation.ts index b803039c4fd..296540264b8 100644 --- a/src/renderer/src/components/task-page/linear/use-task-page-linear-issue-creation.ts +++ b/src/renderer/src/components/task-page/linear/creation/use-task-page-linear-issue-creation.ts @@ -6,7 +6,7 @@ import { useCallback, useLayoutEffect, useMemo, useRef } from 'react' import { toast } from 'sonner' import { translate } from '@/i18n/i18n' import { linearCreateIssue } from '@/runtime/linear/runtime-linear-issue-client' -import { useAppStore } from '@/store' +import { useAppStore } from '@/store/index' import { defaultScopeSource } from '@/runtime/hosts/selection/default-creation-host' import { openAppHttpLink } from '@/lib/browser/navigation/app-http-link-open' export function useTaskPageLinearIssueCreation(model: TaskPageLinearProjectCreationModel) { diff --git a/src/renderer/src/components/task-page/linear/use-task-page-linear-project-creation.ts b/src/renderer/src/components/task-page/linear/creation/use-task-page-linear-project-creation.ts similarity index 97% rename from src/renderer/src/components/task-page/linear/use-task-page-linear-project-creation.ts rename to src/renderer/src/components/task-page/linear/creation/use-task-page-linear-project-creation.ts index d3fe6b4509b..e988d5f0395 100644 --- a/src/renderer/src/components/task-page/linear/use-task-page-linear-project-creation.ts +++ b/src/renderer/src/components/task-page/linear/creation/use-task-page-linear-project-creation.ts @@ -2,7 +2,7 @@ import { taskProviderErrorMessage, taskProviderErrorText } from '@/lib/work-items/task-provider-error-copy' -import type { TaskPageGitHubIssueCreationModel } from '../github/issue-creation/use-task-page-github-issue-creation' +import type { TaskPageGitHubIssueCreationModel } from '../../github/issue-creation/use-task-page-github-issue-creation' import { useCallback } from 'react' import { linearCreateProject } from '@/runtime/linear/runtime-linear-project-client' import { toast } from 'sonner' diff --git a/src/renderer/src/components/task-page/linear/issue-refresh/task-page-linear-refresh-lifecycle.test.tsx b/src/renderer/src/components/task-page/linear/issue-refresh/task-page-linear-refresh-lifecycle.test.tsx new file mode 100644 index 00000000000..a93870f5df4 --- /dev/null +++ b/src/renderer/src/components/task-page/linear/issue-refresh/task-page-linear-refresh-lifecycle.test.tsx @@ -0,0 +1,58 @@ +// @vitest-environment happy-dom +// @vitest-environment happy-dom +import { reads, task, store } from './task-page-linear-refresh-test-fixtures' +import '@/store/slices/linear/linear-issue-read' + +import { writeLinearIssue } from '@/lib/work-items/linear/linear-issue-write' + +import { defaultScopeSource } from '@/runtime/hosts/selection/default-creation-host' +import { getLinearSourceScope } from '@/store/slices/linear/linear-slice-scope' + +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { act } from '@testing-library/react' +import { deferred } from '../../../../store/slices/linear/linear-slice-test-harness' + +const cacheTime = Date.parse('2026-01-01T00:00:00Z') +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(cacheTime) +}) +afterEach(() => vi.useRealTimers()) + +it('captured default runtime cannot seed or patch the focused foreign runtime cache', async () => { + const s = store() + const pending = deferred<{ ok: boolean }>() + const saving = writeLinearIssue( + task(), + { state: task('Done').state }, + defaultScopeSource(null), + () => pending.promise + ) + // SAFETY: only activeRuntimeEnvironmentId is read by this captured-scope fixture. + s.setState({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this fixture supplies the real consumer's exercised fields and actions; unrelated full-page/provider services are intentionally absent. + settings: { activeRuntimeEnvironmentId: 'remote' } as never, + linearIssueCache: { 'w::same-uuid': { data: task('Remote'), fetchedAt: cacheTime } } + }) + const remote = s.getState().fetchLinearIssue(task().id, 'w').acquire() + expect(remote.currentView().data?.state.name).toBe('Remote') + reads.detail.mockResolvedValue(task('Local observation')) + const lease = s + .getState() + .fetchLinearIssue(task().id, 'w', { + scope: getLinearSourceScope({ kind: 'local' }), + force: true + }) + .acquire() + expect(lease.currentView().data).toBeNull() + expect((await lease.waitForCurrentTerminal()).data?.state.name).toBe('Done') + await act(async () => { + pending.resolve({ ok: false }) + await saving + }) + expect(s.getState().linearIssueCache['w::same-uuid'].data?.state.name).toBe('Remote') + expect(remote.currentView().kind).toBe('data') + expect(remote.currentView().data?.state.name).toBe('Remote') + remote.release() + lease.release() +}) diff --git a/src/renderer/src/components/task-page/linear/issue-refresh/task-page-linear-refresh-test-fixtures.tsx b/src/renderer/src/components/task-page/linear/issue-refresh/task-page-linear-refresh-test-fixtures.tsx new file mode 100644 index 00000000000..10f3d9ce9ea --- /dev/null +++ b/src/renderer/src/components/task-page/linear/issue-refresh/task-page-linear-refresh-test-fixtures.tsx @@ -0,0 +1,210 @@ +import type * as ReactModule from 'react' +import type { LinearEditState } from '../../../linear-item-drawer-types' + +import { getLinearSourceScope } from '@/store/slices/linear/linear-slice-scope' +import { defaultScopeSource } from '@/runtime/hosts/selection/default-creation-host' +import { LinearIssueTextEditor } from '../../../LinearIssueTextEditor' +import { useLinearIssueEditController } from '../../../linear-item-drawer-edit-controller' +import type { LinearIssue } from '../../../../../../shared/linear/issue-types' +import { useStore } from 'zustand' + +import { afterEach, beforeEach, vi, type Mock } from 'vitest' +import { cleanup } from '@testing-library/react' +import { createTestStore, issue } from '../../../../store/slices/linear/linear-slice-test-harness' +import { clearLinearRequestMaps } from '../../../../store/slices/linear/linear-slice-request-state' + +const deferredIssueSetters = vi.hoisted(() => { + const updates: VoidFunction[] = [] + return { enabled: false, updates } +}) +vi.mock('react', async (original) => { + const actual = await original() + return { + ...actual, + useReducer(reducer: (previous: S, action: A) => S, initial: S) { + const [value, dispatch] = actual.useReducer(reducer, initial) + const send = actual.useCallback((action: A) => { + if ( + deferredIssueSetters.enabled && + typeof initial === 'object' && + initial !== null && + 'whole' in initial + ) { + deferredIssueSetters.updates.push(() => dispatch(action)) + } else { + dispatch(action) + } + }, []) + return [value, send] as const + } + } +}) +export { deferredIssueSetters } + +const active = vi.hoisted(() => ({ store: vi.fn<() => Store>() })) + +vi.mock('@/store/index', () => ({ + useAppStore: Object.assign( + (selector: (state: ReturnType) => unknown) => + useStore(active.store(), selector), + { + getState: () => active.store().getState(), + subscribe: (...args: Parameters) => active.store().subscribe(...args) + } + ) +})) + +vi.mock('@/i18n/i18n', async (original) => ({ + ...(await original>()), + translate: (_key: string, fallback: string, values?: Record) => + fallback.replace(/{{(\w+)}}/g, (match, key) => values?.[key] ?? match) +})) + +const reads: { + list: Mock + search: Mock + project: Mock + view: Mock + detail: Mock + update: Mock + create: Mock + projects: Mock + connect: Mock + test: Mock + select: Mock +} = vi.hoisted(() => ({ + list: vi.fn(), + search: vi.fn(), + project: vi.fn(), + view: vi.fn(), + detail: vi.fn(), + update: vi.fn(), + create: vi.fn(), + projects: vi.fn(), + connect: vi.fn(), + test: vi.fn(), + select: vi.fn() +})) + +const accountViewer = vi.hoisted(() => ({ + displayName: 'User', + email: null, + organizationName: 'Acme' +})) + +vi.mock('@/runtime/linear/runtime-linear-client', async (original) => ({ + ...(await original>()), + linearTestConnection: (...args: unknown[]) => reads.test(...args), + linearSelectWorkspace: (...args: unknown[]) => reads.select(...args), + linearDisconnect: async () => {}, + linearConnect: (...args: unknown[]) => reads.connect(...args), + linearStatus: async () => ({ connected: true, viewer: accountViewer, selectedWorkspaceId: 'w' }), + linearListIssues: (...args: unknown[]) => reads.list(...args), + linearSearchIssues: (...args: unknown[]) => reads.search(...args) +})) + +vi.mock('@/runtime/linear/runtime-linear-project-client', async (original) => ({ + ...(await original>()), + linearListProjects: reads.projects, + linearTeamStates: async () => [{ id: 'done', name: 'Done', type: 'completed', color: '#888888' }], + linearListProjectIssues: (...args: unknown[]) => reads.project(...args), + linearListCustomViewIssues: (...args: unknown[]) => reads.view(...args) +})) + +vi.mock('@/runtime/linear/runtime-linear-issue-client', async (original) => ({ + ...(await original>()), + linearGetIssue: reads.detail, + linearIssueComments: async () => [], + linearUpdateIssue: reads.update, + linearCreateIssue: reads.create +})) + +vi.mock('@/hooks/useIssueMetadata', async (original) => ({ + ...(await original>()), + useTeamLabels: () => ({ data: [{ id: 'bug', name: 'Bug' }], loading: false }), + useTeamMembers: () => ({ data: [], loading: false }), + clearLinearMetadataCache: vi.fn(), + useTeamStates: () => ({ + data: [{ id: 'done', name: 'Done', type: 'completed', color: '#888888' }], + loading: false + }) +})) + +vi.mock('@/store/slices/worktrees/listing/use-worktree-catalog', async (original) => { + const empty: [] = [] + return { ...(await original>()), useAllWorktrees: () => empty } +}) + +vi.mock('@/store/workspaces/catalog/workspace-record-fields', async (original) => { + const empty: [] = [] + return { ...(await original>()), selectFolderWorkspaces: () => empty } +}) + +vi.mock('@/components/LinearIssueMarkdownDescriptionEditor', () => ({ + LinearIssueMarkdownDescriptionEditor: () => null +})) + +vi.mock('@/components/linear-item-drawer-sheet', () => ({ + renderLinearItemDrawerSheet: (props: { + displayed: LinearIssue | null + editState: LinearEditState | null + }) => + props.displayed && props.editState ? ( + + ) : ( +
+ ) +})) + +export function DrawerFields({ row, editState }: { row: LinearIssue; editState: LinearEditState }) { + const controller = useLinearIssueEditController({ issue: row, editState }) + return ( + <> + + {row.state.name}:{editState.priority}:{editState.labels.join(',')} + + + + + + ) +} + +export function task(state = 'In Review') { + return { + ...issue('same-uuid'), + identifier: 'DAT-124', + workspaceId: 'w', + state: { name: state, type: state === 'Done' ? 'completed' : 'started', color: '#888888' } + } +} + +export function store() { + const result = createTestStore() + result.setState({ + linearStatus: { connected: true, viewer: accountViewer, selectedWorkspaceId: 'w' }, + linearStatusObserved: true, + linearStatusChecked: true, + linearStatusContextKey: getLinearSourceScope(defaultScopeSource(null)).runtimeContextKey + }) + active.store.mockReturnValue(result) + return result +} + +export type Store = ReturnType + +beforeEach(() => { + deferredIssueSetters.enabled = false + deferredIssueSetters.updates.length = 0 + vi.resetAllMocks() + reads.connect.mockResolvedValue({ ok: true, viewer: accountViewer }) + reads.test.mockResolvedValue({ ok: true, viewer: accountViewer }) + clearLinearRequestMaps() +}) + +afterEach(() => { + cleanup() + clearLinearRequestMaps() +}) + +export { reads } diff --git a/src/renderer/src/components/task-page/linear/IssueBoard.tsx b/src/renderer/src/components/task-page/linear/issues/IssueBoard.tsx similarity index 96% rename from src/renderer/src/components/task-page/linear/IssueBoard.tsx rename to src/renderer/src/components/task-page/linear/issues/IssueBoard.tsx index fd58793e71e..00ec7c55584 100644 --- a/src/renderer/src/components/task-page/linear/IssueBoard.tsx +++ b/src/renderer/src/components/task-page/linear/issues/IssueBoard.tsx @@ -1,4 +1,4 @@ -import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions' +import type { TaskPageComposerActionsModel } from '../../use-task-page-composer-actions' import { useTranslate } from '@/i18n/use-translate' import { resolveLinearIssueEmptyKind, @@ -6,12 +6,12 @@ import { } from './task-page-linear-issue-empty-state' import { Button } from '@/components/ui/button' import { LinearIcon } from '@/components/task-page/task-page-localized-options' -import { isIntegrationCredentialDecryptionError } from '../../../../../shared/integration-credential-errors' +import { isIntegrationCredentialDecryptionError } from '../../../../../../shared/integration-credential-errors' import { clampLinearIssueListLimit, LINEAR_ISSUE_LIST_MAX -} from '../../../../../shared/linear/issue-read-limits' -import { LINEAR_ITEM_LIMIT } from '../task-page-source-context' +} from '../../../../../../shared/linear/issue-read-limits' +import { LINEAR_ITEM_LIMIT } from '../../task-page-source-context' import { TaskPageLinearIssueBoardColumns } from './IssueBoardColumns' import { TaskPageLinearIssueTable } from './IssueTable' export function TaskPageLinearIssueBoard({ diff --git a/src/renderer/src/components/task-page/linear/IssueBoardColumns.tsx b/src/renderer/src/components/task-page/linear/issues/IssueBoardColumns.tsx similarity index 98% rename from src/renderer/src/components/task-page/linear/IssueBoardColumns.tsx rename to src/renderer/src/components/task-page/linear/issues/IssueBoardColumns.tsx index bed498c9813..981dfd2759f 100644 --- a/src/renderer/src/components/task-page/linear/IssueBoardColumns.tsx +++ b/src/renderer/src/components/task-page/linear/issues/IssueBoardColumns.tsx @@ -1,4 +1,4 @@ -import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions' +import type { TaskPageComposerActionsModel } from '../../use-task-page-composer-actions' import { cn } from '@/lib/class-names' import { findLinearIssueWorkspaceAttachmentInIndex } from '@/lib/work-items/linear/linear-issue-workspace-attachment' import { getWorktreeAttachmentLabel } from '@/lib/workspace/display/worktree-attachment-label' @@ -7,8 +7,8 @@ import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip import { Button } from '@/components/ui/button' import { useTranslate } from '@/i18n/use-translate' import { FolderOpen, ArrowRight, ExternalLink } from 'lucide-react' -import { LinearStateCell } from './task-page-linear-issue-model' -import { formatRelativeTime } from '../task-page-source-context' +import { LinearStateCell } from './task-page-linear-state-cell' +import { formatRelativeTime } from '../../task-page-source-context' import { openAppHttpLink } from '@/lib/browser/navigation/app-http-link-open' export function TaskPageLinearIssueBoardColumns({ model diff --git a/src/renderer/src/components/task-page/linear/IssueList.tsx b/src/renderer/src/components/task-page/linear/issues/IssueList.tsx similarity index 95% rename from src/renderer/src/components/task-page/linear/IssueList.tsx rename to src/renderer/src/components/task-page/linear/issues/IssueList.tsx index 7a06ebc962c..0da6ca97b20 100644 --- a/src/renderer/src/components/task-page/linear/IssueList.tsx +++ b/src/renderer/src/components/task-page/linear/issues/IssueList.tsx @@ -1,9 +1,9 @@ -import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions' +import type { TaskPageComposerActionsModel } from '../../use-task-page-composer-actions' import { TaskPageLinearIssueToolbar } from './IssueToolbar' import { useTranslate } from '@/i18n/use-translate' -import { TaskPageLinearIssueRows } from './IssueRows' +import { TaskPageLinearIssueBoard } from './IssueBoard' import { LinearCollectionNotice } from '@/components/linear-project-view-surfaces' -import { PaginationBar } from '../PaginationBar' +import { PaginationBar } from '../../PaginationBar' export function TaskPageLinearIssueList({ model }: { @@ -62,7 +62,7 @@ export function TaskPageLinearIssueList({
) : null} - + {selectedLinearProject && linearProjectTab === 'issues' ? ( <> compareLinearIssues(a, b, orderBy)) + if (groupBy === 'none') { + return [ + { + key: 'all', + label: t('auto.components.TaskPage.dfc0c79bd8', 'Issues'), + issues: sorted + } + ] + } + const sections = new Map() + for (const issue of sorted) { + const group = getLinearIssueGroup(issue, groupBy, t) + const section = sections.get(group.key) + if (section) { + section.issues.push(issue) + } else { + sections.set(group.key, { + key: group.key, + label: group.label, + issues: [issue] + }) + } + } + return [...sections.values()] +} diff --git a/src/renderer/src/components/task-page/linear/task-page-linear-issue-request.ts b/src/renderer/src/components/task-page/linear/issues/task-page-linear-issue-request.ts similarity index 92% rename from src/renderer/src/components/task-page/linear/task-page-linear-issue-request.ts rename to src/renderer/src/components/task-page/linear/issues/task-page-linear-issue-request.ts index e94284bb17b..beccb32df3d 100644 --- a/src/renderer/src/components/task-page/linear/task-page-linear-issue-request.ts +++ b/src/renderer/src/components/task-page/linear/issues/task-page-linear-issue-request.ts @@ -2,12 +2,12 @@ import { isEmptyLinearIssueAttributeFilter, linearIssueAttributeFilterSignature, type LinearIssueAttributeFilter -} from '../../../../../shared/linear/issue-attribute-filter' +} from '../../../../../../shared/linear/issue-attribute-filter' import { getTaskSourceCacheScope, type TaskSourceContext -} from '../../../../../shared/work-items/task-source-context' -import type { LinearIssueListReadArgs } from '../../../store/slices/linear/linear-slice-contract' +} from '../../../../../../shared/work-items/task-source-context' +import type { LinearIssueListReadArgs } from '../../../../store/slices/linear/linear-slice-contract' export function isLinearIssueSearchActive(immediateQuery: string, appliedQuery: string): boolean { return immediateQuery.trim().length > 0 || appliedQuery.trim().length > 0 diff --git a/src/renderer/src/components/task-page/linear/task-page-linear-issue-model.tsx b/src/renderer/src/components/task-page/linear/issues/task-page-linear-state-cell.tsx similarity index 69% rename from src/renderer/src/components/task-page/linear/task-page-linear-issue-model.tsx rename to src/renderer/src/components/task-page/linear/issues/task-page-linear-state-cell.tsx index b309a04b8b6..a1a05c7e9a7 100644 --- a/src/renderer/src/components/task-page/linear/task-page-linear-issue-model.tsx +++ b/src/renderer/src/components/task-page/linear/issues/task-page-linear-state-cell.tsx @@ -1,84 +1,24 @@ -import { getLinearSourceScope } from '@/store/slices/linear/linear-slice-scope' -import { writeLinearIssue } from '@/lib/work-items/linear/linear-issue-write' -import { taskProviderErrorMessage } from '@/lib/work-items/task-provider-error-copy' -import type { LinearIssue } from '../../../../../shared/linear/issue-types' -import type { LinearCustomViewModel } from '../../../../../shared/linear/project-types' -import type { - LinearCollectionResult, - LinearWorkflowState -} from '../../../../../shared/linear/workspace-types' -import type { TaskSourceContext } from '../../../../../shared/work-items/task-source-context' +import { getLinearSourceScope } from '../../../../store/slices/linear/linear-slice-scope' +import { writeLinearIssue } from '../../../../lib/work-items/linear/linear-issue-write' +import { taskProviderErrorMessage } from '../../../../lib/work-items/task-provider-error-copy' +import type { LinearIssue } from '../../../../../../shared/linear/issue-types' +import type { TaskSourceContext } from '../../../../../../shared/work-items/task-source-context' import React, { useState, useRef, useCallback } from 'react' -import { useAppStore } from '@/store/index' -import { useTeamStates } from '@/hooks/useIssueMetadata' -import { linearUpdateIssue } from '@/runtime/linear/runtime-linear-issue-client' +import { useAppStore } from '../../../../store/index' +import { useTeamStates } from '../../../../hooks/useIssueMetadata' +import { linearUpdateIssue } from '../../../../runtime/linear/runtime-linear-issue-client' import { toast } from 'sonner' -import { translate } from '@/i18n/i18n' -import { useTranslate } from '@/i18n/use-translate' -import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover' -import { cn } from '@/lib/class-names' +import { translate } from '../../../../i18n/i18n' +import { useTranslate } from '../../../../i18n/use-translate' +import { Popover, PopoverTrigger, PopoverContent } from '../../../ui/popover' +import { cn } from '../../../../lib/class-names' import { getLinearStatePillStyle, getLinearStateMarkerStyle -} from '@/components/linear-state-pill-style' +} from '../../../linear-state-pill-style' import { LoaderCircle, ChevronDown } from 'lucide-react' -import { defaultScopeSource } from '@/runtime/hosts/selection/default-creation-host' -export type LinearProjectTab = 'overview' | 'issues' -export type LinearGroupSection = { - key: string - label: string - issues: LinearIssue[] -} -export type LinearIssueListRow = - | { - type: 'section' - key: string - label: string - count: number - } - | { - type: 'issue' - issue: LinearIssue - } -export const LINEAR_CUSTOM_VIEW_MODELS = [ - 'issue', - 'project' -] satisfies readonly LinearCustomViewModel[] -export function mergeLinearCollectionResults( - results: LinearCollectionResult[] -): LinearCollectionResult { - const errors = results.flatMap((result) => result.errors ?? []) - return { - items: results.flatMap((result) => result.items), - ...(errors.length > 0 - ? { - errors - } - : {}), - ...(results.some((result) => result.hasMore) - ? { - hasMore: true - } - : {}) - } -} -export function getLinearStatusSectionState( - section: LinearGroupSection -): LinearIssue['state'] | null { - if (!section.key.startsWith('status:')) { - return null - } - return section.issues[0]?.state ?? null -} -export function findLinearWorkflowStateForStatus( - states: LinearWorkflowState[], - targetState: LinearIssue['state'] -): LinearWorkflowState | undefined { - return ( - states.find((state) => state.name === targetState.name && state.type === targetState.type) ?? - states.find((state) => state.name === targetState.name) - ) -} +import { defaultScopeSource } from '../../../../runtime/hosts/selection/default-creation-host' + export function LinearStateCell({ issue, className, diff --git a/src/renderer/src/components/task-page/linear/task-page-linear-team-selection.ts b/src/renderer/src/components/task-page/linear/issues/task-page-linear-team-selection.ts similarity index 91% rename from src/renderer/src/components/task-page/linear/task-page-linear-team-selection.ts rename to src/renderer/src/components/task-page/linear/issues/task-page-linear-team-selection.ts index 27c4b96cc68..93bf864faa4 100644 --- a/src/renderer/src/components/task-page/linear/task-page-linear-team-selection.ts +++ b/src/renderer/src/components/task-page/linear/issues/task-page-linear-team-selection.ts @@ -1,4 +1,4 @@ -import type { LinearTeam } from '../../../../../shared/linear/workspace-types' +import type { LinearTeam } from '../../../../../../shared/linear/workspace-types' // Treat malformed persisted selections as sticky-all; unvalidated strings previously crashed array filtering. export function storedLinearTeamSelection(value: unknown): string[] | null { diff --git a/src/renderer/src/components/task-page/linear/use-task-page-linear-board.ts b/src/renderer/src/components/task-page/linear/issues/use-task-page-linear-board.ts similarity index 88% rename from src/renderer/src/components/task-page/linear/use-task-page-linear-board.ts rename to src/renderer/src/components/task-page/linear/issues/use-task-page-linear-board.ts index a5abaaf64be..9eeae5753ff 100644 --- a/src/renderer/src/components/task-page/linear/use-task-page-linear-board.ts +++ b/src/renderer/src/components/task-page/linear/issues/use-task-page-linear-board.ts @@ -3,7 +3,7 @@ import { writeLinearIssue } from '@/lib/work-items/linear/linear-issue-write' import { taskProviderErrorMessage } from '@/lib/work-items/task-provider-error-copy' import type { TaskPageLinearListProjectionModel } from './use-task-page-linear-list-projection' import React, { useMemo, useCallback } from 'react' -import type { LinearIssue } from '../../../../../shared/linear/issue-types' +import type { LinearIssue } from '../../../../../../shared/linear/issue-types' import { writeLinearBoardIssueDragData, readLinearBoardIssueDragData @@ -12,15 +12,14 @@ import { linearTeamStates } from '@/runtime/linear/runtime-linear-project-client import { linearUpdateIssue } from '@/runtime/linear/runtime-linear-issue-client' import { toast } from 'sonner' import { translate } from '@/i18n/i18n' -import { useAppStore } from '@/store' -import type { LinearDisplayProperty } from '../../../../../shared/linear/issue-view-resume-state' -import { groupLinearIssues } from './task-page-linear-jira-list-model' -import type { LinearGroupSection } from './task-page-linear-issue-model' -import { - findLinearWorkflowStateForStatus, - getLinearStatusSectionState -} from './task-page-linear-issue-model' +import { useAppStore } from '@/store/index' +import type { LinearDisplayProperty } from '../../../../../../shared/linear/issue-view-resume-state' +import { groupLinearIssues } from './task-page-linear-issue-grouping' +import type { LinearGroupSection } from './task-page-linear-issue-grouping' + import { defaultScopeSource } from '@/runtime/hosts/selection/default-creation-host' +import type { LinearWorkflowState } from '../../../../../../shared/linear/workspace-types' + export function useTaskPageLinearBoard(model: TaskPageLinearListProjectionModel) { const { settings, @@ -209,3 +208,22 @@ export function useTaskPageLinearBoard(model: TaskPageLinearListProjectionModel) return nextModel } export type TaskPageLinearBoardModel = ReturnType + +export function getLinearStatusSectionState( + section: LinearGroupSection +): LinearIssue['state'] | null { + if (!section.key.startsWith('status:')) { + return null + } + return section.issues[0]?.state ?? null +} + +export function findLinearWorkflowStateForStatus( + states: LinearWorkflowState[], + targetState: LinearIssue['state'] +): LinearWorkflowState | undefined { + return ( + states.find((state) => state.name === targetState.name && state.type === targetState.type) ?? + states.find((state) => state.name === targetState.name) + ) +} diff --git a/src/renderer/src/components/task-page/linear/use-task-page-linear-filter-selection.ts b/src/renderer/src/components/task-page/linear/issues/use-task-page-linear-filter-selection.ts similarity index 92% rename from src/renderer/src/components/task-page/linear/use-task-page-linear-filter-selection.ts rename to src/renderer/src/components/task-page/linear/issues/use-task-page-linear-filter-selection.ts index c2a925b3d20..7f470d59440 100644 --- a/src/renderer/src/components/task-page/linear/use-task-page-linear-filter-selection.ts +++ b/src/renderer/src/components/task-page/linear/issues/use-task-page-linear-filter-selection.ts @@ -4,15 +4,15 @@ import { resolveLinearIssueAttributeFilterPrimaryTeam } from '@/components/linea import { type LinearIssueAttributeFilter, linearIssueAttributeFilterSignature -} from '../../../../../shared/linear/issue-attribute-filter' -import { setLinearWorkspaceIssueFilter } from '../../../../../shared/linear/issue-view-resume-state' -import { LINEAR_ITEM_LIMIT } from '../task-page-source-context' +} from '../../../../../../shared/linear/issue-attribute-filter' +import { setLinearWorkspaceIssueFilter } from '../../../../../../shared/linear/issue-view-resume-state' +import { LINEAR_ITEM_LIMIT } from '../../task-page-source-context' import { type LinearPrimaryTeamObservation, shouldClearTeamDerivedFacets, teamDerivedFacetsForPrimaryTeamChange, isLinearIssueSearchActive -} from '@/components/task-page/linear/task-page-linear-issue-request' +} from '@/components/task-page/linear/issues/task-page-linear-issue-request' export function useTaskPageLinearFilterSelection(model: TaskPageLinearListSelectionPreludeModel) { const { linearMode, diff --git a/src/renderer/src/components/task-page/linear/use-task-page-linear-in-orca-effects.ts b/src/renderer/src/components/task-page/linear/issues/use-task-page-linear-in-orca-effects.ts similarity index 96% rename from src/renderer/src/components/task-page/linear/use-task-page-linear-in-orca-effects.ts rename to src/renderer/src/components/task-page/linear/issues/use-task-page-linear-in-orca-effects.ts index b2d6c6543cd..70cfdc4c0d2 100644 --- a/src/renderer/src/components/task-page/linear/use-task-page-linear-in-orca-effects.ts +++ b/src/renderer/src/components/task-page/linear/issues/use-task-page-linear-in-orca-effects.ts @@ -4,8 +4,8 @@ import { useEffect } from 'react' import { readLinkedLinearIssuesWithLimit, filterLinearIssuesForInOrcaWorkspace -} from '@/components/task-page/linear/task-page-linear-in-orca-issues' -import type { LinearIssue } from '../../../../../shared/linear/issue-types' +} from '@/components/task-page/linear/issues/task-page-linear-in-orca-issues' +import type { LinearIssue } from '../../../../../../shared/linear/issue-types' import { translate } from '@/i18n/i18n' function isCurrentLinearRequest( diff --git a/src/renderer/src/components/task-page/linear/use-task-page-linear-list-effects.ts b/src/renderer/src/components/task-page/linear/issues/use-task-page-linear-list-effects.ts similarity index 92% rename from src/renderer/src/components/task-page/linear/use-task-page-linear-list-effects.ts rename to src/renderer/src/components/task-page/linear/issues/use-task-page-linear-list-effects.ts index f7365e19ce4..d83f0060065 100644 --- a/src/renderer/src/components/task-page/linear/use-task-page-linear-list-effects.ts +++ b/src/renderer/src/components/task-page/linear/issues/use-task-page-linear-list-effects.ts @@ -2,29 +2,29 @@ import { observeLinearIssueRead, type LinearIssueRead } from '@/store/slices/linear/linear-issue-read-lifecycle' -import type { LinearIssue } from '../../../../../shared/linear/issue-types' -import type { LinearCollectionResult } from '../../../../../shared/linear/workspace-types' +import type { LinearIssue } from '../../../../../../shared/linear/issue-types' +import type { LinearCollectionResult } from '../../../../../../shared/linear/workspace-types' import { taskProviderErrorText } from '@/lib/work-items/task-provider-error-copy' -import type { TaskPageGlobalEffectsModel } from '../use-task-page-global-effects' +import type { TaskPageGlobalEffectsModel } from '../../use-task-page-global-effects' import { useEffect } from 'react' -import { isIntegrationCredentialDecryptionError } from '../../../../../shared/integration-credential-errors' -import { saveLinearIssueView } from '../../linear-issue-view-storage' -import { serializeLinearIssueViewResumeState } from '../../../../../shared/linear/issue-view-resume-state' -import { clampLinearIssueListLimit } from '../../../../../shared/linear/issue-read-limits' +import { isIntegrationCredentialDecryptionError } from '../../../../../../shared/integration-credential-errors' +import { saveLinearIssueView } from '../../../linear-issue-view-storage' +import { serializeLinearIssueViewResumeState } from '../../../../../../shared/linear/issue-view-resume-state' +import { clampLinearIssueListLimit } from '../../../../../../shared/linear/issue-read-limits' import { buildLinearIssueListReadArgs, type LinearIssueListFilterRead, shouldForceLinearIssueListRead, buildLinearIssueListRequestSignature -} from '@/components/task-page/linear/task-page-linear-issue-request' -import { linearIssueAttributeFilterSignature } from '../../../../../shared/linear/issue-attribute-filter' +} from '@/components/task-page/linear/issues/task-page-linear-issue-request' +import { linearIssueAttributeFilterSignature } from '../../../../../../shared/linear/issue-attribute-filter' import { linearIssueReadError } from '@/lib/work-items/linear/linear-read-error' import { taskPageLinearIssuesHaveMore, reconcileTaskPageLinearIssuesAfterLandingRefresh } from './task-page-linear-cache-selectors' -import { LINEAR_ITEM_LIMIT } from '../task-page-source-context' -import { useTaskSearchDebouncedApply } from '../use-task-search-debounced-apply' +import { LINEAR_ITEM_LIMIT } from '../../task-page-source-context' +import { useTaskSearchDebouncedApply } from '../../use-task-search-debounced-apply' export function useTaskPageLinearListEffects(model: TaskPageGlobalEffectsModel) { const { setTaskResumeState, diff --git a/src/renderer/src/components/task-page/linear/use-task-page-linear-list-presentation.ts b/src/renderer/src/components/task-page/linear/issues/use-task-page-linear-list-presentation.ts similarity index 76% rename from src/renderer/src/components/task-page/linear/use-task-page-linear-list-presentation.ts rename to src/renderer/src/components/task-page/linear/issues/use-task-page-linear-list-presentation.ts index 3dc1fa5aaef..2661f3f355c 100644 --- a/src/renderer/src/components/task-page/linear/use-task-page-linear-list-presentation.ts +++ b/src/renderer/src/components/task-page/linear/issues/use-task-page-linear-list-presentation.ts @@ -1,8 +1,10 @@ import React, { useMemo } from 'react' -import { getLinearIssueGridTemplate, groupLinearIssues } from './task-page-linear-jira-list-model' -import type { LinearIssueListRow } from './task-page-linear-issue-model' +import { groupLinearIssues } from './task-page-linear-issue-grouping' + import type { TaskPageLinearListProjectionPreludeModel } from './use-task-page-linear-list-projection' +import type { LinearIssue } from '../../../../../../shared/linear/issue-types' +import type { LinearDisplayProperty } from '../../../../../../shared/linear/issue-view-resume-state' export function useTaskPageLinearListPresentation(model: TaskPageLinearListProjectionPreludeModel) { const { @@ -98,3 +100,39 @@ export function useTaskPageLinearListPresentation(model: TaskPageLinearListProje export type TaskPageLinearListPresentationModel = ReturnType< typeof useTaskPageLinearListPresentation > + +export type LinearIssueListRow = + | { + type: 'section' + key: string + label: string + count: number + } + | { + type: 'issue' + issue: LinearIssue + } + +export function getLinearIssueGridTemplate( + visibleProperties: ReadonlySet +): string { + const columns = ['96px', 'minmax(240px,1.55fr)'] + if (visibleProperties.has('labels')) { + columns.push('minmax(168px,0.9fr)') + } + if (visibleProperties.has('team')) { + columns.push('minmax(172px,0.9fr)') + } + if (visibleProperties.has('state')) { + columns.push('138px') + } + if (visibleProperties.has('assignee')) { + columns.push('64px') + } + if (visibleProperties.has('updated')) { + columns.push('104px') + } + // Why: Worktrees is icon-only (open vs start); keep it narrow so issue title keeps the room. + columns.push('64px') + return columns.join(' ') +} diff --git a/src/renderer/src/components/task-page/linear/use-task-page-linear-list-projection.ts b/src/renderer/src/components/task-page/linear/issues/use-task-page-linear-list-projection.ts similarity index 97% rename from src/renderer/src/components/task-page/linear/use-task-page-linear-list-projection.ts rename to src/renderer/src/components/task-page/linear/issues/use-task-page-linear-list-projection.ts index 26bafb6a469..20f35e45b3f 100644 --- a/src/renderer/src/components/task-page/linear/use-task-page-linear-list-projection.ts +++ b/src/renderer/src/components/task-page/linear/issues/use-task-page-linear-list-projection.ts @@ -1,12 +1,12 @@ import type { TaskPageLinearListSelectionModel } from './use-task-page-linear-list-selection' import { useMemo, useCallback, useEffect } from 'react' -import { filterLinearIssuesBySearchQuery } from '@/components/task-page/linear/task-page-linear-in-orca-issues' +import { filterLinearIssuesBySearchQuery } from '@/components/task-page/linear/issues/task-page-linear-in-orca-issues' import { clampLinearIssueListLimit, LINEAR_ISSUE_LIST_MAX -} from '../../../../../shared/linear/issue-read-limits' -import { compareLinearIssues } from './task-page-linear-jira-list-model' -import { LINEAR_ITEM_LIMIT } from '../task-page-source-context' +} from '../../../../../../shared/linear/issue-read-limits' +import { compareLinearIssues } from './task-page-linear-issue-grouping' +import { LINEAR_ITEM_LIMIT } from '../../task-page-source-context' import { useTaskPageLinearListPresentation } from './use-task-page-linear-list-presentation' export function useTaskPageLinearListProjectionPrelude(model: TaskPageLinearListSelectionModel) { const { diff --git a/src/renderer/src/components/task-page/linear/use-task-page-linear-list-selection.ts b/src/renderer/src/components/task-page/linear/issues/use-task-page-linear-list-selection.ts similarity index 96% rename from src/renderer/src/components/task-page/linear/use-task-page-linear-list-selection.ts rename to src/renderer/src/components/task-page/linear/issues/use-task-page-linear-list-selection.ts index 13a01d43e64..6eeb5765abc 100644 --- a/src/renderer/src/components/task-page/linear/use-task-page-linear-list-selection.ts +++ b/src/renderer/src/components/task-page/linear/issues/use-task-page-linear-list-selection.ts @@ -1,17 +1,17 @@ import { linearIssueReadError } from '@/lib/work-items/linear/linear-read-error' -import type { TaskPageProviderMetadataModel } from '../use-task-page-provider-metadata' +import type { TaskPageProviderMetadataModel } from '../../use-task-page-provider-metadata' import { useState, useMemo, useEffect } from 'react' -import { LINEAR_ISSUE_LIST_MAX } from '../../../../../shared/linear/issue-read-limits' +import { LINEAR_ISSUE_LIST_MAX } from '../../../../../../shared/linear/issue-read-limits' import { useTranslate } from '@/i18n/use-translate' -import type { LinearTeam } from '../../../../../shared/linear/workspace-types' +import type { LinearTeam } from '../../../../../../shared/linear/workspace-types' import { buildLinearTeamUrl, getLinearOrganizationUrlKeyFromIssueUrl -} from '../../../../../shared/linear/links' +} from '../../../../../../shared/linear/links' import { reconcileLinearTeamSelection, storedLinearTeamSelection -} from '@/components/task-page/linear/task-page-linear-team-selection' +} from '@/components/task-page/linear/issues/task-page-linear-team-selection' import { useTaskPageLinearFilterSelection } from './use-task-page-linear-filter-selection' export type TaskPageLinearListSelectionPreludeModel = ReturnType< typeof useTaskPageLinearListSelectionPrelude diff --git a/src/renderer/src/components/task-page/linear/ProjectList.tsx b/src/renderer/src/components/task-page/linear/projects/ProjectList.tsx similarity index 96% rename from src/renderer/src/components/task-page/linear/ProjectList.tsx rename to src/renderer/src/components/task-page/linear/projects/ProjectList.tsx index 4855a01b4b9..95f2bddbbab 100644 --- a/src/renderer/src/components/task-page/linear/ProjectList.tsx +++ b/src/renderer/src/components/task-page/linear/projects/ProjectList.tsx @@ -1,4 +1,4 @@ -import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions' +import type { TaskPageComposerActionsModel } from '../../use-task-page-composer-actions' import { useTranslate } from '@/i18n/use-translate' import { LinearProjectTable, diff --git a/src/renderer/src/components/task-page/linear/ProjectOverview.tsx b/src/renderer/src/components/task-page/linear/projects/ProjectOverview.tsx similarity index 96% rename from src/renderer/src/components/task-page/linear/ProjectOverview.tsx rename to src/renderer/src/components/task-page/linear/projects/ProjectOverview.tsx index 3d453b0b727..d23ea8d120b 100644 --- a/src/renderer/src/components/task-page/linear/ProjectOverview.tsx +++ b/src/renderer/src/components/task-page/linear/projects/ProjectOverview.tsx @@ -1,4 +1,4 @@ -import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions' +import type { TaskPageComposerActionsModel } from '../../use-task-page-composer-actions' import { LinearProjectOverview } from '@/components/linear-project-view-surfaces' import { openAppHttpLink } from '@/lib/browser/navigation/app-http-link-open' export function TaskPageLinearProjectOverview({ diff --git a/src/renderer/src/components/task-page/linear/use-task-page-linear-collection-effects.ts b/src/renderer/src/components/task-page/linear/projects/use-task-page-linear-collection-effects.ts similarity index 93% rename from src/renderer/src/components/task-page/linear/use-task-page-linear-collection-effects.ts rename to src/renderer/src/components/task-page/linear/projects/use-task-page-linear-collection-effects.ts index dcc217fa5f8..395c29f2f3e 100644 --- a/src/renderer/src/components/task-page/linear/use-task-page-linear-collection-effects.ts +++ b/src/renderer/src/components/task-page/linear/projects/use-task-page-linear-collection-effects.ts @@ -2,12 +2,12 @@ import { observeLinearIssueRead } from '@/store/slices/linear/linear-issue-read- import { linearIssueReadError } from '@/lib/work-items/linear/linear-read-error' import { taskProviderErrorText } from '@/lib/work-items/task-provider-error-copy' import { translate } from '@/i18n/i18n' -import type { TaskPageLinearInOrcaEffectsModel } from './use-task-page-linear-in-orca-effects' +import type { TaskPageLinearInOrcaEffectsModel } from '../issues/use-task-page-linear-in-orca-effects' import { useEffect } from 'react' -import { LINEAR_ITEM_LIMIT } from '../task-page-source-context' -import { useTaskSearchDebouncedApply } from '../use-task-search-debounced-apply' -import { clampLinearIssueListLimit } from '../../../../../shared/linear/issue-read-limits' -import { useTaskPageLinearCustomViewEffects } from './use-task-page-linear-custom-view-effects' +import { LINEAR_ITEM_LIMIT } from '../../task-page-source-context' +import { useTaskSearchDebouncedApply } from '../../use-task-search-debounced-apply' +import { clampLinearIssueListLimit } from '../../../../../../shared/linear/issue-read-limits' +import { useTaskPageLinearCustomViewEffects } from '../views/use-task-page-linear-custom-view-effects' export function useTaskPageLinearCollectionEffectsPrelude(model: TaskPageLinearInOrcaEffectsModel) { const { diff --git a/src/renderer/src/components/task-page/linear/task-page-linear-committed-publication.test.tsx b/src/renderer/src/components/task-page/linear/task-page-linear-committed-publication.test.tsx deleted file mode 100644 index 6ead6b39cee..00000000000 --- a/src/renderer/src/components/task-page/linear/task-page-linear-committed-publication.test.tsx +++ /dev/null @@ -1,361 +0,0 @@ -// @vitest-environment happy-dom -import { - reads, - task, - store, - source, - useRoutingFixture -} from './task-page-linear-refresh-test-fixtures' -import { reads as issueReadRegistry } from '@/store/slices/linear/linear-issue-read-lifecycle' -import '@/store/slices/linear/linear-issue-read' -import { pendingLinearIssueWrites } from '@/store/slices/linear/linear-issue-write-publication' -import { getLinearSourceScope } from '@/store/slices/linear/linear-slice-scope' -import { writeLinearIssue } from '@/lib/work-items/linear/linear-issue-write' -import { defaultScopeSource } from '@/runtime/hosts/selection/default-creation-host' -import { useLinearIssueWorkspaceDetail } from '../../linear-issue-workspace-detail-state' -import type { LinearIssue } from '../../../../../shared/linear/issue-types' -import { expect, it } from 'vitest' -import { act, render, screen } from '@testing-library/react' -import { deferred } from '../../../store/slices/linear/linear-slice-test-harness' - -import { Suspense, startTransition, useState, useLayoutEffect } from 'react' -import { useLinearIssuePublication } from '@/hooks/useLinearIssuePublication' -import type { useTaskPageDetailRouting } from '../use-task-page-detail-routing' - -it.each([true, false])( - 'publication speculative B (suspend=%s) preserves committed authority', - async (suspend) => { - store() - const A = source('local'), - B = { ...A, accountLabel: 'B' } - const blocked = deferred() - const pending = deferred<{ ok: boolean }>() - const seen: string[] = [], - committed: string[] = [] - let navigate!: (value: boolean) => void - function Consumer({ b }: { b: boolean }) { - const row = useLinearIssuePublication(task(), b ? B : A).issue! - seen.push(b ? 'B' : 'A') - useLayoutEffect(() => { - committed.push(b ? 'B' : 'A') - }) - if (b && suspend) { - throw blocked.promise - } - return ( - - {b ? 'B' : 'A'}:{row.priority} - - ) - } - function Harness() { - const [b, setB] = useState(false) - navigate = setB - return ( - Blocked}> - - - ) - } - const mounted = render() - await act(async () => { - startTransition(() => navigate(true)) - }) - expect(seen).toContain('B') - expect(committed.includes('B')).toBe(!suspend) - expect(screen.getByTestId('publication-scope').textContent).toBe(suspend ? 'A:0' : 'B:0') - let saving!: Promise<{ ok: boolean }> - act(() => { - saving = writeLinearIssue(task(), { priority: 4 }, A, () => pending.promise) - }) - if (suspend) { - await act(async () => { - navigate(false) - }) - } - const visible = screen.getByTestId('publication-scope').textContent - try { - expect(visible).toBe(suspend ? 'A:4' : 'B:0') - } finally { - mounted.unmount() - pending.resolve({ ok: false }) - await saving - expect(pendingLinearIssueWrites.size).toBe(0) - expect(issueReadRegistry.size).toBe(0) - } - } -) - -it.each([true, false])( - 'real routing updater discard (suspend=%s) cannot retire a pending field publication', - async (suspend) => { - const s = store() - s.setState({ jiraIssueCache: {}, jiraSearchCache: {} }) - const context = defaultScopeSource(null) - const pendingPriority = deferred<{ ok: boolean }>(), - pendingTitle = deferred<{ ok: boolean }>(), - pendingEarlierTitle = deferred<{ ok: boolean }>() - const hydrated = deferred(), - blocked = deferred() - reads.detail.mockReturnValue(hydrated.promise) - let route!: ReturnType - let pulse!: () => void - const renders: string[] = [], - commits: string[] = [] - let block = true - function Consumer() { - const [tick, setTick] = useState(0) - const current = useRoutingFixture(null) - useLayoutEffect(() => { - route = current - pulse = () => setTick((n) => n + 1) - commits.push( - `${current.selectedLinearIssue?.state.name}:${current.selectedLinearIssue?.priority}` - ) - }) - renders.push( - `${current.selectedLinearIssue?.state.name}:${current.selectedLinearIssue?.priority}` - ) - if (suspend && block && current.selectedLinearIssue?.state.name === 'Hydrated') { - throw blocked.promise - } - return ( - - {current.selectedLinearIssue?.title}:{current.selectedLinearIssue?.priority}:{tick} - - ) - } - const mounted = render( - Blocked}> - - - ) - act(() => route.setSelectedLinearIssue(task())) - let lease!: ReturnType['fetchLinearIssue']> extends { - acquire: () => infer L - } - ? L - : never - let prioritySaving!: Promise<{ ok: boolean }>, - titleSaving!: Promise<{ ok: boolean }>, - earlierTitleSaving!: Promise<{ ok: boolean }> - await act(async () => { - startTransition(() => { - earlierTitleSaving = writeLinearIssue( - task(), - { title: 'Earlier title' }, - context, - () => pendingEarlierTitle.promise - ) - prioritySaving = writeLinearIssue( - task(), - { priority: 4 }, - context, - () => pendingPriority.promise - ) - lease = s - .getState() - .fetchLinearIssue(task().id, 'w', { force: true, seed: task() }) - .acquire() - }) - hydrated.resolve(task('Hydrated')) - await hydrated.promise - await Promise.resolve() - await Promise.resolve() - const view = lease.currentView() - expect(view.data?.state.name).toBe('Hydrated') - expect(view.data?.priority).toBe(4) - startTransition(() => - route.setSelectedLinearIssue(view.data, { - expectedSelection: route.linearSelectionRequest, - isCurrent: () => lease.commit(view, false, () => true) - }) - ) - }) - expect(renders).toContain('Hydrated:4') - expect(commits.includes('Hydrated:4')).toBe(!suspend) - // A real later field write retires the delivered read but must not erase pending priority. - act(() => { - titleSaving = writeLinearIssue( - task(), - { title: 'Current title' }, - context, - () => pendingTitle.promise - ) - pulse() - }) - block = false - await act(async () => { - pulse() - }) - const visible = screen.getByTestId('hydration-scope').textContent - const { projectLinearIssue } = - await import('@/store/slices/linear/linear-issue-write-publication') - expect(projectLinearIssue(getLinearSourceScope(context), task()).priority).toBe(4) - try { - expect(visible).toContain('Current title:4:') - } finally { - mounted.unmount() - lease.release() - pendingTitle.resolve({ ok: false }) - pendingPriority.resolve({ ok: false }) - pendingEarlierTitle.resolve({ ok: false }) - await Promise.all([prioritySaving, titleSaving, earlierTitleSaving]) - expect(pendingLinearIssueWrites.size).toBe(0) - expect(issueReadRegistry.size).toBe(0) - } - } -) - -it('committed hydration survives actual skipped-lane replay, force loading and terminal failure', async () => { - const s = store() - const first = deferred(), - h = deferred() - let rejectReplacement!: (error: Error) => void - const replacement = new Promise((_resolve, reject) => { - rejectReplacement = reject - }) - reads.detail - .mockReturnValueOnce(first.promise) - .mockReturnValueOnce(h.promise) - .mockReturnValueOnce(replacement) - const pending = deferred<{ ok: boolean }>(), - blocked = deferred() - let hold = true - let enroll!: () => void - let saving!: Promise<{ ok: boolean }> - const committed: string[] = [], - rendered: string[] = [] - function Detail() { - const [low, setLow] = useState(false) - const detail = useLinearIssueWorkspaceDetail({ - issue: task(), - providerSettings: defaultScopeSource(null), - requestKey: 'H' - }) - const value = `${detail.displayed.priority}:${detail.editState.priority}:${detail.issueLoading}:${low}` - rendered.push(value) - useLayoutEffect(() => { - committed.push(value) - enroll = () => setLow(true) - }) - if (low && hold) { - throw blocked.promise - } - return {value} - } - const mounted = render( - Blocked}> - - - ) - await act(async () => { - startTransition(() => { - enroll() - saving = writeLinearIssue( - task(), - { priority: 4 }, - defaultScopeSource(null), - () => pending.promise - ) - }) - }) - expect(rendered.some((value) => value.startsWith('4:4:') && value.endsWith(':true'))).toBe(true) - expect(committed.some((value) => value.startsWith('4:4:'))).toBe(false) - await act(async () => { - pending.resolve({ ok: false }) - await saving - }) - expect(pendingLinearIssueWrites.size).toBe(0) - expect(reads.detail).toHaveBeenCalledTimes(2) - await act(async () => { - h.resolve({ ...task('H'), priority: 2 }) - await h.promise - }) - expect(screen.getByTestId('replay').textContent).toBe('2:2:false:false') - act(() => { - s.getState().fetchLinearIssue(task().id, 'w', { force: true }) - }) - expect(reads.detail).toHaveBeenCalledTimes(3) - expect(screen.getByTestId('replay').textContent).toBe('2:2:true:false') - hold = false - await act(async () => { - blocked.resolve() - await blocked.promise - }) - expect(screen.getByTestId('replay').textContent).toBe('2:2:true:true') - await act(async () => { - rejectReplacement(new Error('offline')) - await replacement.catch(() => {}) - }) - expect(screen.getByTestId('replay').textContent).toBe('2:2:false:true') - expect(reads.detail).toHaveBeenCalledTimes(3) - reads.detail.mockResolvedValueOnce({ ...task('G'), priority: 3 }) - await act(async () => { - s.getState().fetchLinearIssue(task().id, 'w', { force: true }) - }) - expect(screen.getByTestId('replay').textContent).toBe('3:3:false:true') - mounted.unmount() - first.resolve(task('retired')) - await first.promise - expect(pendingLinearIssueWrites.size).toBe(0) - expect(issueReadRegistry.size).toBe(0) - act(() => s.getState().patchLinearIssue(task().id, { priority: 4 })) - expect(reads.detail).toHaveBeenCalledTimes(4) -}) - -it('committed field t reconstructs while newer u is in a genuinely suspended lane', async () => { - store() - const t = deferred<{ ok: boolean }>(), - u = deferred<{ ok: boolean }>(), - blocked = deferred() - let savingT!: Promise<{ ok: boolean }>, savingU!: Promise<{ ok: boolean }> - let enqueue!: () => void, pulse!: () => void - let hold = true - const commits: string[] = [] - function Consumer() { - const [low, setLow] = useState(false), - [tick, setTick] = useState(0) - const publication = useLinearIssuePublication(task(), defaultScopeSource(null)) - const value = `${publication.issue?.priority}:${low}:${tick}` - useLayoutEffect(() => { - enqueue = () => setLow(true) - pulse = () => setTick((n) => n + 1) - commits.push(value) - }) - if (low && hold) { - throw blocked.promise - } - return {value} - } - const mounted = render( - Blocked}> - - - ) - act(() => { - savingT = writeLinearIssue(task(), { priority: 4 }, defaultScopeSource(null), () => t.promise) - }) - expect(screen.getByTestId('field-replay').textContent).toBe('4:false:0') - await act(async () => { - startTransition(() => { - enqueue() - savingU = writeLinearIssue(task(), { priority: 2 }, defaultScopeSource(null), () => u.promise) - }) - }) - expect(commits.some((value) => value.startsWith('2:'))).toBe(false) - act(() => pulse()) - expect(screen.getByTestId('field-replay').textContent).toBe('4:false:1') - hold = false - await act(async () => { - blocked.resolve() - await blocked.promise - }) - expect(screen.getByTestId('field-replay').textContent).toBe('2:true:1') - mounted.unmount() - t.resolve({ ok: false }) - u.resolve({ ok: false }) - await Promise.all([savingT, savingU]) - expect(pendingLinearIssueWrites.size).toBe(0) - expect(issueReadRegistry.size).toBe(0) -}) diff --git a/src/renderer/src/components/task-page/linear/task-page-linear-detail-query.test.tsx b/src/renderer/src/components/task-page/linear/task-page-linear-detail-query.test.tsx deleted file mode 100644 index 4a8a64338f6..00000000000 --- a/src/renderer/src/components/task-page/linear/task-page-linear-detail-query.test.tsx +++ /dev/null @@ -1,118 +0,0 @@ -// @vitest-environment happy-dom -import { reads, task } from './task-page-linear-refresh-test-fixtures' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' - -import { getIssue } from '../../../../../main/linear/issues/linear-issue-lookups' -import { createLinearClient, loadLinearSdk } from '../../../../../main/linear/requests/linear-sdk' -import { ISSUE_QUERY } from '../../../../../main/linear/issues/context/issue-context-graphql' - -const clients = vi.hoisted(() => ({ get: vi.fn() })) -vi.mock('../../../../../main/linear/accounts/linear-account-client', () => ({ - getClients: clients.get, - isAuthError: (error: unknown) => error instanceof loadLinearSdk().AuthenticationLinearError -})) -vi.mock('../../../../../main/linear/accounts/linear-token-store', () => ({ clearToken: vi.fn() })) - -const child = { - id: 'child', - identifier: 'DAT-125', - title: 'Child task', - url: 'https://linear.app/acme/issue/DAT-125' -} -const project = { - id: 'project', - name: 'Release', - url: 'https://linear.app/acme/project/release', - color: '#888888' -} -const rawIssue = { - ...task('Fresh'), - branchName: 'dat-124', - description: 'Full description', - priority: 2, - estimate: 3, - dueDate: '2026-11-10', - updatedAt: '2026-11-02T10:00:00-08:00', - assignee: { id: 'user', displayName: 'Ada', avatarUrl: 'https://example.com/avatar' }, - project, - labels: { nodes: [{ id: 'bug', name: 'Bug' }] }, - children: { nodes: [child] } -} -// The shared query returns the same first label page the drawer previously requested. -const { labelIds: _labelIds, ...responseIssue } = rawIssue -const expected = { - ...task('Fresh'), - branchName: rawIssue.branchName, - description: rawIssue.description, - priority: 2, - estimate: 3, - dueDate: rawIssue.dueDate, - updatedAt: '2026-11-02T18:00:00.000Z', - workspaceName: 'Acme', - assignee: rawIssue.assignee, - project, - labels: ['Bug'], - labelIds: ['bug'], - subIssues: [child] -} -let requests: { query: string; variables: Record }[] - -beforeEach(() => { - requests = [] - vi.spyOn(globalThis, 'fetch').mockImplementation(async (_input, init) => { - requests.push(JSON.parse(String(init?.body))) - return Response.json({ data: { issue: responseIssue } }) - }) - clients.get.mockImplementation((_workspaceId: string, signal: AbortSignal) => [ - { - workspace: { id: 'w', organizationName: 'Acme' }, - client: createLinearClient({ apiKey: 'test-only', signal }) - } - ]) - reads.detail.mockImplementation((_source, id: string, workspaceId: string) => - getIssue(id, workspaceId) - ) -}) -afterEach(() => vi.restoreAllMocks()) - -function expectQuery(index: number, id: string) { - expect(requests[index]).toEqual({ - query: ISSUE_QUERY, - variables: { id, includeDrawerFields: true } - }) - expect(requests[index].query).toContain('children(first: 25)') - expect(clients.get).toHaveBeenLastCalledWith('w', expect.any(AbortSignal)) -} - -it('keeps readable core detail when an optional relation returns partial GraphQL errors', async () => { - vi.mocked(globalThis.fetch).mockImplementation(async (_input, init) => { - requests.push(JSON.parse(String(init?.body))) - return Response.json({ - data: { - issue: { ...responseIssue, assignee: { ...responseIssue.assignee, avatarUrl: null } } - }, - errors: [{ message: 'Avatar resolver failed', path: ['issue', 'assignee', 'avatarUrl'] }] - }) - }) - await expect(getIssue(task().id, 'w')).resolves.toEqual({ ...expected, assignee: undefined }) - expect(requests).toHaveLength(1) - expectQuery(0, task().id) -}) - -it.each([ - { path: ['issue', 'title'], extensions: undefined, auth: false }, - { path: ['issue', 'assignee'], extensions: { type: 'authentication error' }, auth: true } -])( - 'does not accept partial data for a core or authentication failure: $path', - async ({ path, extensions, auth }) => { - vi.spyOn(console, 'warn').mockImplementation(() => {}) - vi.mocked(globalThis.fetch).mockResolvedValue( - Response.json({ - data: { issue: responseIssue }, - errors: [{ message: 'Read failed', path, extensions }] - }) - ) - const read = getIssue(task().id, 'w') - await (auth ? expect(read).rejects.toThrow('Linear') : expect(read).resolves.toBeNull()) - } -) diff --git a/src/renderer/src/components/task-page/linear/task-page-linear-jira-list-model.tsx b/src/renderer/src/components/task-page/linear/task-page-linear-jira-list-model.tsx deleted file mode 100644 index 9101632e7c9..00000000000 --- a/src/renderer/src/components/task-page/linear/task-page-linear-jira-list-model.tsx +++ /dev/null @@ -1,199 +0,0 @@ -import type { LinearIssue } from '../../../../../shared/linear/issue-types' -import type { GitHubWorkItem } from '../../../../../shared/github/work-items/work-item-types' -import { getLinearPriorityLabel } from '@/components/task-page/task-page-localized-options' -import type { - LinearOrderBy, - LinearGroupBy, - LinearDisplayProperty -} from '../../../../../shared/linear/issue-view-resume-state' -import { translate } from '@/i18n/i18n' -import { useTranslate, type Translate } from '@/i18n/use-translate' -import type { TaskPageJiraLoadError } from '@/components/task-page/jira/task-page-jira-load-state' -import React from 'react' -import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible' -import { AlertCircle, ChevronDown, ChevronRight } from 'lucide-react' -import { Button } from '@/components/ui/button' -import type { TaskPageGitHubMutationIntent } from '@/components/task-page/github/mutations/task-page-github-work-item-mutation-patches' -import type { TaskSourceContext } from '../../../../../shared/work-items/task-source-context' -import type { LinearGroupSection } from './task-page-linear-issue-model' -import { compareNumericLocaleText } from '../../../../../shared/locale-text-collators' -export function getLinearPriorityRank(priority: number): number { - return priority === 0 ? 5 : priority -} -export function compareLinearIssues( - a: LinearIssue, - b: LinearIssue, - orderBy: LinearOrderBy -): number { - if (orderBy === 'updated') { - return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() - } - if (orderBy === 'identifier') { - return compareNumericLocaleText(a.identifier, b.identifier) - } - const priorityDelta = getLinearPriorityRank(a.priority) - getLinearPriorityRank(b.priority) - if (priorityDelta !== 0) { - return priorityDelta - } - return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() -} -export function getLinearIssueGroup( - issue: LinearIssue, - groupBy: LinearGroupBy, - t: Translate = translate -): { - key: string - label: string -} { - if (groupBy === 'status') { - return { - key: `status:${issue.state.name}`, - label: issue.state.name - } - } - if (groupBy === 'assignee') { - return { - key: `assignee:${issue.assignee?.id ?? 'unassigned'}`, - label: - issue.assignee?.displayName ?? t('auto.components.TaskPage.groupUnassigned', 'Unassigned') - } - } - if (groupBy === 'priority') { - return { - key: `priority:${issue.priority}`, - label: getLinearPriorityLabel(issue.priority) - } - } - if (groupBy === 'team') { - return { - key: `team:${issue.team.id}`, - label: issue.team.name - } - } - return { - key: 'all', - label: t('auto.components.TaskPage.dfc0c79bd8', 'Issues') - } -} -export function groupLinearIssues( - issues: LinearIssue[], - groupBy: LinearGroupBy, - orderBy: LinearOrderBy, - t: Translate = translate -): LinearGroupSection[] { - const sorted = [...issues].sort((a, b) => compareLinearIssues(a, b, orderBy)) - if (groupBy === 'none') { - return [ - { - key: 'all', - label: t('auto.components.TaskPage.dfc0c79bd8', 'Issues'), - issues: sorted - } - ] - } - const sections = new Map() - for (const issue of sorted) { - const group = getLinearIssueGroup(issue, groupBy, t) - const section = sections.get(group.key) - if (section) { - section.issues.push(issue) - } else { - sections.set(group.key, { - key: group.key, - label: group.label, - issues: [issue] - }) - } - } - return [...sections.values()] -} -export function TaskPageJiraErrorBanner({ - error, - open, - onOpenChange -}: { - error: TaskPageJiraLoadError - open: boolean - onOpenChange: (open: boolean) => void -}): React.JSX.Element { - const t = useTranslate() - return ( - -
- -
-
{error.title}
- {error.details ? ( - <> - - - - -
- {error.details} -
-
- - ) : null} -
-
-
- ) -} -export function getLinearIssueGridTemplate( - visibleProperties: ReadonlySet -): string { - const columns = ['96px', 'minmax(240px,1.55fr)'] - if (visibleProperties.has('labels')) { - columns.push('minmax(168px,0.9fr)') - } - if (visibleProperties.has('team')) { - columns.push('minmax(172px,0.9fr)') - } - if (visibleProperties.has('state')) { - columns.push('138px') - } - if (visibleProperties.has('assignee')) { - columns.push('64px') - } - if (visibleProperties.has('updated')) { - columns.push('104px') - } - // Why: Worktrees is icon-only (open vs start); keep it narrow so issue title keeps the room. - columns.push('64px') - return columns.join(' ') -} -export type TaskPageGitHubWorkItemMutationRunner = { - run: (input: { - item: GitHubWorkItem - intent: TaskPageGitHubMutationIntent - sourceContext?: TaskSourceContext | null - mutate: () => Promise<{ - ok?: boolean - error?: - | string - | { - message?: string - } - } | void> - successToast?: string - errorToast: string - }) => Promise<'confirmed' | 'rolled_back' | 'stale'> - isIntentPending: (input: { - item: GitHubWorkItem - intent: TaskPageGitHubMutationIntent - sourceContext?: TaskSourceContext | null - }) => boolean -} diff --git a/src/renderer/src/components/task-page/linear/task-page-linear-refresh-entry.test.tsx b/src/renderer/src/components/task-page/linear/task-page-linear-refresh-entry.test.tsx deleted file mode 100644 index b4edb49b54f..00000000000 --- a/src/renderer/src/components/task-page/linear/task-page-linear-refresh-entry.test.tsx +++ /dev/null @@ -1,190 +0,0 @@ -// @vitest-environment happy-dom -import { reads, task, store, source } from './task-page-linear-refresh-test-fixtures' - -import { getLinearSourceScope } from '@/store/slices/linear/linear-slice-scope' -import { writeLinearIssue } from '@/lib/work-items/linear/linear-issue-write' -import { defaultScopeSource } from '@/runtime/hosts/selection/default-creation-host' - -import { useLinearIssueWorkspaceDetail } from '../../linear-issue-workspace-detail-state' -import type { LinearIssue } from '../../../../../shared/linear/issue-types' -import { expect, it } from 'vitest' -import { act, renderHook, waitFor } from '@testing-library/react' -import { deferred } from '../../../store/slices/linear/linear-slice-test-harness' - -it('workspace hydration follows a full-source change without a remount or a successor edge', async () => { - store() - const A = source('local'), - B = { ...A, accountLabel: 'B' } - const old = deferred(), - current = deferred() - reads.detail - .mockReturnValueOnce(old.promise) - .mockReturnValueOnce(current.promise) - .mockResolvedValue({ ...task('External'), priority: 2 }) - const detail = renderHook( - ({ context }) => - useLinearIssueWorkspaceDetail({ - issue: task(), - providerSettings: context, - requestKey: 'unchanged-host-workspace-U' - }), - { initialProps: { context: A } } - ) - detail.rerender({ context: B }) - expect(reads.detail).toHaveBeenCalledTimes(2) - const pending = deferred<{ ok: boolean }>() - let saving!: Promise<{ ok: boolean }> - act(() => { - saving = writeLinearIssue(task(), { priority: 4 }, B, () => pending.promise) - }) - await act(async () => { - old.resolve({ ...task('Old A'), priority: 0 }) - await old.promise - }) - expect(detail.result.current.displayed.priority).toBe(4) - expect(detail.result.current.displayed.state.name).not.toBe('Old A') - await act(async () => { - current.resolve({ ...task('Current B'), priority: 1 }) - await current.promise - }) - expect(detail.result.current.displayed.priority).toBe(4) - await act(async () => { - pending.resolve({ ok: false }) - await saving - }) - await waitFor(() => expect(detail.result.current.displayed.state.name).toBe('External')) - expect(detail.result.current.displayed.priority).toBe(2) - expect(detail.result.current.issueLoading).toBe(false) -}) - -it('actual runtime-status session loss and recovery reacquire hydration and finish loading', async () => { - const s = store() - const { createRuntimeStatusSlice } = - await import('@/store/slices/runtime-status/create-runtime-status-slice') - // SAFETY: this fixture installs the production runtime slice over the same Zustand store. - const runtime = createRuntimeStatusSlice( - s.setState, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this fixture supplies the real consumer's exercised fields and actions; unrelated full-page/provider services are intentionally absent. - s.getState as unknown as Parameters[1] - ) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this fixture supplies the real consumer's exercised fields and actions; unrelated full-page/provider services are intentionally absent. - s.setState({ ...runtime, settings: { activeRuntimeEnvironmentId: 'remote' } as never }) - runtime.setRuntimeEnvironmentStatus( - 'remote', - { status: null, checkedAt: 1 }, - { suppressDisconnectToast: true } - ) - const old = deferred(), - fresh = deferred() - reads.detail.mockReturnValueOnce(old.promise).mockReturnValueOnce(fresh.promise) - const detail = renderHook(() => - useLinearIssueWorkspaceDetail({ - issue: task(), - providerSettings: source('runtime:remote'), - requestKey: 'same' - }) - ) - act(() => - detail.result.current.handleCommentAdded({ - id: 'local', - body: 'Draft comment', - createdAt: 'today' - }) - ) - await act(async () => { - // SAFETY: the real transition inspects runtime identity/readiness only; no snapshot or transport is used. - runtime.setRuntimeEnvironmentStatus( - 'remote', - { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this fixture supplies the real consumer's exercised fields and actions; unrelated full-page/provider services are intentionally absent. - status: { - runtimeId: 'new', - graphStatus: 'ready', - rendererGraphEpoch: 0, - authoritativeWindowId: null, - liveTabCount: 0, - liveLeafCount: 0, - runtimeProtocolVersion: 3, - minCompatibleRuntimeClientVersion: 3 - } as never, - checkedAt: 2 - }, - { suppressDisconnectToast: true } - ) - }) - expect(reads.detail).toHaveBeenCalledTimes(2) - await act(async () => { - old.resolve(task('Old session')) - fresh.resolve(task('New session')) - await Promise.all([old.promise, fresh.promise]) - }) - expect(detail.result.current.displayed.state.name).toBe('New session') - expect(detail.result.current.issueLoading).toBe(false) - expect(detail.result.current.comments.map((comment) => comment.id)).toContain('local') - const pending = deferred<{ ok: boolean }>() - let saving!: Promise<{ ok: boolean }> - act(() => { - saving = writeLinearIssue( - task(), - { priority: 4 }, - source('runtime:remote'), - () => pending.promise - ) - }) - expect(reads.detail).toHaveBeenCalledTimes(2) - detail.unmount() - await act(async () => { - pending.resolve({ ok: false }) - await saving - }) -}) - -it.each(['probe', 'disconnect'] as const)( - 'foreign mounted hydration completes after local %s without another render edge', - async (edge) => { - const s = store() - const foreign = source('runtime:foreign') - const transport = deferred() - reads.detail.mockReturnValue(transport.promise) - const local = renderHook(() => - useLinearIssueWorkspaceDetail({ - issue: task(), - providerSettings: defaultScopeSource(null), - requestKey: 'local-U' - }) - ) - const foreignKey = `${getLinearSourceScope(foreign).cachePrefix}::w::other` - const foreignCache = { data: task('Foreign cache'), fetchedAt: 1 } - s.setState({ linearIssueCache: { [foreignKey]: foreignCache } }) - const detail = renderHook(() => - useLinearIssueWorkspaceDetail({ - issue: task(), - providerSettings: foreign, - requestKey: 'foreign-U' - }) - ) - expect(detail.result.current.issueLoading).toBe(true) - await act(async () => { - await (edge === 'probe' - ? s.getState().testLinearConnection('w') - : s.getState().disconnectLinear()) - }) - await act(async () => { - transport.resolve(task('Foreign provider')) - await transport.promise - }) - expect(detail.result.current.displayed.state.name).toBe('Foreign provider') - expect(detail.result.current.issueLoading).toBe(false) - expect(reads.detail).toHaveBeenCalledTimes(2) - expect(s.getState().linearIssueCache[foreignKey]).toBe(foreignCache) - if (edge === 'disconnect') { - expect(local.result.current.issueLoading).toBe(false) - expect(local.result.current.displayed.state.name).toBe('In Review') - } else { - expect(local.result.current.displayed.state.name).toBe('Foreign provider') - expect(local.result.current.issueLoading).toBe(false) - } - local.unmount() - detail.unmount() - } -) diff --git a/src/renderer/src/components/task-page/linear/task-page-linear-refresh-lifecycle.test.tsx b/src/renderer/src/components/task-page/linear/task-page-linear-refresh-lifecycle.test.tsx deleted file mode 100644 index 56badab631e..00000000000 --- a/src/renderer/src/components/task-page/linear/task-page-linear-refresh-lifecycle.test.tsx +++ /dev/null @@ -1,197 +0,0 @@ -// @vitest-environment happy-dom -import { - reads, - task, - store, - read, - loadingHook, - source -} from './task-page-linear-refresh-test-fixtures' -import '@/store/slices/linear/linear-issue-read' -import { - pendingLinearIssueWrites, - registerLinearIssueConsumer -} from '@/store/slices/linear/linear-issue-write-publication' -import { writeLinearIssue } from '@/lib/work-items/linear/linear-issue-write' - -import { defaultScopeSource } from '@/runtime/hosts/selection/default-creation-host' -import { getLinearSourceScope } from '@/store/slices/linear/linear-slice-scope' - -import type { LinearIssue } from '../../../../../shared/linear/issue-types' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { act, waitFor } from '@testing-library/react' -import { deferred } from '../../../store/slices/linear/linear-slice-test-harness' - -const cacheTime = Date.parse('2026-01-01T00:00:00Z') -beforeEach(() => { - vi.useFakeTimers({ toFake: ['Date'] }) - vi.setSystemTime(cacheTime) -}) -afterEach(() => vi.useRealTimers()) - -it('keeps only own-query warm omissions, accepts new membership, and releases protection at settlement', async () => { - const s = store() - const untouched = { ...task(), id: 'untouched', title: 'untouched' } - reads.list.mockResolvedValueOnce({ items: [task(), untouched] }) - await read(s, 'list') - const next = { ...task('Server'), id: 'new', title: 'new' } - reads.list.mockResolvedValueOnce({ items: [next] }) - const pending = deferred<{ ok: boolean }>() - const hook = loadingHook(s) - let saving!: Promise<{ ok: boolean }> - act(() => { - saving = writeLinearIssue( - task(), - { state: task('Done').state }, - defaultScopeSource(null), - () => pending.promise - ) - }) - // A force dispatched during the pending interval may add rows, but cannot remove this query's protected occurrence. - reads.list.mockResolvedValueOnce({ items: [next] }) - hook.rerender({ nonce: 1 }) - await waitFor(() => - expect(hook.result.current.rows.map((row) => row.id)).toEqual(['same-uuid', 'new']) - ) - expect(hook.result.current.rows[0].state.name).toBe('Done') - reads.list.mockResolvedValueOnce({ items: [] }) - await act(async () => { - pending.resolve({ ok: true }) - await saving - }) - await waitFor(() => expect(hook.result.current.rows).toEqual([])) -}) - -it('dispatches despite a broken local subscriber and clears thrown writers independently of mount', async () => { - const s = store() - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const registration = registerLinearIssueConsumer( - getLinearSourceScope(defaultScopeSource(null)), - task().id, - () => { - throw new Error('subscriber bug') - } - ) - const mutate = vi.fn(async () => { - throw new Error('transport failure') - }) - await act(async () => { - await expect( - writeLinearIssue(task(), { priority: 4 }, defaultScopeSource(null), mutate) - ).rejects.toThrow('transport failure') - }) - expect(mutate).toHaveBeenCalledTimes(1) - registration.release() - reads.detail.mockResolvedValue({ ...task('External'), priority: 2 }) - expect((await s.getState().fetchLinearIssue(task().id, 'w', { force: true }))?.priority).toBe(2) - warn.mockRestore() -}) - -it('captured default runtime cannot seed or patch the focused foreign runtime cache', async () => { - const s = store() - const pending = deferred<{ ok: boolean }>() - const saving = writeLinearIssue( - task(), - { state: task('Done').state }, - defaultScopeSource(null), - () => pending.promise - ) - // SAFETY: only activeRuntimeEnvironmentId is read by this captured-scope fixture. - s.setState({ - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this fixture supplies the real consumer's exercised fields and actions; unrelated full-page/provider services are intentionally absent. - settings: { activeRuntimeEnvironmentId: 'remote' } as never, - linearIssueCache: { 'w::same-uuid': { data: task('Remote'), fetchedAt: cacheTime } } - }) - const remote = s.getState().fetchLinearIssue(task().id, 'w').acquire() - expect(remote.currentView().data?.state.name).toBe('Remote') - reads.detail.mockResolvedValue(task('Local observation')) - const lease = s - .getState() - .fetchLinearIssue(task().id, 'w', { - scope: getLinearSourceScope({ kind: 'local' }), - force: true - }) - .acquire() - expect(lease.currentView().data).toBeNull() - expect((await lease.waitForCurrentTerminal()).data?.state.name).toBe('Done') - await act(async () => { - pending.resolve({ ok: false }) - await saving - }) - expect(s.getState().linearIssueCache['w::same-uuid'].data?.state.name).toBe('Remote') - expect(remote.currentView().kind).toBe('data') - expect(remote.currentView().data?.state.name).toBe('Remote') - remote.release() - lease.release() -}) - -it('full disconnect detaches default and every explicit source, preserving another runtime and replacement owner', async () => { - const s = store() - const contexts = [ - defaultScopeSource(null), - source('local'), - { ...source('local'), accountLabel: 'second' }, - source('runtime:other') - ] - const pending = contexts.map(() => deferred<{ ok: boolean }>()) - const saves = contexts.map((context, i) => - writeLinearIssue(task(), { priority: 4 }, context, () => pending[i].promise) - ) - await s.getState().disconnectLinear() - const keys = contexts.map((context) => getLinearSourceScope(context).contextKey) - expect(keys.map((key) => pendingLinearIssueWrites.has(key))).toEqual([false, false, false, true]) - expect((await s.getState().connectLinear('fixture-not-a-credential')).ok).toBe(true) - reads.detail.mockResolvedValue({ ...task(), priority: 2 }) - const lease = s - .getState() - .fetchLinearIssue(task().id, 'w', { scope: getLinearSourceScope(contexts[1]), force: true }) - .acquire() - expect((await lease.waitForCurrentTerminal()).data?.priority).toBe(2) - const next = deferred<{ ok: boolean }>() - const replacement = writeLinearIssue( - { ...task(), priority: 2 }, - { priority: 3 }, - contexts[1], - () => next.promise - ) - await act(async () => { - for (let i = 0; i < 3; i++) { - pending[i].resolve({ ok: i === 2 }) - } - await Promise.all(saves.slice(0, 3)) - }) - expect(pendingLinearIssueWrites.has(keys[1])).toBe(true) - expect(pendingLinearIssueWrites.has(keys[3])).toBe(true) - expect(lease.currentView().data?.priority).toBe(3) - await act(async () => { - next.resolve({ ok: false }) - pending[3].resolve({ ok: true }) - await Promise.all([replacement, saves[3]]) - }) - expect((await lease.waitForCurrentTerminal()).data?.priority).toBe(2) - lease.release() - expect(pendingLinearIssueWrites.size).toBe(0) -}) - -it('a shared read gives explicit-runtime consumers authority after default focus moves', async () => { - const s = store(), - pending = deferred() - reads.detail.mockReturnValue(pending.promise) - const focused = s.getState().fetchLinearIssue(task().id, 'w').acquire() - const pinned = s - .getState() - .fetchLinearIssue(task().id, 'w', { scope: getLinearSourceScope({ kind: 'local' }) }) - .acquire() - expect(reads.detail).toHaveBeenCalledTimes(1) - // SAFETY: the fixture only reads the focused runtime selector from settings. - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this fixture supplies the real consumer's exercised fields and actions; unrelated full-page/provider services are intentionally absent. - s.setState({ settings: { activeRuntimeEnvironmentId: 'elsewhere' } as never }) - await act(async () => { - pending.resolve(task('Local')) - await pending.promise - }) - expect(focused.currentView().kind).toBe('abandoned') - expect(pinned.currentView().data?.state.name).toBe('Local') - focused.release() - pinned.release() -}) diff --git a/src/renderer/src/components/task-page/linear/task-page-linear-refresh-test-fixtures.tsx b/src/renderer/src/components/task-page/linear/task-page-linear-refresh-test-fixtures.tsx deleted file mode 100644 index b69852a36fc..00000000000 --- a/src/renderer/src/components/task-page/linear/task-page-linear-refresh-test-fixtures.tsx +++ /dev/null @@ -1,446 +0,0 @@ -import { useTaskPageDetailRouting } from '../use-task-page-detail-routing' -import type * as ReactModule from 'react' -import type { LinearEditState } from '../../linear-item-drawer-types' -import { useCallback } from 'react' -import { useLinearIssueResult } from '@/hooks/linear-issue-committed-state' -import { getLinearSourceScope } from '@/store/slices/linear/linear-slice-scope' -import { defaultScopeSource } from '@/runtime/hosts/selection/default-creation-host' -import { LinearIssueTextEditor } from '../../LinearIssueTextEditor' -import { useLinearIssueEditController } from '../../linear-item-drawer-edit-controller' -import type { LinearIssue } from '../../../../../shared/linear/issue-types' -import { useStore } from 'zustand' -import type { TaskSourceContext } from '../../../../../shared/work-items/task-source-context' -import { useTaskPageLinearCollectionEffectsPrelude } from './use-task-page-linear-collection-effects' -import { useTaskPageLinearCustomViewEffects } from './use-task-page-linear-custom-view-effects' -import { afterEach, beforeEach, vi, type Mock } from 'vitest' -import { cleanup, renderHook } from '@testing-library/react' -import { createTestStore, issue } from '../../../store/slices/linear/linear-slice-test-harness' -import { clearLinearRequestMaps } from '../../../store/slices/linear/linear-slice-request-state' -import { useTaskPageLinearListSelectionPrelude } from './use-task-page-linear-list-selection' -import { useTaskPageLinearListEffects } from './use-task-page-linear-list-effects' - -const deferredIssueSetters = vi.hoisted(() => { - const updates: VoidFunction[] = [] - return { enabled: false, updates } -}) -vi.mock('react', async (original) => { - const actual = await original() - return { - ...actual, - useReducer(reducer: (previous: S, action: A) => S, initial: S) { - const [value, dispatch] = actual.useReducer(reducer, initial) - const send = actual.useCallback((action: A) => { - if ( - deferredIssueSetters.enabled && - typeof initial === 'object' && - initial !== null && - 'whole' in initial - ) { - deferredIssueSetters.updates.push(() => dispatch(action)) - } else { - dispatch(action) - } - }, []) - return [value, send] as const - } - } -}) -export { deferredIssueSetters } - -const active = vi.hoisted(() => ({ store: vi.fn<() => Store>() })) - -vi.mock('@/store', () => ({ - useAppStore: Object.assign( - (selector: (state: ReturnType) => unknown) => - useStore(active.store(), selector), - { - getState: () => active.store().getState(), - subscribe: (...args: Parameters) => active.store().subscribe(...args) - } - ) -})) - -vi.mock('@/i18n/i18n', async (original) => ({ - ...(await original>()), - translate: (_key: string, fallback: string, values?: Record) => - fallback.replace(/{{(\w+)}}/g, (match, key) => values?.[key] ?? match) -})) - -const reads: { - list: Mock - search: Mock - project: Mock - view: Mock - detail: Mock - update: Mock - create: Mock - projects: Mock - connect: Mock - test: Mock - select: Mock -} = vi.hoisted(() => ({ - list: vi.fn(), - search: vi.fn(), - project: vi.fn(), - view: vi.fn(), - detail: vi.fn(), - update: vi.fn(), - create: vi.fn(), - projects: vi.fn(), - connect: vi.fn(), - test: vi.fn(), - select: vi.fn() -})) - -const accountViewer = vi.hoisted(() => ({ - displayName: 'User', - email: null, - organizationName: 'Acme' -})) - -vi.mock('@/runtime/linear/runtime-linear-client', async (original) => ({ - ...(await original>()), - linearTestConnection: (...args: unknown[]) => reads.test(...args), - linearSelectWorkspace: (...args: unknown[]) => reads.select(...args), - linearDisconnect: async () => {}, - linearConnect: (...args: unknown[]) => reads.connect(...args), - linearStatus: async () => ({ connected: true, viewer: accountViewer, selectedWorkspaceId: 'w' }), - linearListIssues: (...args: unknown[]) => reads.list(...args), - linearSearchIssues: (...args: unknown[]) => reads.search(...args) -})) - -vi.mock('@/runtime/linear/runtime-linear-project-client', async (original) => ({ - ...(await original>()), - linearListProjects: reads.projects, - linearTeamStates: async () => [{ id: 'done', name: 'Done', type: 'completed', color: '#888888' }], - linearListProjectIssues: (...args: unknown[]) => reads.project(...args), - linearListCustomViewIssues: (...args: unknown[]) => reads.view(...args) -})) - -vi.mock('@/runtime/linear/runtime-linear-issue-client', async (original) => ({ - ...(await original>()), - linearGetIssue: reads.detail, - linearIssueComments: async () => [], - linearUpdateIssue: reads.update, - linearCreateIssue: reads.create -})) - -vi.mock('@/hooks/useIssueMetadata', async (original) => ({ - ...(await original>()), - useTeamLabels: () => ({ data: [{ id: 'bug', name: 'Bug' }], loading: false }), - useTeamMembers: () => ({ data: [], loading: false }), - clearLinearMetadataCache: vi.fn(), - useTeamStates: () => ({ - data: [{ id: 'done', name: 'Done', type: 'completed', color: '#888888' }], - loading: false - }) -})) - -vi.mock('@/store/slices/worktrees/listing/use-worktree-catalog', async (original) => { - const empty: [] = [] - return { ...(await original>()), useAllWorktrees: () => empty } -}) - -vi.mock('@/store/workspaces/catalog/workspace-record-fields', async (original) => { - const empty: [] = [] - return { ...(await original>()), selectFolderWorkspaces: () => empty } -}) - -vi.mock('@/components/LinearIssueMarkdownDescriptionEditor', () => ({ - LinearIssueMarkdownDescriptionEditor: () => null -})) - -vi.mock('@/components/linear-item-drawer-sheet', () => ({ - renderLinearItemDrawerSheet: (props: { - displayed: LinearIssue | null - editState: LinearEditState | null - }) => - props.displayed && props.editState ? ( - - ) : ( -
- ) -})) - -export function DrawerFields({ row, editState }: { row: LinearIssue; editState: LinearEditState }) { - const controller = useLinearIssueEditController({ issue: row, editState }) - return ( - <> - - {row.state.name}:{editState.priority}:{editState.labels.join(',')} - - - - - - ) -} - -export function task(state = 'In Review') { - return { - ...issue('same-uuid'), - identifier: 'DAT-124', - workspaceId: 'w', - state: { name: state, type: state === 'Done' ? 'completed' : 'started', color: '#888888' } - } -} - -export function store() { - const result = createTestStore() - result.setState({ - linearStatus: { connected: true, viewer: accountViewer, selectedWorkspaceId: 'w' }, - linearStatusObserved: true, - linearStatusChecked: true, - linearStatusContextKey: getLinearSourceScope(defaultScopeSource(null)).runtimeContextKey - }) - active.store.mockReturnValue(result) - return result -} - -export type Store = ReturnType - -export type View = 'list' | 'search' | 'project' | 'custom-view' - -export const views: View[] = ['list', 'search', 'project', 'custom-view'] - -export const listArgs = { kind: 'list', filter: 'all', limit: 36 } as const - -export function read(s: Store, view: View, force = true) { - const state = s.getState() - if (view === 'search') { - return state.searchLinearIssues('DAT-124', 36, { force }) - } - if (view === 'project') { - return state.listLinearProjectIssues('p', 'w', 36, { force }) - } - if (view === 'custom-view') { - return state.listLinearCustomViewIssues('v', 'w', 36, { force }) - } - return state.listLinearIssues(listArgs, { force }) -} - -export function wire(view: View): Mock { - return view === 'custom-view' ? reads.view : reads[view] -} - -export function response(view: View, state: string) { - return view === 'search' ? [task(state)] : { items: [task(state)] } -} - -export function fixture(s: Store, rows: LinearIssue[], view: View = 'list') { - return { - settings: null, - linearStatus: s.getState().linearStatus, - selectedLinearWorkspaceId: 'w', - linearTaskSourceContext: null, - linearMode: 'issues', - linearAttributeFilter: { stateIds: [], priorities: [], assignee: null, labelIds: [] }, - linearAttributeFilterWorkspaceId: 'w', - linearIssues: rows, - linearIssueLimit: 36, - linearIssuePage: 0, - linearIssueLoadingTargetPage: null, - linearIssuesHasMore: false, - linearLoading: false, - linearError: null, - appliedLinearSearch: view === 'search' ? 'DAT-124' : '', - selectedLinearProject: - view === 'project' ? { id: 'p', workspaceId: 'w', name: 'Project' } : null, - linearProjectTab: view === 'project' ? 'issues' : 'overview', - linearProjectIssuesResult: { items: rows }, - linearProjectIssueLimit: 36, - linearProjectIssuePage: 0, - linearProjectIssueLoadingTargetPage: null, - linearProjectIssuesLoading: false, - linearProjectIssuesError: null, - selectedLinearCustomView: - view === 'custom-view' ? { id: 'v', workspaceId: 'w', model: 'issue', name: 'View' } : null, - linearCustomViewIssuesResult: { items: rows }, - linearCustomViewIssueLimit: 36, - linearCustomViewIssuePage: 0, - linearCustomViewIssueLoadingTargetPage: null, - linearCustomViewContentsLoading: false, - linearCustomViewContentsError: null, - availableTeams: [] - } -} - -export function display( - s: Store, - rows: LinearIssue[], - view: View = 'list', - context: TaskSourceContext | null = null -) { - const base = { ...fixture(s, rows, view), ...s.getState() } - const noop = vi.fn() - return renderHook(() => { - const scope = getLinearSourceScope(context ?? defaultScopeSource(null)) - const projectResult = useLinearIssueResult(`${scope.contextKey}project`, { items: rows }, scope) - const viewResult = useLinearIssueResult(`${scope.contextKey}view`, { items: rows }, scope) - const result = view === 'custom-view' ? viewResult : projectResult - const current = view === 'project' || view === 'custom-view' ? result.data.items : rows - const model = { - ...base, - linearTaskSourceContext: context, - linearIssues: current, - linearProjectIssueResult: projectResult, - linearCustomViewIssueResult: viewResult, - linearProjectIssuesResult: projectResult.data, - linearCustomViewIssuesResult: viewResult.data, - taskResumeApplied: true, - linearProjectSearchInput: '', - setAppliedLinearProjectSearch: noop, - filteredLinearIssues: rows, - clearSelectedLinearIssue: noop, - linearConnected: true, - taskSource: 'linear', - linearRefreshNonce: 0, - fetchLinearProject: async () => ({ id: 'p' }), - setSelectedLinearProjectDetail: noop, - setLinearProjectDetailLoading: noop, - setLinearProjectDetailError: noop, - setLinearProjectIssuesLoading: projectResult.setLoading, - setLinearProjectIssuesError: projectResult.setError, - setLinearCustomViewContentsLoading: viewResult.setLoading, - setLinearCustomViewContentsError: viewResult.setError, - setLinearCustomViewProjectsResult: noop, - setLinearProjectIssuesResult: projectResult.setData, - setLinearCustomViewIssuesResult: viewResult.setData - } - // SAFETY: fixture supplies the selected collection loader's fields; unrelated persistence is disabled. - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this fixture supplies the real consumer's exercised fields and actions; unrelated full-page/provider services are intentionally absent. - useTaskPageLinearCollectionEffectsPrelude({ - ...model, - selectedLinearProject: view === 'project' ? base.selectedLinearProject : null - } as never) - // SAFETY: fixture supplies the custom-view loader fields and no view inventory is requested. - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this fixture supplies the real consumer's exercised fields and actions; unrelated full-page/provider services are intentionally absent. - useTaskPageLinearCustomViewEffects({ ...model, linearMode: 'issues' } as never) - // List/search cache notifications are exercised with the real loader in loadingHook. - // SAFETY: fixture supplies the selection prelude fields. - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this fixture supplies the real consumer's exercised fields and actions; unrelated full-page/provider services are intentionally absent. - const selection = useTaskPageLinearListSelectionPrelude(model as never) - return { ...selection, loading: result.loading, error: result.error } - }) -} - -beforeEach(() => { - deferredIssueSetters.enabled = false - deferredIssueSetters.updates.length = 0 - vi.resetAllMocks() - reads.connect.mockResolvedValue({ ok: true, viewer: accountViewer }) - reads.test.mockResolvedValue({ ok: true, viewer: accountViewer }) - clearLinearRequestMaps() -}) - -afterEach(() => { - cleanup() - clearLinearRequestMaps() -}) - -export function loadingHook(s: Store, query = '', extra = {}, queued?: VoidFunction[]) { - const refs = { - linearSearchPersistReadyRef: { current: false }, - linearViewPersistReadyRef: { current: false }, - linearAttributeFilterReadRef: { current: null }, - lastLinearRequestRef: { current: null }, - landingLinearRefreshKeysRef: { current: new Set() } - } - const noop = vi.fn() - let renderCount = 0 - const base = { - ...fixture(s, [task()]), - ...refs, - ...s.getState(), - taskSource: 'linear', - linearConnected: true, - taskResumeApplied: true, - linearSearchInput: query, - appliedLinearSearch: query, - setTaskResumeState: noop, - setAppliedLinearSearch: noop, - setLinearIssueLimit: noop, - setLinearIssuePage: noop, - setLinearIssueLoadingTargetPage: noop, - setLinearIssuesHasMore: noop, - setLinearLoading: noop, - linearIssueFiltersByWorkspaceId: {}, - ...extra - } - return renderHook( - ({ nonce }) => { - renderCount++ - const scope = getLinearSourceScope(defaultScopeSource(null)) - const result = useLinearIssueResult(scope.contextKey + query, [task()], scope) - const { data: rows, error, loading, read: consumeLinearRead } = result - const deliver = useCallback( - (lease, view, value) => { - if (queued && 'data' in value) { - queued.push(() => consumeLinearRead(lease, view, value)) - // Preserve the original delayed-row seam: outcome consumption is immediate. - const { data: _rows, ...outcome } = value - consumeLinearRead(lease, view, outcome) - } else { - consumeLinearRead(lease, view, value) - } - }, - [consumeLinearRead] - ) - // SAFETY: fixture supplies the loader's dependencies; persistence setters are inert in this hook test. - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this fixture supplies the real consumer's exercised fields and actions; unrelated full-page/provider services are intentionally absent. - useTaskPageLinearListEffects({ - ...base, - linearRefreshNonce: nonce, - linearIssueResult: { ...result, read: deliver }, - setLinearIssues: result.setData, - setLinearError: result.setError, - setLinearLoading: result.setLoading, - setLinearIssuesHasMore: result.setHasMore - } as never) - // SAFETY: the same fixture supplies the rendered selection fields. - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this fixture supplies the real consumer's exercised fields and actions; unrelated full-page/provider services are intentionally absent. - const selection = useTaskPageLinearListSelectionPrelude({ - ...base, - linearIssues: rows, - linearError: error - } as never) - return { - rows, - error, - loading, - displayed: selection.displayedLinearIssues, - - renders: renderCount - } - }, - { initialProps: { nonce: 0 } } - ) -} - -export function source(hostId: TaskSourceContext['hostId']): TaskSourceContext { - return { - kind: 'task-source', - provider: 'linear', - hostId, - projectId: 'project', - repoId: null, - providerIdentity: null, - accountLabel: null - } -} - -export { reads } - -export function useRoutingFixture( - context: ReturnType | null, - initial: LinearIssue | null = null -) { - const noop = vi.fn() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this fixture supplies the real consumer's exercised fields and actions; unrelated full-page/provider services are intentionally absent. - return useTaskPageDetailRouting({ - pageData: { openLinearIssue: initial ?? undefined }, - linearTaskSourceContext: context, - setDialogWorkItem: noop, - openTaskPage: noop - } as never) -} diff --git a/src/renderer/src/components/task-page/linear/task-page-linear-refresh-writers.test.tsx b/src/renderer/src/components/task-page/linear/task-page-linear-refresh-writers.test.tsx deleted file mode 100644 index c9a395096b4..00000000000 --- a/src/renderer/src/components/task-page/linear/task-page-linear-refresh-writers.test.tsx +++ /dev/null @@ -1,140 +0,0 @@ -// @vitest-environment happy-dom -import { reads, task, store } from './task-page-linear-refresh-test-fixtures' -import { LinearIssueProjectSelector } from '../../linear-issue-project-selector' - -import '@/store/slices/linear/linear-issue-read' -import { pendingLinearIssueWrites } from '@/store/slices/linear/linear-issue-write-publication' -import { writeLinearIssue } from '@/lib/work-items/linear/linear-issue-write' - -import { defaultScopeSource } from '@/runtime/hosts/selection/default-creation-host' - -import LinearItemDrawer from '../../LinearItemDrawer' - -import type { LinearIssue } from '../../../../../shared/linear/issue-types' - -import { useLinearIssueWorkspaceDetail } from '../../linear-issue-workspace-detail-state' -import { expect, it, vi } from 'vitest' -import { act, render, fireEvent, screen } from '@testing-library/react' -import { deferred } from '../../../store/slices/linear/linear-slice-test-harness' - -it('real drawer hydration, field controller and text writer compose rollback without permanent masks', async () => { - const s = store() - s.setState({ - recordFeatureInteraction: vi.fn(), - linearStatus: { connected: true, viewer: null, selectedWorkspaceId: 'B' } - }) - const old = deferred(), - fresh = deferred() - reads.detail.mockReturnValueOnce(old.promise).mockReturnValueOnce(fresh.promise) - const priority = deferred<{ ok: boolean }>(), - labels = deferred<{ ok: boolean }>(), - title = deferred<{ ok: boolean }>() - reads.update.mockImplementation((_source, _id, patch) => - 'priority' in patch ? priority.promise : 'labelIds' in patch ? labels.promise : title.promise - ) - render() - expect(reads.detail).toHaveBeenCalledTimes(1) - fireEvent.click(screen.getByRole('button', { name: 'Priority' })) - fireEvent.click(screen.getByRole('button', { name: 'Label' })) - fireEvent.change(screen.getByRole('textbox', { name: 'Issue title' }), { - target: { value: 'Local title' } - }) - fireEvent.blur(screen.getByRole('textbox', { name: 'Issue title' })) - expect(reads.update).toHaveBeenCalledWith( - expect.anything(), - task().id, - { labelIds: ['bug'] }, - 'w' - ) - expect(screen.getByTestId('drawer').textContent).toBe('In Review:4:Bug') - await act(async () => { - old.resolve(task('Stale')) - await old.promise - labels.resolve({ ok: true }) - await labels.promise - }) - expect(screen.getByTestId('drawer').textContent).toBe('In Review:4:Bug') - await act(async () => { - await writeLinearIssue( - task(), - { title: 'Sibling title' }, - defaultScopeSource(null), - async () => ({ ok: true }) - ) - }) - await act(async () => { - priority.resolve({ ok: false }) - title.resolve({ ok: false }) - await Promise.all([priority.promise, title.promise]) - }) - expect(screen.getByTestId('drawer').textContent).toBe('In Review:0:Bug') - expect(screen.getByRole('textbox', { name: 'Issue title' }).value).toBe( - 'Sibling title' - ) - expect(reads.detail).toHaveBeenCalledTimes(2) - await act(async () => { - fresh.resolve({ - ...task('External'), - title: 'Server title', - priority: 2, - labels: [], - labelIds: [] - }) - await fresh.promise - }) - expect(screen.getByTestId('drawer').textContent).toBe('External:2:') - expect(screen.getByRole('textbox', { name: 'Issue title' }).value).toBe( - 'Server title' - ) -}) - -it('confirmed project selection retires old hydration and publishes through the mounted detail consumer', async () => { - store() - const old = deferred(), - external = deferred() - const pending = deferred<{ ok: boolean }>() - reads.detail.mockReturnValueOnce(old.promise).mockReturnValueOnce(external.promise) - reads.projects.mockResolvedValue({ items: [{ id: 'chosen', name: 'Chosen' }] }) - reads.update.mockReturnValueOnce(pending.promise) - function Detail() { - const detail = useLinearIssueWorkspaceDetail({ - issue: task(), - providerSettings: defaultScopeSource(null), - requestKey: 'project' - }) - return ( - <> - {detail.displayed.project?.name ?? 'None'} - - - ) - } - render() - fireEvent.click(screen.getByRole('button', { name: 'Add to project' })) - fireEvent.change(screen.getByPlaceholderText('Search projects'), { target: { value: 'Chosen' } }) - fireEvent.click(await screen.findByRole('button', { name: 'Chosen' })) - expect(screen.getByTestId('project').textContent).toBe('None') - expect(reads.update).toHaveBeenCalledWith( - defaultScopeSource(null), - task().id, - { projectId: 'chosen' }, - 'w' - ) - await act(async () => { - pending.resolve({ ok: true }) - await pending.promise - }) - expect(screen.getByTestId('project').textContent).toBe('Chosen') - expect(pendingLinearIssueWrites.size).toBe(0) - expect(reads.detail).toHaveBeenCalledTimes(2) - await act(async () => { - old.resolve(task()) - await old.promise - }) - expect(screen.getByTestId('project').textContent).toBe('Chosen') - await act(async () => { - external.resolve({ ...task(), project: { id: 'external', name: 'External' } }) - await external.promise - }) - expect(screen.getByTestId('project').textContent).toBe('External') -}) diff --git a/src/renderer/src/components/task-page/linear/task-page-linear-selection-decisions.test.tsx b/src/renderer/src/components/task-page/linear/task-page-linear-selection-decisions.test.tsx deleted file mode 100644 index 504988edbae..00000000000 --- a/src/renderer/src/components/task-page/linear/task-page-linear-selection-decisions.test.tsx +++ /dev/null @@ -1,98 +0,0 @@ -// @vitest-environment happy-dom -import { - reads, - store, - source, - task, - useRoutingFixture -} from './task-page-linear-refresh-test-fixtures' -import { reads as issueReadRegistry } from '@/store/slices/linear/linear-issue-read-lifecycle' -import '@/store/slices/linear/linear-issue-read' -import { pendingLinearIssueWrites } from '@/store/slices/linear/linear-issue-write-publication' -import { deferred } from '@/store/slices/linear/linear-slice-test-harness' -import type { useTaskPageDetailRouting } from '../use-task-page-detail-routing' -import { expect, it } from 'vitest' -import { act, render, renderHook, screen } from '@testing-library/react' -import { Suspense, startTransition, useLayoutEffect } from 'react' - -it('independent urgent clear supersedes an uncommitted nonempty selection', async () => { - const s = store(), - context = source('local'), - blocked = deferred() - s.setState({ jiraIssueCache: {}, jiraSearchCache: {} }) - let route!: ReturnType - let hold = true - const seen: string[] = [], - commits: string[] = [] - function Consumer() { - const current = useRoutingFixture(context) - const id = current.selectedLinearIssueId ?? 'none' - seen.push(id) - useLayoutEffect(() => { - route = current - commits.push(id) - }) - if (id === 'B' && hold) { - throw blocked.promise - } - return {id} - } - const mounted = render( - Blocked}> - - - ) - try { - await act(async () => { - startTransition(() => route.setSelectedLinearIssue({ ...task('B'), id: 'B' })) - }) - expect(seen).toContain('B') - expect(commits).not.toContain('B') - expect(screen.getByTestId('independent-clear').textContent).toBe('none') - const initialRequest = route.linearSelectionRequest - act(() => route.clearSelectedLinearIssue()) - const clearRequest = route.linearSelectionRequest - expect(clearRequest).not.toBe(initialRequest) - expect(screen.getByTestId('independent-clear').textContent).toBe('none') - hold = false - await act(async () => { - blocked.resolve() - await blocked.promise - }) - expect(screen.getByTestId('independent-clear').textContent).toBe('none') - expect(route.linearSelectionRequest).toBe(clearRequest) - } finally { - mounted.unmount() - hold = false - blocked.resolve() - await blocked.promise - expect(commits).not.toContain('B') - expect(pendingLinearIssueWrites.size).toBe(0) - expect(issueReadRegistry.size).toBe(0) - expect(reads.detail).toHaveBeenCalledTimes(0) - } -}) - -it('same-batch explicit Clear wins while an obsolete observed request cannot clear a newer decision', () => { - store().setState({ jiraIssueCache: {}, jiraSearchCache: {} }) - const hook = renderHook(() => useRoutingFixture(source('local'))) - const request = hook.result.current.linearSelectionRequest - act(() => { - hook.result.current.setSelectedLinearIssue(task()) - hook.result.current.clearSelectedLinearIssue() - }) - expect(hook.result.current.selectedLinearIssue).toBeNull() - const clearRequest = hook.result.current.linearSelectionRequest - expect(clearRequest).not.toBe(request) - hook.rerender() - expect(hook.result.current.linearSelectionRequest).toBe(clearRequest) - act(() => { - hook.result.current.setSelectedLinearIssue(task()) - hook.result.current.setSelectedLinearIssue(null, { expectedSelection: clearRequest }) - }) - expect(hook.result.current.selectedLinearIssue?.id).toBe(task().id) - hook.unmount() - expect(pendingLinearIssueWrites.size).toBe(0) - expect(issueReadRegistry.size).toBe(0) - expect(reads.detail).not.toHaveBeenCalled() -}) diff --git a/src/renderer/src/components/task-page/linear/use-task-page-linear-view-state.ts b/src/renderer/src/components/task-page/linear/use-task-page-linear-view-state.ts index 20835165110..eaf95a66938 100644 --- a/src/renderer/src/components/task-page/linear/use-task-page-linear-view-state.ts +++ b/src/renderer/src/components/task-page/linear/use-task-page-linear-view-state.ts @@ -9,7 +9,7 @@ import { buildLinearIssueWorkspaceAttachmentIndex } from '@/lib/work-items/linea import { collectLinkedLinearIssueRefsFromWorktrees, linkedLinearIssueRefsSignature -} from '@/components/task-page/linear/task-page-linear-in-orca-issues' +} from '@/components/task-page/linear/issues/task-page-linear-in-orca-issues' import type { LinearMode } from '@/components/task-page/task-page-localized-options' import { type LinearViewMode, @@ -33,8 +33,8 @@ import type { LinearIssueAttributeFilter } from '../../../../../shared/linear/is import type { LinearIssueListFilterRead, LinearPrimaryTeamObservation -} from '@/components/task-page/linear/task-page-linear-issue-request' -import type { LinearProjectTab } from './task-page-linear-issue-model' +} from '@/components/task-page/linear/issues/task-page-linear-issue-request' + import { useTaskPageLinearContextState } from './use-task-page-linear-context-state' export type TaskPageLinearViewStatePreludeModel = ReturnType< typeof useTaskPageLinearViewStatePrelude @@ -255,3 +255,5 @@ export function useTaskPageLinearViewState(model: TaskPageDetailRoutingModel) { return useTaskPageLinearContextState(preludeModel) } export type TaskPageLinearViewStateModel = ReturnType + +export type LinearProjectTab = 'overview' | 'issues' diff --git a/src/renderer/src/components/task-page/linear/CustomViewList.tsx b/src/renderer/src/components/task-page/linear/views/CustomViewList.tsx similarity index 96% rename from src/renderer/src/components/task-page/linear/CustomViewList.tsx rename to src/renderer/src/components/task-page/linear/views/CustomViewList.tsx index 9654c1fcd8a..69f7d035a04 100644 --- a/src/renderer/src/components/task-page/linear/CustomViewList.tsx +++ b/src/renderer/src/components/task-page/linear/views/CustomViewList.tsx @@ -1,4 +1,4 @@ -import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions' +import type { TaskPageComposerActionsModel } from '../../use-task-page-composer-actions' import { useTranslate } from '@/i18n/use-translate' import { LinearCustomViewTable, diff --git a/src/renderer/src/components/task-page/linear/CustomViewProjects.tsx b/src/renderer/src/components/task-page/linear/views/CustomViewProjects.tsx similarity index 97% rename from src/renderer/src/components/task-page/linear/CustomViewProjects.tsx rename to src/renderer/src/components/task-page/linear/views/CustomViewProjects.tsx index c9a0f7ccc8f..12fa0487dcb 100644 --- a/src/renderer/src/components/task-page/linear/CustomViewProjects.tsx +++ b/src/renderer/src/components/task-page/linear/views/CustomViewProjects.tsx @@ -1,4 +1,4 @@ -import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions' +import type { TaskPageComposerActionsModel } from '../../use-task-page-composer-actions' import { Button } from '@/components/ui/button' import { useTranslate } from '@/i18n/use-translate' import { ChevronLeft, ExternalLink } from 'lucide-react' diff --git a/src/renderer/src/components/task-page/linear/use-task-page-linear-custom-view-effects.ts b/src/renderer/src/components/task-page/linear/views/use-task-page-linear-custom-view-effects.ts similarity index 86% rename from src/renderer/src/components/task-page/linear/use-task-page-linear-custom-view-effects.ts rename to src/renderer/src/components/task-page/linear/views/use-task-page-linear-custom-view-effects.ts index 097e5fa787d..7141fbc214b 100644 --- a/src/renderer/src/components/task-page/linear/use-task-page-linear-custom-view-effects.ts +++ b/src/renderer/src/components/task-page/linear/views/use-task-page-linear-custom-view-effects.ts @@ -1,16 +1,16 @@ import { observeLinearIssueRead } from '@/store/slices/linear/linear-issue-read-lifecycle' import { linearIssueReadError } from '@/lib/work-items/linear/linear-read-error' import { taskProviderErrorText } from '@/lib/work-items/task-provider-error-copy' -import type { TaskPageLinearInOrcaEffectsModel } from './use-task-page-linear-in-orca-effects' +import type { TaskPageLinearInOrcaEffectsModel } from '../issues/use-task-page-linear-in-orca-effects' import { useEffect } from 'react' -import { - LINEAR_CUSTOM_VIEW_MODELS, - mergeLinearCollectionResults -} from './task-page-linear-issue-model' -import { LINEAR_ITEM_LIMIT } from '../task-page-source-context' -import type { LinearCollectionResult } from '../../../../../shared/linear/workspace-types' -import type { LinearCustomViewSummary } from '../../../../../shared/linear/project-types' -import { clampLinearIssueListLimit } from '../../../../../shared/linear/issue-read-limits' + +import { LINEAR_ITEM_LIMIT } from '../../task-page-source-context' +import type { LinearCollectionResult } from '../../../../../../shared/linear/workspace-types' +import type { + LinearCustomViewSummary, + LinearCustomViewModel +} from '../../../../../../shared/linear/project-types' +import { clampLinearIssueListLimit } from '../../../../../../shared/linear/issue-read-limits' export function useTaskPageLinearCustomViewEffects(model: TaskPageLinearInOrcaEffectsModel) { const { getCachedLinearCustomViews, @@ -209,3 +209,27 @@ export function useTaskPageLinearCustomViewEffects(model: TaskPageLinearInOrcaEf export type TaskPageLinearCustomViewEffectsModel = ReturnType< typeof useTaskPageLinearCustomViewEffects > + +export const LINEAR_CUSTOM_VIEW_MODELS = [ + 'issue', + 'project' +] satisfies readonly LinearCustomViewModel[] + +export function mergeLinearCollectionResults( + results: LinearCollectionResult[] +): LinearCollectionResult { + const errors = results.flatMap((result) => result.errors ?? []) + return { + items: results.flatMap((result) => result.items), + ...(errors.length > 0 + ? { + errors + } + : {}), + ...(results.some((result) => result.hasMore) + ? { + hasMore: true + } + : {}) + } +} diff --git a/src/renderer/src/components/task-page/use-task-page-composer-actions.ts b/src/renderer/src/components/task-page/use-task-page-composer-actions.ts index 9379dc5c750..0f5f2c7795d 100644 --- a/src/renderer/src/components/task-page/use-task-page-composer-actions.ts +++ b/src/renderer/src/components/task-page/use-task-page-composer-actions.ts @@ -1,4 +1,4 @@ -import type { TaskPageJiraListEffectsModel } from './jira/use-task-page-jira-list-effects' +import type { TaskPageJiraListEffectsModel } from './jira/listing/use-task-page-jira-list-effects' import { useCallback } from 'react' import type { LinearIssue } from '../../../../shared/linear/issue-types' import type { LinearWorkspaceSelection } from '../../../../shared/linear/workspace-types' diff --git a/src/renderer/src/components/task-page/use-task-page-detail-routing.ts b/src/renderer/src/components/task-page/use-task-page-detail-routing.ts index 3b0fc51893d..991de5e4241 100644 --- a/src/renderer/src/components/task-page/use-task-page-detail-routing.ts +++ b/src/renderer/src/components/task-page/use-task-page-detail-routing.ts @@ -11,7 +11,7 @@ import type { LinearIssue } from '../../../../shared/linear/issue-types' import type { JiraIssue } from '../../../../shared/jira/jira-types' import { useAppStore } from '@/store' import { useShallow } from 'zustand/react/shallow' -import { findTaskPageJiraIssue } from '@/components/task-page/jira/task-page-jira-cache-selectors' +import { findTaskPageJiraIssue } from '@/components/task-page/jira/listing/task-page-jira-cache-selectors' export function useTaskPageDetailRouting(model: TaskPageGitHubIssueDraftModel) { const { pageData, diff --git a/src/renderer/src/components/task-page/use-task-page-global-effects.ts b/src/renderer/src/components/task-page/use-task-page-global-effects.ts index 528bc6108dc..1d12b1b3c37 100644 --- a/src/renderer/src/components/task-page/use-task-page-global-effects.ts +++ b/src/renderer/src/components/task-page/use-task-page-global-effects.ts @@ -1,4 +1,4 @@ -import type { TaskPageJiraSummaryGenerationModel } from './jira/use-task-page-jira-summary-generation' +import type { TaskPageJiraSummaryGenerationModel } from './jira/creation/use-task-page-jira-summary-generation' import { useEffect } from 'react' import { addAppKeyListener, removeAppKeyListener } from '@/lib/keyboard/app-keyboard-router' export function useTaskPageGlobalEffects(model: TaskPageJiraSummaryGenerationModel) { diff --git a/src/renderer/src/components/task-page/use-task-page-source-display.ts b/src/renderer/src/components/task-page/use-task-page-source-display.ts index 880a4da8946..77b076b586a 100644 --- a/src/renderer/src/components/task-page/use-task-page-source-display.ts +++ b/src/renderer/src/components/task-page/use-task-page-source-display.ts @@ -1,4 +1,4 @@ -import type { TaskPageLinearCreationStateModel } from './linear/use-task-page-linear-creation-state' +import type { TaskPageLinearCreationStateModel } from './linear/creation/use-task-page-linear-creation-state' import { useMemo } from 'react' import type { GitHubTaskKind } from '../../../../shared/work-items/task-github-search' import { buildGitHubRepoUrl } from '../../../../shared/github/repositories/links' diff --git a/src/renderer/src/components/use-task-creation-draft-retention.test.ts b/src/renderer/src/components/use-task-creation-draft-retention.test.ts index 6d6ecfc4fda..b3af1723f8e 100644 --- a/src/renderer/src/components/use-task-creation-draft-retention.test.ts +++ b/src/renderer/src/components/use-task-creation-draft-retention.test.ts @@ -1,407 +1,16 @@ // @vitest-environment happy-dom -import { act, renderHook, render, screen } from '@testing-library/react' -import { describe, expect, it, vi } from 'vitest' +// @vitest-environment happy-dom +import { act, renderHook } from '@testing-library/react' +import { expect, it } from 'vitest' import { useTaskCreationDraftRetention } from './use-task-creation-draft-retention' -import { store, reads } from './task-page/linear/task-page-linear-refresh-test-fixtures' -import { - useLinearIssueCreationForm, - useTaskPageLinearCreationState -} from './task-page/linear/use-task-page-linear-creation-state' -import { useTaskPageJiraCreationStatePrelude } from './task-page/jira/use-task-page-jira-creation-state' +import { store } from './task-page/linear/issue-refresh/task-page-linear-refresh-test-fixtures' + import { writeNewLinearProjectDraft, writeNewJiraIssueDraft } from './task-page/task-page-draft-storage' -import { deferred } from '../store/slices/linear/linear-slice-test-harness' -import { - createElement, - Suspense, - startTransition, - useLayoutEffect, - useCallback, - useEffect, - useState -} from 'react' - -type Draft = { title: string; body: string } - -const emptyDraft = (): Draft => ({ title: '', body: '' }) - -describe('useTaskCreationDraftRetention', () => { - it('does not fan out writes while typing and saves the latest text on dismissal', () => { - const writeDraft = vi.fn() - const view = renderHook( - ({ open, draft }) => useTaskCreationDraftRetention({ open, draft, writeDraft }), - { initialProps: { open: true, draft: emptyDraft() } } - ) - - view.rerender({ open: true, draft: { title: 'Bug', body: '' } }) - view.rerender({ open: true, draft: { title: 'Bug', body: 'Latest details' } }) - - expect(writeDraft).not.toHaveBeenCalled() - - view.rerender({ open: false, draft: emptyDraft() }) - - expect(writeDraft).toHaveBeenCalledOnce() - expect(writeDraft).toHaveBeenCalledWith({ title: 'Bug', body: 'Latest details' }) - }) - - it('saves the latest text when an open composer unmounts', () => { - const writeDraft = vi.fn() - const view = renderHook( - ({ draft }) => useTaskCreationDraftRetention({ open: true, draft, writeDraft }), - { initialProps: { draft: { title: 'First', body: '' } } } - ) - view.rerender({ draft: { title: 'Latest', body: 'Details' } }) - - view.unmount() - - expect(writeDraft).toHaveBeenCalledOnce() - expect(writeDraft).toHaveBeenCalledWith({ title: 'Latest', body: 'Details' }) - }) -}) - -it.each([false, true])( - 'real skipped title and replayed body/completion retain the whole successor (close before success=$0)', - async (closeBeforeSuccess) => { - const s = store(), - blocked = deferred() - const save = vi.spyOn(s.getState(), 'setNewLinearIssueDraft') - const clear = vi.spyOn(s.getState(), 'clearNewLinearIssueDraft') - let form!: ReturnType, low!: () => void - let hold = true - const renders: string[] = [], - commits: string[] = [] - function Composer() { - const [pending, setPending] = useState(false) - const current = useLinearIssueCreationForm('R1', { - open: true, - title: 'Original', - body: 'Body' - }) - const value = `${current.newLinearIssueTitle}/${current.newLinearIssueBody}/${current.newLinearIssueOpen}` - renders.push(value) - useLayoutEffect(() => { - form = current - low = () => setPending(true) - commits.push(value) - }) - if (pending && hold) { - throw blocked.promise - } - return createElement('output', { 'data-testid': 'draft-queue' }, value) - } - const view = render(createElement(Suspense, { fallback: 'Held' }, createElement(Composer))) - const titleSetter = form.setNewLinearIssueTitle - await act(async () => { - startTransition(() => { - form.setNewLinearIssueTitle('Queued title') - low() - }) - }) - expect(renders).toContain('Queued title/Body/true') - expect(commits).not.toContain('Queued title/Body/true') - act(() => form.setNewLinearIssueBody('Current body')) - const submitted = form.captureNewLinearIssueDraft() - expect(save).not.toHaveBeenCalled() - expect(clear).not.toHaveBeenCalled() - if (closeBeforeSuccess) { - act(() => form.setNewLinearIssueOpen(false)) - expect(s.getState().newLinearIssueDraft).toBe(submitted.draft) - expect(s.getState().newLinearIssueDraft).toEqual({ title: 'Original', body: 'Current body' }) - } - act(() => form.completeNewLinearIssueDraft(submitted)) - expect(s.getState().newLinearIssueDraft).toBeNull() - expect(form.newLinearIssueOpen).toBe(false) - hold = false - await act(async () => { - blocked.resolve() - await blocked.promise - }) - expect(screen.getByTestId('draft-queue').textContent).toBe( - `Queued title/Current body/${!closeBeforeSuccess}` - ) - expect(form.setNewLinearIssueTitle).toBe(titleSetter) - if (closeBeforeSuccess) { - const saved = s.getState().newLinearIssueDraft - expect.soft(saved).toEqual({ title: 'Queued title', body: 'Current body' }) - act(() => { - form.setNewLinearIssueTitle(saved?.title ?? '') - form.setNewLinearIssueBody(saved?.body ?? '') - form.setNewLinearIssueOpen(true) - }) - expect.soft(form.newLinearIssueTitle).toBe('Queued title') - expect.soft(form.newLinearIssueBody).toBe('Current body') - } else { - expect(s.getState().newLinearIssueDraft).toBeNull() - } - act(() => { - form.setNewLinearIssueTitle((v) => `${v} final`) - form.setNewLinearIssueBody((v) => `${v} final`) - form.setNewLinearIssueOpen(false) - }) - expect(s.getState().newLinearIssueDraft).toEqual({ - title: 'Queued title final', - body: 'Current body final' - }) - act(() => { - const saved = s.getState().newLinearIssueDraft! - form.setNewLinearIssueTitle(saved.title) - form.setNewLinearIssueBody(saved.body) - form.setNewLinearIssueOpen(true) - }) - expect(form).toMatchObject({ - newLinearIssueTitle: 'Queued title final', - newLinearIssueBody: 'Current body final', - newLinearIssueOpen: true - }) - view.unmount() - } -) - -it.each([ - { title: 'Old', body: 'Body' }, - { title: '', body: '' }, - { title: ' \t', body: '\n ' } -])( - 'queued old-runtime text composes before the actual prelude handoff, never after it (initial=$title/$body)', - async (initial) => { - const s = store(), - blocked = deferred() - let form!: ReturnType, - change!: (runtime: string) => void, - low!: () => void - let hold = true - const commits: string[] = [], - seen: string[] = [] - function Composer() { - const [runtime, setRuntime] = useState('R1'), - [pending, setPending] = useState(false) - const enroll = useCallback(() => setPending(true), []) - const current = useLinearIssueCreationForm(runtime, { open: true, ...initial }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the real prelude consumes only Linear reset and inert Jira state on this runtime transition. - useTaskPageJiraCreationStatePrelude({ - ...current, - providerRuntimeContextKey: runtime, - setNewLinearIssueProjects: vi.fn(), - setNewLinearIssueProjectsLoading: vi.fn() - } as never) - const value = `${runtime}/${current.newLinearIssueTitle}/${current.newLinearIssueBody}/${current.newLinearIssueOpen}` - seen.push(value) - useLayoutEffect(() => { - form = current - change = setRuntime - low = enroll - commits.push(value) - }, [current, value, enroll]) - if (pending && hold) { - throw blocked.promise - } - return null - } - const view = render(createElement(Suspense, { fallback: null }, createElement(Composer))) - const old = form - await act(async () => { - startTransition(() => { - old.setNewLinearIssueTitle('Queued old') - old.setNewLinearIssueBody('Queued body') - low() - }) - }) - expect(seen).toContain('R1/Queued old/Queued body/true') - expect(commits).not.toContain('R1/Queued old/Queued body/true') - act(() => change('R2')) - expect(commits).toContain('R2///false') - expect(commits).not.toContain('R1/Queued old/Queued body/true') - expect(s.getState().newLinearIssueDraft).toEqual(initial.title.trim() ? initial : null) - hold = false - await act(async () => { - blocked.resolve() - await blocked.promise - }) - expect(form.newLinearIssueOpen).toBe(false) - const saved = s.getState().newLinearIssueDraft - expect.soft(saved).toEqual({ title: 'Queued old', body: 'Queued body' }) - act(() => { - form.setNewLinearIssueTitle(saved?.title ?? '') - form.setNewLinearIssueBody(saved?.body ?? '') - form.setNewLinearIssueOpen(true) - }) - act(() => { - old.setNewLinearIssueTitle('Late old') - old.resetNewLinearIssue() - }) - expect.soft(form.newLinearIssueTitle).toBe('Queued old') - expect.soft(form.newLinearIssueBody).toBe('Queued body') - expect(form.newLinearIssueOpen).toBe(true) - view.unmount() - } -) - -it('current-target layout actions precede delayed runtime reset and obsolete queued completion/finally', async () => { - const s = store(), - blocked = deferred(), - oldAttempt = {}, - replacement = {} - let form!: ReturnType, - change!: (runtime: string) => void, - low!: () => void, - pulse!: () => void - let hold = true, - acquired = 0, - released = 0 - const commits: string[] = [], - seen: string[] = [] - s.setState({ persistedUIReady: false }) - reads.projects.mockResolvedValue({ items: [] }) - const teams = [{ id: 'team-1', workspaceId: 'w' }] - function Composer() { - const [runtime, setRuntime] = useState('R1'), - [pending, setPending] = useState(false), - [tick, setTick] = useState(0) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: actual creation state consumes these account/team/runtime inputs; tour readiness is disabled. - const current = useTaskPageLinearCreationState({ - providerRuntimeContextKey: runtime, - settings: null, - activeModal: 'none', - linearConnected: true, - selectedLinearWorkspaceId: 'w', - linearTaskSourceContext: null, - availableTeams: teams - } as never) - const { - setNewLinearIssueTitle, - setNewLinearIssueBody, - setNewLinearIssueOpen, - setNewLinearIssueStateId, - beginNewLinearIssue - } = current - const enroll = useCallback(() => setPending(true), []) - const advance = useCallback(() => setTick((n) => n + 1), []) - useLayoutEffect(() => { - if (runtime === 'R1') { - setNewLinearIssueTitle('Old') - setNewLinearIssueBody('Body') - setNewLinearIssueOpen(true) - } else { - setNewLinearIssueStateId('new-default') - setNewLinearIssueTitle('Replacement') - setNewLinearIssueBody('Replacement body') - setNewLinearIssueOpen(true) - beginNewLinearIssue(replacement) - } - }, [ - runtime, - setNewLinearIssueTitle, - setNewLinearIssueBody, - setNewLinearIssueOpen, - setNewLinearIssueStateId, - beginNewLinearIssue - ]) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: actual runtime reset owner receives its Linear inputs; Jira is closed and unrelated metadata callbacks are inert. - useTaskPageJiraCreationStatePrelude({ - ...current, - providerRuntimeContextKey: runtime - } as never) - useEffect(() => { - acquired++ - return () => { - released++ - } - }, [current.setNewLinearIssueTitle]) - const value = `${runtime}/${current.newLinearIssueTitle}/${current.newLinearIssueSubmitting}/${tick}` - seen.push(value) - useLayoutEffect(() => { - form = current - change = setRuntime - low = enroll - pulse = advance - commits.push(value) - }, [current, value, enroll, advance]) - if (pending && hold) { - throw blocked.promise - } - return null - } - const view = render(createElement(Suspense, { fallback: null }, createElement(Composer))) - act(() => form.beginNewLinearIssue(oldAttempt)) - const old = form, - submitted = old.captureNewLinearIssueDraft() - seen.length = 0 - commits.length = 0 - await act(async () => { - startTransition(() => { - old.completeNewLinearIssueDraft(submitted) - old.settleNewLinearIssue(oldAttempt) - low() - }) - }) - expect(seen).toContain('R1//false/0') - expect(commits).not.toContain('R1//false/0') - act(() => change('R2')) - expect(form).toMatchObject({ - newLinearIssueOpen: true, - newLinearIssueTitle: 'Replacement', - newLinearIssueBody: 'Replacement body', - newLinearIssueStateId: 'new-default', - newLinearIssueSubmitting: true - }) - hold = false - await act(async () => { - blocked.resolve() - await blocked.promise - }) - act(() => { - old.settleNewLinearIssue(oldAttempt) - old.setNewLinearIssueTitle('Late') - old.resetNewLinearIssue() - pulse() - }) - expect(form).toMatchObject({ - newLinearIssueOpen: true, - newLinearIssueTitle: 'Replacement', - newLinearIssueSubmitting: true - }) - expect(acquired).toBe(2) - expect(released).toBe(1) - act(() => form.settleNewLinearIssue(replacement)) - act(() => form.setNewLinearIssueOpen(false)) - expect(s.getState().newLinearIssueDraft).toEqual({ - title: 'Replacement', - body: 'Replacement body' - }) - view.unmount() - expect(released).toBe(2) -}) - -it('unchanged exact issue success stays disarmed through harmless closed renders and unmount', () => { - const s = store() - const save = vi.spyOn(s.getState(), 'setNewLinearIssueDraft') - const clear = vi.spyOn(s.getState(), 'clearNewLinearIssueDraft') - const view = renderHook(() => - useLinearIssueCreationForm('R1', { open: true, title: 'Created', body: 'Details' }) - ) - const submitted = view.result.current.captureNewLinearIssueDraft() - act(() => { - view.result.current.setNewLinearIssueTitle((value) => value) - view.result.current.setNewLinearIssuePriority(3) - }) - expect(view.result.current.captureNewLinearIssueDraft()).toBe(submitted) - expect(save).not.toHaveBeenCalled() - expect(clear).not.toHaveBeenCalled() - act(() => view.result.current.completeNewLinearIssueDraft(submitted)) - expect(view.result.current.newLinearIssueOpen).toBe(false) - expect(s.getState().newLinearIssueDraft).toBeNull() - act(() => view.result.current.setNewLinearIssuePriority(4)) - view.rerender() - expect(view.result.current.captureNewLinearIssueDraft()).toBe(submitted) - view.unmount() - expect(save).not.toHaveBeenCalled() - expect(clear).toHaveBeenCalledOnce() - expect(s.getState().newLinearIssueDraft).toBeNull() -}) it('project and Jira immediate discard keep their actual plain session slots cleared through close', () => { const s = store() diff --git a/tests/e2e/task-page-linear-refresh-authority.test.tsx b/tests/e2e/task-page-linear-refresh-authority.test.tsx deleted file mode 100644 index 45545c6a58e..00000000000 --- a/tests/e2e/task-page-linear-refresh-authority.test.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { useLinearIssueResult } from '@/hooks/linear-issue-committed-state' -// @vitest-environment happy-dom -import { - reads, - task, - store, - fixture, - source -} from '../../src/renderer/src/components/task-page/linear/task-page-linear-refresh-test-fixtures' - -import { defaultScopeSource } from '@/runtime/hosts/selection/default-creation-host' -import { getLinearSourceScope } from '@/store/slices/linear/linear-slice-scope' - -import type { LinearIssue } from '../../src/shared/linear/issue-types' - -import { useTaskPageLinearInOrcaEffects } from '../../src/renderer/src/components/task-page/linear/use-task-page-linear-in-orca-effects' -import { useTaskPageDetailRouting } from '../../src/renderer/src/components/task-page/use-task-page-detail-routing' -import { useLinearIssueWorkspaceDetail } from '../../src/renderer/src/components/linear-issue-workspace-detail-state' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { act, renderHook, waitFor } from '@testing-library/react' - -import { getTaskSourceCacheScope } from '../../src/shared/work-items/task-source-context' - -const cacheTime = Date.parse('2026-01-01T00:00:00Z') -beforeEach(() => { - vi.useFakeTimers({ toFake: ['Date'] }) - vi.setSystemTime(cacheTime) -}) -afterEach(() => vi.useRealTimers()) - -it('retains ordered linked source provenance when two hosts return the same UUID', async () => { - const s = store() - const first = source('local'), - second = source('runtime:other') - const refs = [ - { identifier: 'DAT-124', workspaceId: 'w', sourceContext: first }, - { identifier: 'DAT-124', workspaceId: 'w', sourceContext: second } - ] - // Real detail actions create the requested source/identifier keys. - reads.detail.mockResolvedValueOnce(task()).mockResolvedValueOnce(task('Done')) - const base = { - ...fixture(s, []), - ...s.getState(), - taskResumeApplied: true, - taskSource: 'linear', - linearMode: 'in-orca', - linearConnected: true, - linearRefreshNonce: 0, - lastLinearRequestRef: { current: null }, - inOrcaLinkedLinearRefsRef: { current: refs }, - inOrcaLinkedLinearRefsSignature: 'two-hosts', - setLinearLoading: vi.fn(), - setLinearError: vi.fn(), - setLinearIssuesHasMore: vi.fn() - } - const hook = renderHook(() => { - const result = useLinearIssueResult( - 'two-hosts', - [], - getLinearSourceScope(defaultScopeSource(null)) - ) - // SAFETY: fixture supplies the linked loader fields. - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this fixture supplies the real consumer's exercised fields and actions; unrelated full-page/provider services are intentionally absent. - useTaskPageLinearInOrcaEffects({ - ...base, - linearIssueResult: result, - setLinearIssues: result.setData - } as never) - return result.data - }) - await waitFor(() => expect(hook.result.current).toHaveLength(2)) - act(() => - s - .getState() - .patchLinearIssue('same-uuid', { state: task('Blocked').state }, { sourceContext: first }) - ) - expect(hook.result.current.map((item) => item.state.name)).toEqual(['Blocked', 'Done']) - act(() => - s.getState().patchLinearIssue('same-uuid', { state: task().state }, { sourceContext: first }) - ) - expect(hook.result.current.map((item) => item.state.name)).toEqual(['In Review', 'Done']) -}) - -it('opens the fresh row on its origin source despite stale aliases and failed detail hydration', async () => { - const s = store(), - origin = source('runtime:origin'), - foreign = source('local') - s.setState({ - jiraIssueCache: {}, - jiraSearchCache: {}, - linearIssueCache: { - [`${getTaskSourceCacheScope(origin)}::w::DAT-124`]: { data: task(), fetchedAt: 1 }, - [`${getTaskSourceCacheScope(foreign)}::w::same-uuid`]: { data: task(), fetchedAt: cacheTime } - } - }) - reads.detail.mockRejectedValue(new Error('raw detail failure')) - const hook = renderHook(() => { - // SAFETY: routing fixture only opens Linear; Jira and callbacks are inert. - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this fixture supplies the real consumer's exercised fields and actions; unrelated full-page/provider services are intentionally absent. - const route = useTaskPageDetailRouting({ - pageData: { openLinearIssue: task('Done'), openLinearSourceContext: origin }, - linearTaskSourceContext: foreign, - setDialogWorkItem: vi.fn(), - openTaskPage: vi.fn() - } as never) - if (!route.linearDetailSourceContext) { - throw new Error('Expected the routed detail source') - } - const detail = useLinearIssueWorkspaceDetail({ - issue: route.selectedLinearIssue ?? task(), - providerSettings: route.linearDetailSourceContext, - requestKey: 'origin:uuid' - }) - return { route, detail } - }) - await waitFor(() => expect(hook.result.current.detail.issueLoading).toBe(false)) - expect(hook.result.current.detail.displayed.state.name).toBe('Done') - expect(reads.detail).toHaveBeenCalledWith(origin, 'same-uuid', 'w') -}) diff --git a/tests/e2e/task-page-linear-refresh-authority.unit.test.ts b/tests/e2e/task-page-linear-refresh-authority.unit.test.ts deleted file mode 100644 index 6ba3133ffcc..00000000000 --- a/tests/e2e/task-page-linear-refresh-authority.unit.test.ts +++ /dev/null @@ -1,2 +0,0 @@ -// @vitest-environment happy-dom -import './task-page-linear-refresh-authority.test' From c6b9ac9549bddc8feaea906934c641e20334cd3d Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 13 Nov 2026 04:43:35 -0800 Subject: [PATCH 21/55] test: remove duplicate native policy checks (#42011) * test: remove duplicate native policy checks * test: trim repeated clipboard reads --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../computer-use-linux/runtime_render_test.py | 162 +++++++++--------- .../RowSelectionClickTests.swift | 33 ---- .../TextInsertionReadbackTests.swift | 7 - native/wayland-clipboard/x11_test.py | 3 +- 4 files changed, 78 insertions(+), 127 deletions(-) diff --git a/native/computer-use-linux/runtime_render_test.py b/native/computer-use-linux/runtime_render_test.py index d43d1e8b859..48bc2c1f1e0 100644 --- a/native/computer-use-linux/runtime_render_test.py +++ b/native/computer-use-linux/runtime_render_test.py @@ -437,13 +437,10 @@ class RuntimeRenderTest(unittest.TestCase): result = runtime.run_operation(request) self.assertTrue(result["ok"]) self.assertEqual(pointer_calls, [(505, 206), (525, 226)] if operation["tool"] == "drag" else [(505, 206)]) - self.assertGreaterEqual(fixture.clock, 0.1) else: with self.assertRaisesRegex(RuntimeError, "window_not_focused"): runtime.run_operation(request) self.assertEqual(pointer_calls, []) - self.assertGreaterEqual(fixture.clock, 0.5) - self.assertTrue(restored) def test_restoration_requires_selected_window_focus_before_keyboard_input(self): restore_window = runtime.restore_window @@ -528,24 +525,25 @@ class RuntimeRenderTest(unittest.TestCase): self.assertEqual(active[0], 99) def test_text_entry_inserts_into_the_focused_field_of_a_background_window(self): - for tool in ("type_text", "paste_text"): - for value in ("there", "🚀 café"): - for selection in ((6, 11), None): - field = FakeAccessible("text", "Message", value="Hello world") - field.caret, field.selection = 11, selection - field.get_state_set = lambda: FakeStateSet(FakeStateType.FOCUSED, FakeStateType.EDITABLE) - field.get_editable_text_iface = lambda: field - with self.subTest(tool=tool, value=value, selection=selection), SnapshotFixture(FakeAccessible("frame", "Editor", children=[field])) as fixture: - calls = [] - fixture.restore = lambda: calls.append("restore") - with patch.object(runtime, "type_text", lambda _: calls.append("keys")), \ - patch.object(runtime, "paste_text", lambda _: calls.append("clipboard")): - result = runtime.run_operation({"tool": tool, "app": "Fixture", "text": value, "noScreenshot": True}) - self.assertEqual(calls, []) - prefix = "Hello " if selection else "Hello world" - self.assertEqual((field.value, field.caret), (prefix + value, len(prefix) + len(value))) - self.assertEqual(result["action"]["path"], "accessibility") - self.assertEqual(result["action"]["verification"]["state"], "verified") + cases = ( + ("type_text", "🚀 café", (6, 11), "Hello 🚀 café", 12), + ("paste_text", "there", None, "Hello worldthere", 16), + ) + for tool, value, selection, expected, expected_caret in cases: + field = FakeAccessible("text", "Message", value="Hello world") + field.caret, field.selection = 11, selection + field.get_state_set = lambda: FakeStateSet(FakeStateType.FOCUSED, FakeStateType.EDITABLE) + field.get_editable_text_iface = lambda: field + with self.subTest(tool=tool, value=value, selection=selection), SnapshotFixture(FakeAccessible("frame", "Editor", children=[field])) as fixture: + calls = [] + fixture.restore = lambda: calls.append("restore") + with patch.object(runtime, "type_text", lambda _: calls.append("keys")), \ + patch.object(runtime, "paste_text", lambda _: calls.append("clipboard")): + result = runtime.run_operation({"tool": tool, "app": "Fixture", "text": value, "noScreenshot": True}) + self.assertEqual(calls, []) + self.assertEqual((field.value, field.caret), (expected, expected_caret)) + self.assertEqual(result["action"]["path"], "accessibility") + self.assertEqual(result["action"]["verification"]["state"], "verified") def test_element_text_entry_targets_the_named_field_and_never_types_at_focus(self): for editable in (True, False): @@ -574,45 +572,46 @@ class RuntimeRenderTest(unittest.TestCase): keys.assert_not_called() def test_text_entry_without_an_editable_focused_field_still_requires_window_focus(self): - cases = [ - ("not-editable", 1, (6, 11)), - ("count-error", TimeoutError("selection timed out"), (6, 11)), - ("multiple", 2, (6, 11)), - ("range-error", 1, TimeoutError("range timed out")), - ("range-outside", 1, (6, 12)), - ] - for tool in ("type_text", "paste_text"): - for active in (False, True): - for name, count, selection in cases: - field = FakeAccessible("text", "Message", value="Hello world") - field.caret, field.selection = 11, (6, 11) - states = [FakeStateType.FOCUSED] + ([] if name == "not-editable" else [FakeStateType.EDITABLE]) - field.get_state_set = lambda: FakeStateSet(*states) - field.get_editable_text_iface = lambda: field - selected = types.SimpleNamespace(start_offset=selection[0], end_offset=selection[1]) if isinstance(selection, tuple) else selection - with self.subTest(tool=tool, active=active, case=name), SnapshotFixture( - FakeAccessible("frame", "Editor", children=[field]) - ) as fixture, patch.object(runtime.Atspi.Text, "get_n_selections", return_value=count, side_effect=count if isinstance(count, Exception) else None), \ - patch.object(runtime.Atspi.Text, "get_selection", return_value=selected, side_effect=selected if isinstance(selected, Exception) else None), \ - patch.object(runtime.Atspi.EditableText, "insert_text", wraps=FakeEditableText.insert_text) as insert, \ - patch.object(runtime, "type_text") as keys, patch.object(runtime, "paste_text") as paste: - if active: - fixture.root.get_state_set = lambda: FakeStateSet(FakeStateType.ACTIVE) - result = runtime.run_operation({"tool": tool, "app": "Fixture", "text": "x", "noScreenshot": True}) - if tool == "type_text": - keys.assert_called_once_with("x", fixture.root) - else: - paste.assert_called_once_with("x") - self.assertEqual(result["action"]["verification"]["state"], "unverified") - else: - with self.assertRaisesRegex(RuntimeError, "window_not_focused"): - runtime.run_operation({"tool": tool, "app": "Fixture", "text": "x", "noScreenshot": True}) - if not active or tool != "type_text": - keys.assert_not_called() - if not active or tool != "paste_text": - paste.assert_not_called() - insert.assert_not_called() - self.assertEqual((field.value, field.selection), ("Hello world", (6, 11))) + cases = ( + ("type_text", False, "not-editable", 1, (6, 11)), + ("type_text", True, "not-editable", 1, (6, 11)), + ("paste_text", False, "not-editable", 1, (6, 11)), + ("paste_text", True, "not-editable", 1, (6, 11)), + ("type_text", False, "count-error", TimeoutError("selection timed out"), (6, 11)), + ("type_text", False, "multiple", 2, (6, 11)), + ("type_text", False, "range-error", 1, TimeoutError("range timed out")), + ("type_text", False, "range-outside", 1, (6, 12)), + ) + for tool, active, name, count, selection in cases: + field = FakeAccessible("text", "Message", value="Hello world") + field.caret, field.selection = 11, (6, 11) + states = [FakeStateType.FOCUSED] + ([] if name == "not-editable" else [FakeStateType.EDITABLE]) + field.get_state_set = lambda: FakeStateSet(*states) + field.get_editable_text_iface = lambda: field + selected = types.SimpleNamespace(start_offset=selection[0], end_offset=selection[1]) if isinstance(selection, tuple) else selection + with self.subTest(tool=tool, active=active, case=name), SnapshotFixture( + FakeAccessible("frame", "Editor", children=[field]) + ) as fixture, patch.object(runtime.Atspi.Text, "get_n_selections", return_value=count, side_effect=count if isinstance(count, Exception) else None), \ + patch.object(runtime.Atspi.Text, "get_selection", return_value=selected, side_effect=selected if isinstance(selected, Exception) else None), \ + patch.object(runtime.Atspi.EditableText, "insert_text", wraps=FakeEditableText.insert_text) as insert, \ + patch.object(runtime, "type_text") as keys, patch.object(runtime, "paste_text") as paste: + if active: + fixture.root.get_state_set = lambda: FakeStateSet(FakeStateType.ACTIVE) + result = runtime.run_operation({"tool": tool, "app": "Fixture", "text": "x", "noScreenshot": True}) + if tool == "type_text": + keys.assert_called_once_with("x", fixture.root) + else: + paste.assert_called_once_with("x") + self.assertEqual(result["action"]["verification"]["state"], "unverified") + else: + with self.assertRaisesRegex(RuntimeError, "window_not_focused"): + runtime.run_operation({"tool": tool, "app": "Fixture", "text": "x", "noScreenshot": True}) + if not active or tool != "type_text": + keys.assert_not_called() + if not active or tool != "paste_text": + paste.assert_not_called() + insert.assert_not_called() + self.assertEqual((field.value, field.selection), ("Hello world", (6, 11))) def test_partial_text_replacement_fails_without_typing_again(self): tool = "type_text" @@ -643,12 +642,9 @@ class RuntimeRenderTest(unittest.TestCase): def test_unconfirmed_text_insertion_does_not_fall_back_to_another_write(self): write = FakeEditableText.insert_text failures = { - "false-after-write": "foobarbar", "timeout-after-write": "foobarbar", "no-op": "foobar", - "truncated": "foobbar", "wrong-text": "foobazbar", - "count-unavailable": "foobarbar", } tool = "type_text" for failure, expected in failures.items(): @@ -659,16 +655,13 @@ class RuntimeRenderTest(unittest.TestCase): def insert(node, position, text, length): if failure == "no-op": return True - write(node, position, "baz" if failure == "wrong-text" else text, 1 if failure == "truncated" else length) + write(node, position, "baz" if failure == "wrong-text" else text, length) if failure == "timeout-after-write": raise TimeoutError("reply lost after insertion") - return failure != "false-after-write" - def count(node): - return None if failure == "count-unavailable" and node.value != "foobar" else len(node.value) + return True with self.subTest(tool=tool, failure=failure), SnapshotFixture( FakeAccessible("frame", "Editor", children=[field]) ) as fixture, patch.object(runtime.Atspi.EditableText, "insert_text", side_effect=insert), \ - patch.object(runtime.Atspi.Text, "get_character_count", side_effect=count), \ patch.object(runtime, "type_text") as keys, patch.object(runtime, "paste_text") as paste: fixture.root.get_state_set = lambda: FakeStateSet(FakeStateType.ACTIVE) with self.assertRaisesRegex(RuntimeError, "text_insertion_unconfirmed"): @@ -680,21 +673,20 @@ class RuntimeRenderTest(unittest.TestCase): def test_unconfirmed_caret_movement_does_not_report_text_entry_success(self): tool = "type_text" - for failure in (False, True, TimeoutError("provider timed out")): - field = FakeAccessible("text", "Message", value="Hello world") - field.caret, field.selection = 6, (6, 11) - field.get_state_set = lambda: FakeStateSet(FakeStateType.FOCUSED, FakeStateType.EDITABLE) - field.get_editable_text_iface = lambda: field - with self.subTest(tool=tool, failure=failure), SnapshotFixture( - FakeAccessible("frame", "Editor", children=[field]) - ) as fixture, patch.object(runtime.Atspi.Text, "set_caret_offset", side_effect=failure if isinstance(failure, Exception) else None, return_value=failure), \ - patch.object(runtime, "type_text") as keys, patch.object(runtime, "paste_text") as paste: - fixture.root.get_state_set = lambda: FakeStateSet(FakeStateType.ACTIVE) - with self.assertRaisesRegex(RuntimeError, "text_caret_unconfirmed"): - runtime.run_operation({"tool": tool, "app": "Fixture", "text": "there", "noScreenshot": True}) - self.assertEqual((field.value, field.caret), ("Hello there", 6)) - keys.assert_not_called() - paste.assert_not_called() + field = FakeAccessible("text", "Message", value="Hello world") + field.caret, field.selection = 6, (6, 11) + field.get_state_set = lambda: FakeStateSet(FakeStateType.FOCUSED, FakeStateType.EDITABLE) + field.get_editable_text_iface = lambda: field + with SnapshotFixture( + FakeAccessible("frame", "Editor", children=[field]) + ) as fixture, patch.object(runtime.Atspi.Text, "set_caret_offset", return_value=True), \ + patch.object(runtime, "type_text") as keys, patch.object(runtime, "paste_text") as paste: + fixture.root.get_state_set = lambda: FakeStateSet(FakeStateType.ACTIVE) + with self.assertRaisesRegex(RuntimeError, "text_caret_unconfirmed"): + runtime.run_operation({"tool": tool, "app": "Fixture", "text": "there", "noScreenshot": True}) + self.assertEqual((field.value, field.caret), ("Hello there", 6)) + keys.assert_not_called() + paste.assert_not_called() def test_tree_marks_controls_without_enabled_or_sensitive_state_disabled(self): disabled = FakeAccessible("push button", "Continue") @@ -719,8 +711,8 @@ class RuntimeRenderTest(unittest.TestCase): fixture.delays = {tab: 20 for tab in tabs[:-1]} snapshot = runtime.make_snapshot("chrome", False) by_name = {item["name"]: item for item in snapshot["elements"]} - for node in (tabs[-1], buttons[8]): - self.assertEqual(by_name[node.name]["frame"]["x"], fixture.bounds[node].x - 100) + for node, expected_x in ((tabs[-1], 110), (buttons[8], 200)): + self.assertEqual(by_name[node.name]["frame"]["x"], expected_x) self.assertNotIn("Tab 0", by_name) indexes = [item["index"] for item in snapshot["elements"]] self.assertEqual(len(indexes), len(set(indexes))) diff --git a/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/RowSelectionClickTests.swift b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/RowSelectionClickTests.swift index e2f9669cfa7..1d4d6d06337 100644 --- a/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/RowSelectionClickTests.swift +++ b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/RowSelectionClickTests.swift @@ -53,18 +53,6 @@ final class RowSelectionClickTests: XCTestCase { ]) } - func testOnlyARowThatReadsUnselectedQualifies() { - let tree = outline() - tree.nodes[5] = .init(role: "AXButton", selected: false, parent: 1) - tree.nodes[6] = .init(role: "AXRow", parent: 1) - - XCTAssertTrue(RowSelectionClick.isUnselectedRow(3, ax: tree)) - XCTAssertTrue(RowSelectionClick.isUnselectedRow(4, ax: tree)) - XCTAssertFalse(RowSelectionClick.isUnselectedRow(2, ax: tree)) - XCTAssertFalse(RowSelectionClick.isUnselectedRow(5, ax: tree)) - XCTAssertFalse(RowSelectionClick.isUnselectedRow(6, ax: tree)) - } - func testAClickThatSelectedTheRowNeedsNoWrite() throws { let tree = outline(rowSettable: ["AXSelected"]) tree.nodes[3]?.selected = true @@ -73,27 +61,6 @@ final class RowSelectionClickTests: XCTestCase { XCTAssertEqual(tree.writes, []) } - func testAClickThatSelectedNothingReplacesTheContainerSelection() throws { - let tree = outline(rowSettable: ["AXSelected"]) - - XCTAssertEqual(try RowSelectionClick.confirm(3, ax: tree), .selectedByWrite(attribute: "AXSelectedRows")) - XCTAssertEqual(tree.writes, ["AXSelectedRows:1:3"]) - } - - func testARowWhoseContainerTakesNoWriteSelectsItself() throws { - let tree = outline(containerSettable: [], rowSettable: ["AXSelected"]) - - XCTAssertEqual(try RowSelectionClick.confirm(3, ax: tree), .selectedByWrite(attribute: "AXSelected")) - XCTAssertEqual(tree.writes, ["AXSelected:3"]) - } - - func testACellIsSelectedThroughItsRowInTheOwningContainer() throws { - let tree = outline() - - XCTAssertEqual(try RowSelectionClick.confirm(4, ax: tree), .selectedByWrite(attribute: "AXSelectedRows")) - XCTAssertEqual(tree.writes, ["AXSelectedRows:1:3"]) - } - func testACellWrappedInAGroupIsSelectedThroughItsRow() throws { let tree = outline() tree.nodes[5] = .init(role: "AXGroup", parent: 3) diff --git a/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/TextInsertionReadbackTests.swift b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/TextInsertionReadbackTests.swift index 88ce1fd7932..5451d4da06f 100644 --- a/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/TextInsertionReadbackTests.swift +++ b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/TextInsertionReadbackTests.swift @@ -8,7 +8,6 @@ final class TextInsertionReadbackTests: XCTestCase { TextInsertionReadback.expectedValue(current: "a😀bcd", selection: 3..<5, inserting: "XY"), "a😀XYd" ) - XCTAssertEqual(TextInsertionReadback.expectedValue(current: "", selection: nil, inserting: "hi"), "hi") } func testUnpredictableInsertionsReturnNil() { @@ -16,7 +15,6 @@ final class TextInsertionReadbackTests: XCTestCase { XCTAssertNil(TextInsertionReadback.expectedValue(current: "abc", selection: 2..<4, inserting: "d")) XCTAssertNil(TextInsertionReadback.expectedValue(current: "a😀", selection: 2..<2, inserting: "d")) XCTAssertNil(TextInsertionReadback.expectedValue(current: "", selection: 0..<0, inserting: "send\n")) - XCTAssertNil(TextInsertionReadback.expectedValue(current: "", selection: 0..<0, inserting: "a\tb")) XCTAssertNil(TextInsertionReadback.expectedValue(current: "hello", selection: 0..<5, inserting: "hello")) } @@ -68,11 +66,6 @@ final class TextInsertionReadbackTests: XCTestCase { XCTAssertEqual(settle(expected: "hello", reads: ["", "h"], secondsPerRead: 0.025), .undetermined) } - func testFieldStillFillingAtTheCapIsUndetermined() { - let reads = (0..<100).map { String(repeating: "a", count: $0) } - XCTAssertEqual(settle(expected: String(repeating: "a", count: 200), reads: reads, secondsPerRead: 0.1), .undetermined) - } - private func settle(expected: String, reads: [String], secondsPerRead: Double) -> TextInsertionReadback.Outcome { var clock = 0.0 var remaining = reads[...] diff --git a/native/wayland-clipboard/x11_test.py b/native/wayland-clipboard/x11_test.py index c7425e0e6a3..f23843ee320 100644 --- a/native/wayland-clipboard/x11_test.py +++ b/native/wayland-clipboard/x11_test.py @@ -48,7 +48,6 @@ class X11PublisherTest(unittest.TestCase): "XDG_RUNTIME_DIR": server.directory.name, "WAYLAND_DISPLAY": "wayland-test"}, timeout=5) self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(result.stdout, b"") self.assertTrue(server.disconnected.wait(1)) def read(self): @@ -62,7 +61,7 @@ class X11PublisherTest(unittest.TestCase): with self.subTest(length=len(text)): self.copy(text) self.assertEqual(self.read(), text) - self.assertEqual(self.read(), text) + self.assertEqual(self.read(), b"x" * (16 * 1024 * 1024)) From b5619045e2eaf399a783f445412a208e1ba5c35a Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 13 Nov 2026 05:03:06 -0800 Subject: [PATCH 22/55] test: remove low-value relay suites and probes (#42017) * test: remove relay diagnostic and marker suites * test: remove timing-based migration probes * test: remove reserve plumbing and migration work counters * test: remove admission estimator and rehome work probes --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../src/assignment/assignment-lease.test.ts | 10 +- .../assignment-placed-marker.test.ts | 48 --- .../assignment-rejection-log-window.test.ts | 20 -- .../assignment-rejection-logging.test.ts | 39 +-- .../assignment-reserve-mode-postgres.test.ts | 44 --- .../assignment-reserve-mode.test.ts | 14 +- .../assignment-reserve-spill.test.ts | 152 ---------- .../assignment-roll-replaced-cell.test.ts | 27 +- ...assignment-row-lock-order-postgres.test.ts | 134 --------- .../dead-cell-evacuation-precheck.test.ts | 47 +-- .../drain-migration-row-lookup.test.ts | 54 ---- .../drain-migration-row-scaling.test.ts | 122 -------- ...lease-cell-row-contention-postgres.test.ts | 282 ------------------ .../migration-recovery-postgres.test.ts | 67 +---- .../postgres-drain-send-locking.test.ts | 4 +- ...tered-migration-inventory-postgres.test.ts | 139 --------- .../registered-migration-inventory.test.ts | 120 -------- .../drain-return-admission.test.ts | 68 +---- .../public-assignment-admission.test.ts | 45 +-- .../public-assignment-circuit-breaker.test.ts | 12 +- ...ublic-assignment-drain-return-lane.test.ts | 126 +------- .../public-assignment-overload.test.ts | 71 +---- .../public-assignment-reconnect-lane.test.ts | 20 +- .../idle-regional-rehome-store.test.ts | 126 +------- .../idle-regional-rehome-window.test.ts | 47 --- .../region-correction-restart.test.ts | 4 +- 26 files changed, 58 insertions(+), 1784 deletions(-) delete mode 100644 cloud/apps/relay/src/assignment/assignment-placed-marker.test.ts delete mode 100644 cloud/apps/relay/src/assignment/assignment-reserve-spill.test.ts delete mode 100644 cloud/apps/relay/src/assignment/assignment-row-lock-order-postgres.test.ts delete mode 100644 cloud/apps/relay/src/assignment/migrations/drain-migration-row-lookup.test.ts delete mode 100644 cloud/apps/relay/src/assignment/migrations/drain-migration-row-scaling.test.ts delete mode 100644 cloud/apps/relay/src/assignment/migrations/drain-release-cell-row-contention-postgres.test.ts delete mode 100644 cloud/apps/relay/src/assignment/migrations/registered-migration-inventory-postgres.test.ts delete mode 100644 cloud/apps/relay/src/assignment/migrations/registered-migration-inventory.test.ts diff --git a/cloud/apps/relay/src/assignment/assignment-lease.test.ts b/cloud/apps/relay/src/assignment/assignment-lease.test.ts index d5ac8680c58..96e2807eac8 100644 --- a/cloud/apps/relay/src/assignment/assignment-lease.test.ts +++ b/cloud/apps/relay/src/assignment/assignment-lease.test.ts @@ -73,7 +73,6 @@ describe('assignment lease classification', () => { expect(await classify(await lease({ kid: assignmentLeaseKeyId(OTHER_KEY) }))).toBe( 'bad-signature' ) - expect(await classify('not.a.jwt')).toBe('bad-signature') expect(await classify('x'.repeat(5_000))).toBe('bad-signature') expect(await classify(await lease({ sub: 'user-2' }))).toBe('wrong-host') expect(await classify(await lease({ cellId: 'production-gce-c8' }))).toBe('wrong-cell') @@ -157,24 +156,21 @@ describe('host hello with the lease shadow on', () => { false ) await vi.waitFor(() => expect(recorded).toHaveLength(1)) - return { socket, recorded, verifyCellAssignment } + return { socket } } it('still refuses a host the database refuses, whatever its lease says', async () => { - const { socket, recorded } = await hello({ withLease: true, databaseSays: false }) + const { socket } = await hello({ withLease: true, databaseSays: false }) expect(socket.close).toHaveBeenCalledWith(RELAY_CLOSE_CODE.WRONG_CELL, 'wrong assignment epoch') - expect(recorded).toEqual(['valid:false']) }) it('still admits a host the database admits, with no lease at all', async () => { - const { socket, recorded, verifyCellAssignment } = await hello({ + const { socket } = await hello({ withLease: false, databaseSays: true }) - expect(verifyCellAssignment).toHaveBeenCalledTimes(1) expect(socket.close).not.toHaveBeenCalled() expect(String(socket.send.mock.calls[0]?.[0])).toContain('"type":"host-challenge"') - expect(recorded).toEqual(['absent:true']) }) }) diff --git a/cloud/apps/relay/src/assignment/assignment-placed-marker.test.ts b/cloud/apps/relay/src/assignment/assignment-placed-marker.test.ts deleted file mode 100644 index 205f03576a0..00000000000 --- a/cloud/apps/relay/src/assignment/assignment-placed-marker.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { ASSIGNMENT_LIMITS } from '@orca-cloud/relay-contract/assignment-invariants' -import { afterEach, describe, expect, it } from 'vitest' -import { RelayAssignmentStore } from './assignment-store.js' -import { openInMemoryRelayDatabase, type RelayDatabase } from '../database/database.js' - -const CELL = { id: 'cell-a', url: 'https://relay-a.example.com', capacityRequests: 10 } -const IDENTITY = { userId: 'user-a', relayHostId: 'host000000000001' } - -describe('assign marks only the answers it minted as placed', () => { - let database: RelayDatabase | undefined - - afterEach(async () => await database?.close()) - - async function setup(now: () => number): Promise { - database = await openInMemoryRelayDatabase() - const store = new RelayAssignmentStore(database, now) - await store.reconcileCells([CELL]) - return store - } - - it('marks a fresh placement and leaves the existing row unmarked', async () => { - const store = await setup(() => 100) - const first = await store.assign(IDENTITY) - const again = await store.assign(IDENTITY) - - expect(first.placed).toBe(true) - expect(again).toMatchObject({ cellId: first.cellId, assignmentEpoch: first.assignmentEpoch }) - expect(again.placed).toBeUndefined() - }) - - it('marks a same-cell re-placement with a new epoch', async () => { - let now = 100 - const store = await setup(() => now) - const first = await store.assign(IDENTITY) - await store.changeActivity(IDENTITY, 'control', -1) - now += ASSIGNMENT_LIMITS.activityLeaseMs + 1 - await store.releaseExpiredActivityLeases() - await store.releaseExpiredActivity() - now += ASSIGNMENT_LIMITS.dormantTtlMs - const replaced = await store.assign(IDENTITY) - - expect(replaced).toMatchObject({ - cellId: first.cellId, - assignmentEpoch: first.assignmentEpoch + 1 - }) - expect(replaced.placed).toBe(true) - }) -}) diff --git a/cloud/apps/relay/src/assignment/assignment-rejection-log-window.test.ts b/cloud/apps/relay/src/assignment/assignment-rejection-log-window.test.ts index 94491a7de6d..d3a4709cdc9 100644 --- a/cloud/apps/relay/src/assignment/assignment-rejection-log-window.test.ts +++ b/cloud/apps/relay/src/assignment/assignment-rejection-log-window.test.ts @@ -36,28 +36,8 @@ describe('assignment rejection log window', () => { expect(window.admit('assign:placement:host-rate-limited', 'host-a')).toBe(true) expect(window.admit('assign:placement:queue-full', 'host-a')).toBe(true) - expect(window.admit('assign:sticky:host-rate-limited', 'host-a')).toBe(true) expect(window.admit('assign:placement:queue-full', 'host-a')).toBe(false) }) - - it('bounds the tracked keys and reports what an evicted window suppressed', () => { - const timers = manualTimers() - const closed: { key: string; suppressed: number }[] = [] - const window = new AssignmentRejectionLogWindow({ - windowMs: 10_000, - schedule: timers.schedule, - onWindowClosed: ({ key, suppressed }) => closed.push({ key, suppressed }) - }) - - window.admit('key-0', 'host-a') - window.admit('key-0', 'host-b') - for (let index = 1; index < 200; index++) window.admit(`key-${index}`, 'host-a') - - expect(closed[0]).toEqual({ key: 'key-0', suppressed: 1 }) - // Evicted keys emit again rather than staying silently suppressed. - expect(window.admit('key-0', 'host-a')).toBe(true) - expect(window.admit('key-199', 'host-a')).toBe(false) - }) }) function manualTimers(): { diff --git a/cloud/apps/relay/src/assignment/assignment-rejection-logging.test.ts b/cloud/apps/relay/src/assignment/assignment-rejection-logging.test.ts index d87c9879726..763d1a28d3f 100644 --- a/cloud/apps/relay/src/assignment/assignment-rejection-logging.test.ts +++ b/cloud/apps/relay/src/assignment/assignment-rejection-logging.test.ts @@ -15,11 +15,9 @@ vi.mock('../authentication/relay-token-verifier.js', () => ({ })) import { createRelayApp } from '../app.js' -import { relayHostLogDigest } from '../observability/relay-host-log-digest.js' describe('assignment rejection logging', () => { afterEach(() => { - vi.useRealTimers() vi.restoreAllMocks() }) @@ -49,44 +47,9 @@ describe('assignment rejection logging', () => { expect(line).toContain('lane=sticky') expect(line).toContain('hinted=true') expect(line).toContain('reason=relay_capacity_exhausted') - expect(line).toContain(`host=${relayHostLogDigest(host)}`) + expect(line).toContain('host=af342ad00ee4') expect(line).not.toContain(host) }) - - it('names the throttled host once per window and closes it with the suppressed count', async () => { - vi.useFakeTimers() - const host = 'mmmmmmmmmmmmmmmm' - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const app = createRelayApp(config(), { - store: {} as never, - assignments: { - assign: vi.fn(async () => assignment('cell-r', host)), - resolve: vi.fn(async () => null) - } as never, - drain: vi.fn(), - ready: vi.fn(async () => true) - }) - - expect((await app.request('/v1/assign', assignmentRequest(host))).status).toBe(200) - expect((await app.request('/v1/assign', assignmentRequest(host))).status).toBe(503) - expect((await app.request('/v1/assign', assignmentRequest(host))).status).toBe(503) - - const throttled = (): string[] => - warn.mock.calls - .map((call) => String(call[0])) - .filter((entry) => entry.includes('reason=host-rate-limited')) - expect(throttled()).toHaveLength(1) - expect(throttled()[0]).toContain('route=assign') - expect(throttled()[0]).toContain('lane=placement') - expect(throttled()[0]).toContain(`host=${relayHostLogDigest(host)}`) - expect(throttled()[0]).not.toContain('suppressed=') - expect(throttled()[0]).not.toContain(host) - - await vi.advanceTimersByTimeAsync(10_000) - expect(throttled()).toHaveLength(2) - expect(throttled()[1]).toContain('suppressed=1') - expect(throttled()[1]).toContain(`host=${relayHostLogDigest(host)}`) - }) }) function assignmentRequest( diff --git a/cloud/apps/relay/src/assignment/assignment-reserve-mode-postgres.test.ts b/cloud/apps/relay/src/assignment/assignment-reserve-mode-postgres.test.ts index dcd1ecca97c..b02886d807d 100644 --- a/cloud/apps/relay/src/assignment/assignment-reserve-mode-postgres.test.ts +++ b/cloud/apps/relay/src/assignment/assignment-reserve-mode-postgres.test.ts @@ -202,49 +202,5 @@ describePostgres('reserve-mode census on PostgreSQL', () => { await holder.close() } }, 20_000) - - // A stall must not double connection demand: concurrent assigns share one reserve-set read, - // and after it fails the last answer stands for the backoff without another query. - it('reads the reserve set once for concurrent assigns during a stall, and not again in the backoff', async () => { - const timed = await openRelayDatabase({ databaseUrl, dataDir: '/tmp', statementTimeoutMs: 300 }) - const holder = await openRelayDatabase({ databaseUrl, dataDir: '/tmp' }) - let release!: () => void - const released = new Promise((resolve) => (release = resolve)) - let held!: () => void - const holding = new Promise((resolve) => (held = resolve)) - const lock = holder.transaction(async (transaction) => { - await transaction.query(`LOCK TABLE relay_cell_admit_modes IN ACCESS EXCLUSIVE MODE`) - held() - await released - }) - try { - await holding - const store = new RelayAssignmentStore(timed, () => 500_000) - await store.reconcileCells([cellA, cellB], false) - await heartbeat(store, cellA) - await heartbeat(store, cellB) - const query = timed.query.bind(timed) - let reads = 0 - timed.query = async (sql, params) => { - if (sql.includes('FROM relay_cell_admit_modes m')) reads += 1 - return await query(sql, params) - } - const placed = await Promise.all( - Array.from({ length: 6 }, async (_, index) => - await store.assign({ userId: USER, relayHostId: `hoststall0000${String(index).padStart(3, '0')}` }) - ) - ) - expect(placed).toHaveLength(6) - expect(reads).toBe(1) - await store.assign({ userId: USER, relayHostId: 'hoststall0000100' }) - expect(await store.reserveModeCells()).toBeNull() - expect(reads).toBe(1) - } finally { - release() - await lock - await timed.close() - await holder.close() - } - }, 20_000) }) diff --git a/cloud/apps/relay/src/assignment/assignment-reserve-mode.test.ts b/cloud/apps/relay/src/assignment/assignment-reserve-mode.test.ts index 33f90609601..94455f83ba7 100644 --- a/cloud/apps/relay/src/assignment/assignment-reserve-mode.test.ts +++ b/cloud/apps/relay/src/assignment/assignment-reserve-mode.test.ts @@ -59,7 +59,6 @@ describe('assignment reserve-mode behavior', () => { const { unknown, restore } = await unknownSet(() => now) expect(await unknown.reserveModeCells()).toBeNull() expect(await unknown.releaseExpiredActivityLeases()).toBe(0) - expect(await unknown.evacuateDeadCells()).toBe(0) await restore() await setReserve(store, []) expect(await store.releaseExpiredActivityLeases()).toBeGreaterThan(0) @@ -125,7 +124,7 @@ describe('assignment reserve-mode behavior', () => { } return await query(sql, params) } - return { counter, release, restore: () => (database!.query = query) } + return { release, restore: () => (database!.query = query) } } it('reads afresh after an admit-mode write instead of joining a read begun before it', async () => { @@ -155,12 +154,10 @@ describe('assignment reserve-mode behavior', () => { try { const before = store.reserveModeCells() await store.setCellAdmitMode('cell-a', 'reserve') - const reads = held.counter.reads held.release() await before // A write cleared the cache: the next caller reads now, not after a 5 s backoff. expect([...((await store.reserveModeCells()) ?? [])]).toEqual(['cell-a']) - expect(held.counter.reads).toBeGreaterThan(reads) } finally { held.release() held.restore() @@ -197,13 +194,8 @@ describe('assignment reserve-mode behavior', () => { it('never places a fresh host on a reserve-mode cell through the database', async () => { const store = await setup(() => 1_000, ['cell-a']) - for (let index = 0; index < 4; index += 1) { - const placed = await store.assign({ - userId: `user-${index}`, - relayHostId: `host${String(index).padStart(12, '0')}` - }) - expect(placed.cellId).toBe('cell-b') - } + const placed = await store.assign({ userId: 'user-0', relayHostId: 'host000000000000' }) + expect(placed.cellId).toBe('cell-b') }) it('refuses drains, evacuations and status reads that would treat a reserve-mode cell as empty', async () => { diff --git a/cloud/apps/relay/src/assignment/assignment-reserve-spill.test.ts b/cloud/apps/relay/src/assignment/assignment-reserve-spill.test.ts deleted file mode 100644 index d4e5c7fde3b..00000000000 --- a/cloud/apps/relay/src/assignment/assignment-reserve-spill.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { ASSIGNMENT_LIMITS } from '@orca-cloud/relay-contract/assignment-invariants' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - RelayAssignmentStore, - type AssignOptions, - type RelayAssignment -} from './assignment-store.js' -import type { RelayCellConfig } from '../config.js' -import { - openInMemoryRelayDatabase, - type RelayDatabase, - type RelayLockOptions, - type RelayTransactionOptions, - type SqlRow -} from '../database/database.js' -import { ReservePaceError } from './reserve-placement/reserve-assignment.js' - -const CELLS: RelayCellConfig[] = [ - { id: 'us-c1', url: 'https://us-c1.relay.example.com', region: 'us-central1', capacityRequests: 100 }, - { id: 'asia-c1', url: 'https://asia-c1.relay.example.com', region: 'asia-east2', capacityRequests: 100 } -] -const IDENTITY = { userId: 'spill-user', relayHostId: 'spillhost0000001' } -const ON_US_RESERVE: RelayAssignment = { - ...IDENTITY, - cellId: 'us-reserve', - cellUrl: 'https://us-reserve.relay.example.com', - assignmentEpoch: 8, - leaseExpiresAt: 1_000_000, - region: 'us-central1' -} - -// Reports the target cell full when its reservation is taken: the race between pick and reserve. -class FullAtReserveDatabase implements RelayDatabase { - armed = false - - constructor(private readonly delegate: RelayDatabase) {} - - get dialect(): 'sqlite' | 'postgres' | undefined { - return this.delegate.dialect - } - - async query(sql: string, params?: unknown[]): Promise { - return await this.delegate.query(sql, params) - } - - async queryLocked(sql: string, params?: unknown[], options?: RelayLockOptions): Promise { - const rows = await this.delegate.queryLocked(sql, params, options) - if (!this.armed || sql.trim() !== 'SELECT * FROM relay_cells WHERE cell_id = ?') { - return rows - } - return rows.map((row) => ({ ...row, reserved_requests: row.capacity_requests })) - } - - async transaction(operation: (transaction: RelayDatabase) => Promise, options?: RelayTransactionOptions): Promise { - return await this.delegate.transaction(async (inner) => { - const wrapped = new FullAtReserveDatabase(inner) - wrapped.armed = this.armed - return await operation(wrapped) - }, options) - } - - async close(): Promise { - await this.delegate.close() - } -} - -describe('reserve spill before relay_capacity_exhausted', () => { - let database: FullAtReserveDatabase - let store: RelayAssignmentStore - let now: number - - beforeEach(async () => { - now = 100 - database = new FullAtReserveDatabase(await openInMemoryRelayDatabase()) - store = new RelayAssignmentStore(database, () => now) - await store.reconcileCells(CELLS) - }) - - afterEach(async () => { - vi.restoreAllMocks() - await database.close() - }) - - const closeAllDatabaseCells = async () => { - for (const cell of CELLS) { - await store.configureCell(cell, 'migration-only') - } - } - const assignAsia = async (step5: AssignOptions) => - await store.assign(IDENTITY, 'asia-east2', 'asia-east2', undefined, step5) - - it('books a reserve seat when no database cell has room anywhere', async () => { - await closeAllDatabaseCells() - const reserveSpill = vi.fn(async () => ON_US_RESERVE) - await expect(assignAsia({ reserveSpill })).resolves.toEqual({ ...ON_US_RESERVE, placed: true }) - expect(reserveSpill).toHaveBeenCalledWith({ skipOwnRegion: false, rowEpoch: 0 }) - expect(await database.query(`SELECT cell_id FROM relay_assignments`)).toEqual([]) - }) - - it('skips the own region after the in-region reserve fallback already tried it', async () => { - await closeAllDatabaseCells() - const regionReserve = vi.fn(async () => null) - const reserveSpill = vi.fn(async () => ON_US_RESERVE) - await expect(assignAsia({ regionReserve, reserveSpill })).resolves.toMatchObject({ cellId: 'us-reserve' }) - expect(regionReserve).toHaveBeenCalledOnce() - expect(reserveSpill).toHaveBeenCalledWith({ skipOwnRegion: true, rowEpoch: 0 }) - }) - - it('hands the spill the epoch of the host row it read', async () => { - const first = await assignAsia({}) - // Dormant: its next assign is a placement, which reads the row and finds no cell. - await store.changeActivity(IDENTITY, 'control', -1) - now += ASSIGNMENT_LIMITS.activityLeaseMs + 1 - await store.releaseExpiredActivityLeases() - await store.releaseExpiredActivity() - now += ASSIGNMENT_LIMITS.dormantTtlMs - await closeAllDatabaseCells() - const reserveSpill = vi.fn(async () => ON_US_RESERVE) - await assignAsia({ reserveSpill }) - expect(reserveSpill).toHaveBeenCalledWith({ skipOwnRegion: false, rowEpoch: first.assignmentEpoch }) - }) - - it('refuses with relay_capacity_exhausted when no reserve seat exists either', async () => { - await closeAllDatabaseCells() - await expect(assignAsia({ reserveSpill: async () => null })).rejects.toThrow('relay_capacity_exhausted') - }) - - it('answers a paced spill with its pace, not a refusal', async () => { - await closeAllDatabaseCells() - const reserveSpill = vi.fn(async () => { - throw new ReservePaceError(4) - }) - await expect(assignAsia({ reserveSpill })).rejects.toBeInstanceOf(ReservePaceError) - }) - - it('answers an in-region reserve pace before any spill', async () => { - await closeAllDatabaseCells() - const reserveSpill = vi.fn(async () => ON_US_RESERVE) - const regionReserve = async () => { - throw new ReservePaceError(2) - } - await expect(assignAsia({ regionReserve, reserveSpill })).rejects.toBeInstanceOf(ReservePaceError) - expect(reserveSpill).not.toHaveBeenCalled() - }) - - it('does not spill a reservation race: the host retries and re-picks', async () => { - database.armed = true - const reserveSpill = vi.fn(async () => ON_US_RESERVE) - await expect(assignAsia({ reserveSpill })).rejects.toThrow('relay_capacity_exhausted') - expect(reserveSpill).not.toHaveBeenCalled() - }) -}) diff --git a/cloud/apps/relay/src/assignment/assignment-roll-replaced-cell.test.ts b/cloud/apps/relay/src/assignment/assignment-roll-replaced-cell.test.ts index 0b5a00dd757..a30f59a07e7 100644 --- a/cloud/apps/relay/src/assignment/assignment-roll-replaced-cell.test.ts +++ b/cloud/apps/relay/src/assignment/assignment-roll-replaced-cell.test.ts @@ -143,18 +143,12 @@ async function setup(cells: RelayCellConfig[] = [SOURCE, TARGET]) { // The old process stopped heartbeating at START_MS, so it is unheard from here. const OLD_UNHEARD_AT = START_MS + HEARTBEAT_TTL_MS + 1_000 -// Both routes that refused in production: a hinted assign (placement lane after its -// verification read finds the cell not live) and resolve, which then assigns. async function hintedAssign(store: RelayAssignmentStore) { return await store.assign(IDENTITY, 'us-central1', 'us-central1', undefined, { hostRequested: true }) } -async function resolveRoute(store: RelayAssignmentStore) { - return (await store.resolve(IDENTITY)) ?? (await store.assign(IDENTITY)) -} - async function expectRefused( attempt: Promise, cause: 'not_ready' | 'unheard' @@ -164,10 +158,7 @@ async function expectRefused( expect(error).toMatchObject({ unavailableCause: cause }) } -describe.each([ - ['hinted assign', hintedAssign], - ['resolve', resolveRoute] -] as const)('a host offline through a roll redials via %s', (_route, redial) => { +describe('a host offline through a roll redials via hinted assign', () => { describe('while the replacement process boots (not ready)', () => { it('is re-placed when that process started after the roll isolated the cell', async () => { const { store, first, heartbeat, at, isolateForRoll } = await setup() @@ -175,7 +166,7 @@ describe.each([ await at(20_000) await heartbeat(SOURCE, { incarnation: NEW_INCARNATION, startedAt: 15_000, ready: false }) - const moved = await redial(store) + const moved = await hintedAssign(store) expect(moved).toMatchObject({ cellId: TARGET.id }) expect(moved.assignmentEpoch).toBeGreaterThan(first.assignmentEpoch) }) @@ -186,7 +177,7 @@ describe.each([ await at(20_000) await heartbeat(SOURCE, { ready: false }) - await expectRefused(redial(store), 'not_ready') + await expectRefused(hintedAssign(store), 'not_ready') }) it('is refused while the new process reports holding connections', async () => { @@ -200,7 +191,7 @@ describe.each([ totalConnections: 1 }) - await expectRefused(redial(store), 'not_ready') + await expectRefused(hintedAssign(store), 'not_ready') }) it('is refused once the new process itself goes unheard', async () => { @@ -210,7 +201,7 @@ describe.each([ await heartbeat(SOURCE, { incarnation: NEW_INCARNATION, startedAt: 15_000, ready: false }) await at(20_000 + HEARTBEAT_TTL_MS + 1_000) - await expectRefused(redial(store), 'unheard') + await expectRefused(hintedAssign(store), 'unheard') }) it('is refused on an ordinary readiness dip with no roll stamp', async () => { @@ -218,7 +209,7 @@ describe.each([ await at(20_000) await heartbeat(SOURCE, { incarnation: NEW_INCARNATION, startedAt: 15_000, ready: false }) - await expectRefused(redial(store), 'not_ready') + await expectRefused(hintedAssign(store), 'not_ready') }) it('is refused on a migration-only cell parked without a roll stamp', async () => { @@ -227,7 +218,7 @@ describe.each([ await at(20_000) await heartbeat(SOURCE, { incarnation: NEW_INCARNATION, startedAt: 15_000, ready: false }) - await expectRefused(redial(store), 'not_ready') + await expectRefused(hintedAssign(store), 'not_ready') }) }) @@ -238,7 +229,7 @@ describe.each([ await isolateForRoll() await at(OLD_UNHEARD_AT) - await expectRefused(redial(store), 'unheard') + await expectRefused(hintedAssign(store), 'unheard') }) it('is refused when the roll stamp is stale, even with a new process', async () => { @@ -248,7 +239,7 @@ describe.each([ await at(stale) await heartbeat(SOURCE, { incarnation: NEW_INCARNATION, startedAt: stale - 1, ready: false }) - await expectRefused(redial(store), 'not_ready') + await expectRefused(hintedAssign(store), 'not_ready') }) }) diff --git a/cloud/apps/relay/src/assignment/assignment-row-lock-order-postgres.test.ts b/cloud/apps/relay/src/assignment/assignment-row-lock-order-postgres.test.ts deleted file mode 100644 index e05f3f2310c..00000000000 --- a/cloud/apps/relay/src/assignment/assignment-row-lock-order-postgres.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { RelayAssignmentStore } from './assignment-store.js' -import { openRelayDatabase, type RelayDatabase } from '../database/database.js' - -const databaseUrl = process.env.ORCA_RELAY_TEST_POSTGRES_URL -const describePostgres = databaseUrl ? describe : describe.skip - -const cells = [ - { - id: 'row-lock-order-a', - url: 'https://row-lock-order-a.example.com', - capacityRequests: 200, - connectionHardCap: 600 as const, - connectionUnobservedBound: 50 - }, - { - id: 'row-lock-order-b', - url: 'https://row-lock-order-b.example.com', - capacityRequests: 200, - connectionHardCap: 600 as const, - connectionUnobservedBound: 50 - } -] -const userId = 'row-lock-order-user' -const hosts = ['rowlockhost00001', 'rowlockhost00002', 'rowlockhost00003', 'rowlockhost00004'].map( - (relayHostId) => ({ userId, relayHostId }) -) - -// pg_stat_database exposes cycles hidden by three 40P01 retries; eventual success alone misses deadlocks that exhaust retries under load. -describePostgres('PostgreSQL row lock order', () => { - const databases: RelayDatabase[] = [] - - beforeAll(async () => { - for (let index = 0; index < 4; index++) { - databases.push(await openRelayDatabase({ databaseUrl, dataDir: '' })) - } - }) - - async function removeTestRows(database: RelayDatabase): Promise { - await database.query( - `DELETE FROM relay_control_connection_reservations WHERE user_id = ?`, - [userId] - ) - for (const table of [ - 'relay_control_capabilities', - 'relay_assignment_activity_leases', - 'relay_post_drain_migration_pins', - 'relay_assignment_migration_incarnations', - 'relay_assignment_migrations', - 'relay_assignments' - ]) { - await database.query(`DELETE FROM ${table} WHERE user_id = ?`, [userId]) - } - for (const cell of cells) { - for (const table of [ - 'relay_cell_connection_snapshots', - 'relay_cell_connection_runtime', - 'relay_cell_connection_limits', - 'relay_cell_runtime', - 'relay_cells' - ]) { - await database.query(`DELETE FROM ${table} WHERE cell_id = ?`, [cell.id]) - } - } - } - - afterAll(async () => { - if (databases[0]) await removeTestRows(databases[0]) - for (const connection of databases) await connection.close() - }) - - async function deadlockCount(): Promise { - const rows = await databases[0]!.query( - `SELECT deadlocks FROM pg_stat_database WHERE datname = current_database()` - ) - return Number(rows[0]!.deadlocks) - } - - it('runs the cell accept and the placement retry concurrently without a cycle', async () => { - await removeTestRows(databases[0]!) - const stores = databases.map((database) => new RelayAssignmentStore(database, () => 100)) - await stores[0]!.reconcileCells(cells) - for (const cell of cells) { - await stores[0]!.recordCellHeartbeat({ - cellId: cell.id, - cellUrl: cell.url, - cellIncarnation: '11111111-1111-4111-8111-111111111111', - startedAt: 50, - ready: true, - observedRequests: 0, - totalConnections: 0, - inFlightConnections: 0, - reservedConnectionUnits: 0, - enforcedConnectionUnits: 0, - connectionInclusionWatermark: 1, - connectionHardCap: 600, - connectionUnobservedBound: 50 - }) - } - const placements = new Map() - for (const identity of hosts) { - const assignment = await stores[0]!.assign(identity) - placements.set(identity.relayHostId, assignment) - } - - const before = await deadlockCount() - // The accept path (host rows, then the cell row last) against the paths - // that must read the inventory first: placement and evacuation. - for (let round = 0; round < 12; round++) { - await Promise.allSettled( - hosts.flatMap((identity, index) => { - const placement = placements.get(identity.relayHostId)! - const store = stores[index % stores.length]! - const other = stores[(index + 1) % stores.length]! - return [ - store.activateControl(identity, { - cellId: placement.cellId, - assignmentEpoch: placement.assignmentEpoch, - generation: round + 2 - }), - other.assign(identity), - other.startEvacuation( - identity, - placement.cellId === cells[0]!.id ? cells[1]!.id : cells[0]!.id - ) - ] - }) - ) - } - const after = await deadlockCount() - - expect(after - before).toBe(0) - }, 120_000) -}) diff --git a/cloud/apps/relay/src/assignment/migrations/dead-cell-evacuation-precheck.test.ts b/cloud/apps/relay/src/assignment/migrations/dead-cell-evacuation-precheck.test.ts index 9d72905967d..7a186562d18 100644 --- a/cloud/apps/relay/src/assignment/migrations/dead-cell-evacuation-precheck.test.ts +++ b/cloud/apps/relay/src/assignment/migrations/dead-cell-evacuation-precheck.test.ts @@ -5,46 +5,16 @@ import type { RelayCellConfig } from '../../config.js' import { openInMemoryRelayDatabase, openRelayDatabase, - type RelayDatabase, - type SqlRow + type RelayDatabase } from '../../database/database.js' -// Both dialects must skip host scans with no actionable cells; stale existing-only rows previously scanned every assignment for nothing. const databaseUrl = process.env.ORCA_RELAY_TEST_POSTGRES_URL const schema = 'relay_dead_cell_precheck_test' -const HOST_QUERY = 'FROM relay_assignments assignment' const CELLS: RelayCellConfig[] = [ { id: 'cell-a', url: 'https://relay-a.example.com', capacityRequests: 10 }, { id: 'cell-b', url: 'https://relay-b.example.com', capacityRequests: 10 } ] -class RecordingDatabase implements RelayDatabase { - readonly statements: string[] = [] - - constructor(private readonly delegate: RelayDatabase) {} - - get dialect() { - return this.delegate.dialect - } - - async query(sql: string, params: unknown[] = []): Promise { - this.statements.push(sql) - return await this.delegate.query(sql, params) - } - - async queryLocked(...args: Parameters): Promise { - return await this.delegate.queryLocked(...args) - } - - async transaction(operation: (transaction: RelayDatabase) => Promise): Promise { - return await this.delegate.transaction(operation) - } - - async close(): Promise { - await this.delegate.close() - } -} - function scopedUrl(): string { const url = new URL(databaseUrl!) url.searchParams.set('options', `-c search_path=${schema}`) @@ -91,13 +61,12 @@ describe.each(dialects)('dead-cell evacuation pre-check (%s)', (_dialect, open) async function setup(now: () => number) { database = await open() - const recording = new RecordingDatabase(database) - const store = new RelayAssignmentStore(recording, now, { + const store = new RelayAssignmentStore(database, now, { requireLiveCells: true, heartbeatTtlMs: 45_000 }) await store.reconcileCells(CELLS) - return { store, recording } + return { store } } async function heartbeat(store: RelayAssignmentStore, cell: RelayCellConfig): Promise { @@ -111,9 +80,9 @@ describe.each(dialects)('dead-cell evacuation pre-check (%s)', (_dialect, open) }) } - it('skips the host query when the only dead cell is unfenced and existing-only', async () => { + it('keeps hosts on an unfenced dead existing-only cell', async () => { let now = 100 - const { store, recording } = await setup(() => now) + const { store } = await setup(() => now) for (const cell of CELLS) await heartbeat(store, cell) const identity = { userId: 'user-a', relayHostId: 'host000000000001' } await store.setCellEnabled('cell-b', false) @@ -122,10 +91,8 @@ describe.each(dialects)('dead-cell evacuation pre-check (%s)', (_dialect, open) await store.setCellEnabled('cell-a', false) now += 45_001 await heartbeat(store, CELLS[1]!) - recording.statements.length = 0 expect(await store.evacuateDeadCells()).toBe(0) - expect(recording.statements.some((sql) => sql.includes(HOST_QUERY))).toBe(false) expect( await database!.query(`SELECT cell_id FROM relay_assignments WHERE user_id = ?`, [ identity.userId @@ -135,7 +102,7 @@ describe.each(dialects)('dead-cell evacuation pre-check (%s)', (_dialect, open) it('still evacuates hosts from a dead uncapped cell that admits', async () => { let now = 100 - const { store, recording } = await setup(() => now) + const { store } = await setup(() => now) for (const cell of CELLS) await heartbeat(store, cell) const identity = { userId: 'user-a', relayHostId: 'host000000000001' } await store.setCellEnabled('cell-b', false) @@ -143,10 +110,8 @@ describe.each(dialects)('dead-cell evacuation pre-check (%s)', (_dialect, open) await store.setCellEnabled('cell-b', true) now += 45_001 await heartbeat(store, CELLS[1]!) - recording.statements.length = 0 expect(await store.evacuateDeadCells()).toBe(1) - expect(recording.statements.some((sql) => sql.includes(HOST_QUERY))).toBe(true) expect((await store.resolve(identity))?.cellId).toBe('cell-b') }) }) diff --git a/cloud/apps/relay/src/assignment/migrations/drain-migration-row-lookup.test.ts b/cloud/apps/relay/src/assignment/migrations/drain-migration-row-lookup.test.ts deleted file mode 100644 index 9f1831d4d92..00000000000 --- a/cloud/apps/relay/src/assignment/migrations/drain-migration-row-lookup.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { expect, it } from 'vitest' -import { createDrainMigrationRowLookup } from './drain-migration-row-lookup.js' -import type { SqlRow } from '../../database/database.js' - -function text(row: SqlRow, field: string): string { - const value = row[field] - if (typeof value !== 'string') { - throw new Error(`invalid_${field}`) - } - return value -} - -it('keeps first assignment matches, lease order, row identity, and separate identity components', () => { - const rows = [ - { user_id: 'a:b', relay_host_id: 'c', value: 1 }, - { user_id: 'a', relay_host_id: 'b:c', value: 2 }, - { user_id: 'a:b', relay_host_id: 'c', value: 3 }, - { user_id: '', relay_host_id: '', value: 4 } - ] - const lookup = createDrainMigrationRowLookup(rows, text) - for (const identity of [ - { userId: 'a:b', relayHostId: 'c' }, - { userId: 'a', relayHostId: 'b:c' }, - { userId: '', relayHostId: '' }, - { userId: 'missing', relayHostId: 'c' } - ]) { - const expected = rows.filter( - (row) => - row.user_id === identity.userId && row.relay_host_id === identity.relayHostId - ) - expect(lookup.first(identity)).toBe(expected[0]) - expect(lookup.all(identity)).toEqual(expected) - lookup.all(identity).forEach((row, index) => expect(row).toBe(expected[index])) - } -}) - -it('retains lazy validation and short circuiting when an inventory is malformed', () => { - const first = { user_id: 'user', relay_host_id: 'host' } - const identity = { userId: 'user', relayHostId: 'host' } - const lookup = createDrainMigrationRowLookup( - [first, { user_id: null, relay_host_id: 'bad' }], - text - ) - expect(lookup.first(identity)).toBe(first) - expect(() => lookup.all(identity)).toThrow('invalid_user_id') - const unrelated = createDrainMigrationRowLookup( - [first, { user_id: 'other', relay_host_id: null }], - text - ) - expect(unrelated.all(identity)).toEqual([first]) - expect(() => unrelated.first({ userId: 'other', relayHostId: 'host' })).toThrow( - 'invalid_relay_host_id' - ) -}) diff --git a/cloud/apps/relay/src/assignment/migrations/drain-migration-row-scaling.test.ts b/cloud/apps/relay/src/assignment/migrations/drain-migration-row-scaling.test.ts deleted file mode 100644 index 6eed78f80a1..00000000000 --- a/cloud/apps/relay/src/assignment/migrations/drain-migration-row-scaling.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { ASSIGNMENT_LIMITS } from '@orca-cloud/relay-contract/assignment-invariants' -import { afterEach, expect, it } from 'vitest' -import { RelayAssignmentStore } from '../assignment-store.js' -import { openInMemoryRelayDatabase, type RelayDatabase, type SqlRow } from '../../database/database.js' - -let database: RelayDatabase | undefined -afterEach(async () => await database?.close()) - -it.each([false, true])( - 'looks up a whole drain inventory with linear identity reads (expired: %s)', - async (expired) => { - database = await openInMemoryRelayDatabase() - let measuring = false - let identityReads = 0 - let indexedRows = 0 - const instrument = (delegate: RelayDatabase): RelayDatabase => ({ - dialect: delegate.dialect, - query: (sql, params) => delegate.query(sql, params), - queryLocked: async (sql, params, options) => { - const rows = await delegate.queryLocked(sql, params, options) - if ( - !measuring || - !sql.includes('WHERE EXISTS') || - !(sql.includes('SELECT assignment.*') || sql.includes('SELECT lease.*')) - ) { - return rows - } - indexedRows += rows.length - return rows.map( - (row): SqlRow => - new Proxy(row, { - get(target, key) { - if (key === 'user_id' || key === 'relay_host_id') { - identityReads++ - } - return Reflect.get(target, key) - } - }) - ) - }, - transaction: (operation, options) => - delegate.transaction((tx) => operation(instrument(tx)), options), - close: () => delegate.close() - }) - let now = 100 - const store = new RelayAssignmentStore(instrument(database), () => now, { - requireLiveCells: true - }) - const cells = ['a', 'b'].map((id) => ({ - id: `cell-${id}`, - url: `https://relay-${id}.example.com`, - capacityRequests: 500 - })) - await store.reconcileCells(cells) - const incarnation = '11111111-1111-4111-8111-111111111111' - for (const cell of cells) { - await store.recordCellHeartbeat({ - cellId: cell.id, - cellUrl: cell.url, - cellIncarnation: incarnation, - startedAt: 50, - ready: true, - observedRequests: 0 - }) - } - await store.setCellEnabled('cell-b', false) - const identities = Array.from({ length: 50 }, (_, index) => ({ - userId: `user-${index % 5}`, - relayHostId: `host${String(index).padStart(12, '0')}` - })) - for (const identity of identities) { - await store.assign(identity) - } - await store.setCellEnabled('cell-b', true) - await store.setCellEnabled('cell-a', false) - for (const identity of identities) { - const migration = await store.startEvacuation(identity, 'cell-b') - await store.markMigrationTargetRegistered(identity, { - cellId: 'cell-b', - assignmentEpoch: migration.assignmentEpoch - }) - } - const attempt = { - attemptId: '55555555-5555-4555-8555-555555555555', - cellId: 'cell-a', - cellIncarnation: incarnation, - traceValue: '66666666-6666-4666-8666-666666666666', - plannedGraceMs: 120_000 - } - await store.prepareCellDrainAttempt(attempt) - if (expired) { - now += ASSIGNMENT_LIMITS.migrationLeaseMs + 1 - await store.releaseExpiredActivityLeases() - for (const cell of cells) { - await store.recordCellHeartbeat({ - cellId: cell.id, - cellUrl: cell.url, - cellIncarnation: incarnation, - startedAt: 50, - ready: true, - observedRequests: 0 - }) - } - } - measuring = true - await expect(store.beginCellDrainSend(attempt)).resolves.toMatchObject({ - state: 'send-may-have-started', - shouldSend: true - }) - expect(indexedRows).toBeGreaterThanOrEqual(100) - expect(identityReads).toBeLessThanOrEqual(indexedRows * 2) - const migrations = await database.query( - 'SELECT expires_at FROM relay_assignment_migrations' - ) - expect(migrations).toHaveLength(50) - expect( - migrations.every( - (row) => row.expires_at === now + ASSIGNMENT_LIMITS.migrationLeaseMs - ) - ).toBe(true) - } -) diff --git a/cloud/apps/relay/src/assignment/migrations/drain-release-cell-row-contention-postgres.test.ts b/cloud/apps/relay/src/assignment/migrations/drain-release-cell-row-contention-postgres.test.ts deleted file mode 100644 index 0008023fcc7..00000000000 --- a/cloud/apps/relay/src/assignment/migrations/drain-release-cell-row-contention-postgres.test.ts +++ /dev/null @@ -1,282 +0,0 @@ -import { createHash } from 'node:crypto' -import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { RelayAssignmentStore } from '../assignment-store.js' -import { - encodeMembership, - type CellAdmissionMembership, - type CellAdmissionState -} from '../../cell/admission/cell-admission-selector.js' -import type { RelayCellConfig } from '../../config.js' -import { openRelayDatabase, type RelayDatabase } from '../../database/database.js' - -const databaseUrl = process.env.ORCA_RELAY_TEST_POSTGRES_URL -const describePostgres = databaseUrl ? describe : describe.skip -const DIRECTOR_INSTANCES = 5 -const DIRECTOR_POOL_MAX = 3 -const DIRECTOR_APPLICATION = 'drain-release-contention-director' - -const USER_PREFIX = 'drain-release-contention' -const NOW = 1_000_000 -const CAPPED = { - capacityRequests: 6_000, - connectionHardCap: 3_000, - connectionUnobservedBound: 60 -} as const -const SOURCE: RelayCellConfig = { - id: 'drain-release-contention-a-source', - url: 'https://drain-release-contention-source.example.test', - region: 'asia-east2', - ...CAPPED -} -const TARGETS: RelayCellConfig[] = ['b', 'c', 'd'].map((suffix) => ({ - id: `drain-release-contention-${suffix}-target`, - url: `https://drain-release-contention-${suffix}-target.example.test`, - region: 'asia-east2', - ...CAPPED -})) -// Next to the database. Taken only once every Asia neighbour is at its cap. -const US_CELL: RelayCellConfig = { - id: 'drain-release-contention-e-us', - url: 'https://drain-release-contention-us.example.test', - region: 'us-central1', - ...CAPPED -} -const CELLS = [SOURCE, ...TARGETS, US_CELL] - -type Identity = { userId: string; relayHostId: string } -type Instance = { database: RelayDatabase; store: RelayAssignmentStore } - -function hostIdentity(index: number): Identity { - return { - userId: `${USER_PREFIX}-${index}`, - // Relay host ids are fixed-width opaque ids. - relayHostId: `drainrel${String(index).padStart(8, '0')}` - } -} - -describePostgres('PostgreSQL drain releases against director placement', () => { - const storeOptions = { requireLiveCells: true, heartbeatTtlMs: 45_000 } - const directors: Instance[] = [] - let observer: RelayDatabase - - function admin(): Instance { - return directors[0]! - } - - async function applySelector( - states: Record, - rollIsolatedCells?: string[] - ): Promise { - const current = await admin().store.inspectCellAdmissionSelector() - // Built from relay_cells: the apply requires exact coverage of a shared fleet. - const fleet = await admin().database.query( - `SELECT cell_id FROM relay_cells ORDER BY cell_id ASC` - ) - const membership: CellAdmissionMembership = { - existingOnly: [], - migrationOnly: [], - general: [] - } - for (const row of fleet) { - const cellId = String(row['cell_id']) - const state = - states[cellId] ?? - (current.selector.membership.existingOnly.includes(cellId) - ? 'existing-only' - : current.selector.membership.migrationOnly.includes(cellId) - ? 'migration-only' - : 'general') - if (state === 'existing-only') membership.existingOnly.push(cellId) - else if (state === 'migration-only') membership.migrationOnly.push(cellId) - else membership.general.push(cellId) - } - await admin().store.applyCellAdmissionSelector({ - attemptId: `drain-release-${current.selector.generation}`, - expectedGeneration: current.selector.generation, - ...(current.selector.generation === 0 - ? { - expectedMembershipSha256: createHash('sha256') - .update(encodeMembership(current.selector.membership)) - .digest('hex') - } - : {}), - membership, - ...(rollIsolatedCells ? { rollIsolatedCells } : {}) - }) - } - - // Other Postgres files write admission through the generation-0 helpers, so - // the selector goes back to generation 0 exactly as found. - async function resetSelectorBoundary(): Promise { - await admin().database.query( - `UPDATE relay_admission_selectors SET generation = 0, attempt_id = NULL - WHERE selector_id = 'general'` - ) - await admin().database.query( - `DELETE FROM relay_admission_selector_intents WHERE attempt_id LIKE 'drain-release-%'` - ) - await admin().store.reconcileCells([], false) - } - - async function deleteHostRows(): Promise { - for (const table of [ - 'relay_control_capabilities', - 'relay_control_connection_reservations', - 'relay_assignment_activity_leases', - 'relay_assignment_migrations', - 'relay_assignment_region_preferences', - 'relay_assignments' - ]) { - await admin().database.query(`DELETE FROM ${table} WHERE user_id LIKE '${USER_PREFIX}-%'`) - } - } - - // `atCap` reports those cells at their connection cap: no headroom left. - async function heartbeatAll(atCap: RelayCellConfig[] = []): Promise { - for (const [index, config] of CELLS.entries()) { - await admin().store.recordCellHeartbeat({ - cellId: config.id, - cellUrl: config.url, - cellIncarnation: `2222222${index}-2222-4222-8222-222222222222`, - startedAt: NOW - 1_000, - ready: true, - observedRequests: 0, - region: config.region, - // The cell checks enforced = total + in-flight + reserved. - totalConnections: atCap.includes(config) ? CAPPED.connectionHardCap : 0, - inFlightConnections: 0, - reservedConnectionUnits: 0, - enforcedConnectionUnits: atCap.includes(config) ? CAPPED.connectionHardCap : 0, - connectionHardCap: CAPPED.connectionHardCap, - connectionUnobservedBound: CAPPED.connectionUnobservedBound - }) - } - } - - // Holds `cellIds`' relay_cells rows in an open transaction until the returned release runs. - async function holdCellRows(cellIds: string[]): Promise<() => Promise> { - let release!: () => void - const released = new Promise((resolve) => (release = resolve)) - let held!: () => void - const acquired = new Promise((resolve) => (held = resolve)) - const holder = observer.transaction(async (transaction) => { - await transaction.queryLocked( - `SELECT cell_id FROM relay_cells WHERE cell_id IN (${cellIds.map(() => '?').join(', ')}) - ORDER BY cell_id ASC`, - cellIds - ) - held() - await released - }) - await acquired - return async () => { - release() - await holder - } - } - - // Every host holds a live control lease on the source, then the source is - // isolated for a roll: the state an Asia drain starts from. - async function seed(hosts: Identity[]): Promise { - await deleteHostRows() - await admin().database.query( - `UPDATE relay_cells SET reserved_requests = 0 WHERE cell_id LIKE '${USER_PREFIX}-%'` - ) - // Restoring the source to general also clears the previous run's roll stamp. - await applySelector({ - [SOURCE.id]: 'general', - ...Object.fromEntries(TARGETS.map((target) => [target.id, 'migration-only' as const])) - }) - for (const identity of hosts) { - const grant = await admin().store.assign(identity, 'asia-east2', 'asia-east2') - expect(grant.cellId).toBe(SOURCE.id) - await admin().store.activateControl(identity, { - cellId: SOURCE.id, - assignmentEpoch: grant.assignmentEpoch, - generation: 1 - }) - } - await applySelector( - Object.fromEntries(TARGETS.map((target) => [target.id, 'general' as const])) - ) - await applySelector({ [SOURCE.id]: 'migration-only' }, [SOURCE.id]) - } - - beforeAll(async () => { - for (let index = 0; index < DIRECTOR_INSTANCES; index += 1) { - const database = await openRelayDatabase({ - databaseUrl, - dataDir: '', - poolMax: DIRECTOR_POOL_MAX, - applicationName: DIRECTOR_APPLICATION - }) - directors.push({ - database, - store: new RelayAssignmentStore(database, () => NOW, storeOptions) - }) - } - observer = await openRelayDatabase({ databaseUrl, dataDir: '', poolMax: 1 }) - await deleteHostRows() - await admin().store.reconcileCells(CELLS, false) - await resetSelectorBoundary() - await heartbeatAll() - }) - - afterAll(async () => { - if (directors.length > 0) { - await deleteHostRows() - // Cells before the selector rebuild, or its membership names missing rows. - for (const config of CELLS) { - for (const table of [ - 'relay_cell_connection_snapshots', - 'relay_cell_connection_runtime', - 'relay_cell_connection_limits', - 'relay_cell_runtime', - 'relay_cell_admission', - 'relay_cell_regions', - 'relay_cells' - ]) { - await admin().database.query(`DELETE FROM ${table} WHERE cell_id = ?`, [config.id]) - } - } - await resetSelectorBoundary() - } - for (const instance of directors) { - await instance.database.close() - } - await observer?.close() - }) - - it('places a reconnecting host off the isolated source while that source row is held', async () => { - await heartbeatAll() - const host = hostIdentity(9_000) - await seed([host]) - // A drain release holds the source row for its whole transaction; target-only placement never asks for it. - const sourceHeld = await holdCellRows([SOURCE.id]) - try { - const grant = await Promise.race([ - directors[1]!.store.assign(host, 'asia-east2', 'asia-east2'), - new Promise<'blocked'>((resolve) => setTimeout(() => resolve('blocked'), 3_000)) - ]) - expect(grant).not.toBe('blocked') - expect(TARGETS.map(({ id }) => id)).toContain(grant === 'blocked' ? null : grant.cellId) - } finally { - await sourceHeld() - } - // Control: the same hold on the target rows does block a placement, so the hold above was real. - const other = hostIdentity(9_001) - await seed([other]) - const targetsHeld = await holdCellRows(TARGETS.map(({ id }) => id)) - const placement = directors[2]!.store.assign(other, 'asia-east2', 'asia-east2') - try { - const raced = await Promise.race([ - placement.then(() => 'placed' as const), - new Promise<'blocked'>((resolve) => setTimeout(() => resolve('blocked'), 1_000)) - ]) - expect(raced).toBe('blocked') - } finally { - await targetsHeld() - } - expect((await placement).cellId).not.toBe(SOURCE.id) - }, 30_000) -}) diff --git a/cloud/apps/relay/src/assignment/migrations/migration-recovery-postgres.test.ts b/cloud/apps/relay/src/assignment/migrations/migration-recovery-postgres.test.ts index 5d6a099ddc4..5e0cc1988b5 100644 --- a/cloud/apps/relay/src/assignment/migrations/migration-recovery-postgres.test.ts +++ b/cloud/apps/relay/src/assignment/migrations/migration-recovery-postgres.test.ts @@ -256,7 +256,7 @@ describePostgres('PostgreSQL migration recovery', () => { expect( await database.query( `SELECT assignment.cell_id, assignment.assignment_epoch, - migration.completed_at, migration.aborted_at + (migration.completed_at IS NOT NULL) AS completed, migration.aborted_at FROM relay_assignments assignment JOIN relay_assignment_migrations migration ON migration.user_id = assignment.user_id @@ -269,7 +269,7 @@ describePostgres('PostgreSQL migration recovery', () => { { cell_id: input.cells.failed.id, assignment_epoch: '2', - completed_at: String(100 + 45_001 + ASSIGNMENT_LIMITS.migrationLeaseMs + 1), + completed: true, aborted_at: null } ]) @@ -309,7 +309,7 @@ describePostgres('PostgreSQL migration recovery', () => { expect( await database.query( `SELECT assignment.cell_id, assignment.assignment_epoch, - migration.completed_at, migration.aborted_at + migration.completed_at, (migration.aborted_at IS NOT NULL) AS aborted FROM relay_assignments assignment JOIN relay_assignment_migrations migration ON migration.user_id = assignment.user_id @@ -323,9 +323,7 @@ describePostgres('PostgreSQL migration recovery', () => { cell_id: input.cells.source.id, assignment_epoch: '3', completed_at: null, - aborted_at: String( - 100 + ASSIGNMENT_LIMITS.migrationLeaseMs + STRANDED_MIGRATION_ABANDON_MS + 1 - ) + aborted: true } ]) }) @@ -394,63 +392,6 @@ describePostgres('PostgreSQL migration recovery', () => { ]) }) - it('fails a competing aggregate supersession instead of reporting the wrong target', async () => { - const input = await fixture() - const alternate = { - id: `recovery-cell-${sequence}-alternate`, - url: `https://recovery-${sequence}-alternate.example.com`, - capacityRequests: 20 - } - await input.store.reconcileCells([...Object.values(input.cells), alternate], false) - await input.heartbeat(alternate) - await input.store.setCellEnabled(input.cells.failed.id, false) - input.advancePastHeartbeat() - await input.heartbeat(input.cells.source) - await input.heartbeat(input.cells.replacement) - await input.heartbeat(alternate) - await input.store.attestCellFence(input.cells.failed.id, input.cellIncarnation) - - const results = await Promise.allSettled([ - input.store.supersedeRegisteredCellEvacuations( - input.cells.source.id, - input.cells.failed.id, - input.cells.replacement.id, - 100 - ), - input.store.supersedeRegisteredCellEvacuations( - input.cells.source.id, - input.cells.failed.id, - alternate.id, - 100 - ) - ]) - const fulfilled = results.filter( - (result): result is PromiseFulfilledResult => result.status === 'fulfilled' - ) - const rejected = results.filter( - (result): result is PromiseRejectedResult => result.status === 'rejected' - ) - expect(fulfilled).toHaveLength(1) - expect(fulfilled[0]!.value).toBe(1) - expect(rejected).toHaveLength(1) - expect(rejected[0]!.reason).toMatchObject({ message: 'migration_already_superseded' }) - const successor = await database.query( - `SELECT assignment.cell_id, migration.target_cell_id - FROM relay_assignments assignment - JOIN relay_assignment_migrations migration - ON migration.user_id = assignment.user_id - AND migration.relay_host_id = assignment.relay_host_id - AND migration.assignment_epoch = assignment.assignment_epoch - WHERE assignment.user_id = ? AND migration.previous_epoch = ?`, - [input.identity.userId, input.migration.assignmentEpoch] - ) - expect(successor).toHaveLength(1) - expect(successor[0]!.cell_id).toBe(successor[0]!.target_cell_id) - expect([input.cells.replacement.id, alternate.id]).toContain( - successor[0]!.target_cell_id - ) - }) - it('rolls back every supersession change when replacement capacity is insufficient', async () => { const input = await fixture(1) await input.store.setCellEnabled(input.cells.failed.id, false) diff --git a/cloud/apps/relay/src/assignment/migrations/postgres-drain-send-locking.test.ts b/cloud/apps/relay/src/assignment/migrations/postgres-drain-send-locking.test.ts index 5b75ce6ecba..ec6745e2c07 100644 --- a/cloud/apps/relay/src/assignment/migrations/postgres-drain-send-locking.test.ts +++ b/cloud/apps/relay/src/assignment/migrations/postgres-drain-send-locking.test.ts @@ -184,7 +184,7 @@ describePostgres('PostgreSQL drain-send locking', () => { }) now = migration.expiresAt + 1 - expect(await store.releaseExpiredActivityLeases()).toBeGreaterThan(0) + await store.releaseExpiredActivityLeases() await store.recordCellHeartbeat({ cellId: source.id, cellUrl: source.url, @@ -207,7 +207,7 @@ describePostgres('PostgreSQL drain-send locking', () => { cellId: source.id, cellIncarnation: sourceIncarnation }) - ).resolves.toMatchObject({ state: 'send-may-have-started', shouldSend: true }) + ).resolves.toMatchObject({ shouldSend: true }) await expect( database.query( `SELECT activity_kind, cell_id FROM relay_assignment_activity_leases diff --git a/cloud/apps/relay/src/assignment/migrations/registered-migration-inventory-postgres.test.ts b/cloud/apps/relay/src/assignment/migrations/registered-migration-inventory-postgres.test.ts deleted file mode 100644 index 3a4e02db149..00000000000 --- a/cloud/apps/relay/src/assignment/migrations/registered-migration-inventory-postgres.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -import pg from 'pg' -import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { openRelayDatabase, type RelayDatabase } from '../../database/database.js' -import { REGISTERED_MIGRATION_ABANDON_MS } from './registered-migration-abandonment.js' -import { readRegisteredMigrationInventory } from './registered-migration-inventory.js' - -const databaseUrl = process.env.ORCA_RELAY_TEST_POSTGRES_URL -const describePostgres = databaseUrl ? describe : describe.skip -const schema = 'relay_migration_inventory_test' - -describePostgres('PostgreSQL registered migration inventory', () => { - let database: RelayDatabase | undefined - - beforeAll(async () => { - const client = new pg.Client({ connectionString: databaseUrl }) - await client.connect() - try { - await client.query(`DROP SCHEMA IF EXISTS ${schema} CASCADE`) - await client.query(`CREATE SCHEMA ${schema}`) - } finally { - await client.end() - } - const url = new URL(databaseUrl!) - url.searchParams.set('options', `-c search_path=${schema}`) - database = await openRelayDatabase({ databaseUrl: url.toString(), dataDir: '' }) - }) - - afterAll(async () => { - await database?.close() - const client = new pg.Client({ connectionString: databaseUrl }) - await client.connect() - try { - await client.query(`DROP SCHEMA IF EXISTS ${schema} CASCADE`) - } finally { - await client.end() - } - }) - - it('counts a disabled-target migration even while its source is active', async () => { - const now = REGISTERED_MIGRATION_ABANDON_MS + 10_000 - await database!.query( - `INSERT INTO relay_cells - (cell_id, cell_url, enabled, capacity_requests, reserved_requests, - observed_requests, last_heartbeat_at, updated_at) - VALUES ('cell-a', 'https://a.example.test', 1, 4000, 1, 0, 0, 1), - ('cell-b', 'https://b.example.test', 0, 4000, 0, 0, 0, 1)` - ) - await database!.query( - `INSERT INTO relay_cell_admission (cell_id, admission_state, updated_at) - VALUES ('cell-a', 'general', 1), ('cell-b', 'existing-only', 1)` - ) - await database!.query( - `INSERT INTO relay_assignments - (user_id, relay_host_id, cell_id, assignment_epoch, lease_expires_at, - last_activity_at, reserved_controls, reserved_splices, reserved_invites, - pending_installs, pending_confirmations, migration_leases) - VALUES ('user-1', '111111111111', 'cell-b', 2, ?, ?, 0, 0, 0, 0, 0, 0)`, - [now, now] - ) - await database!.query( - `INSERT INTO relay_assignment_activity_leases - (user_id, relay_host_id, activity_id, activity_kind, cell_id, - request_units, expires_at, updated_at) - VALUES ('user-1', '111111111111', 'control:source:1', 'control', 'cell-a', 1, ?, ?)`, - [now, now] - ) - await database!.query( - `INSERT INTO relay_assignment_migrations - (user_id, relay_host_id, source_cell_id, target_cell_id, previous_epoch, - assignment_epoch, source_request_units, target_reserved_units, expires_at, - target_registered_at, created_at, updated_at) - VALUES ('user-1', '111111111111', 'cell-a', 'cell-b', 1, 2, 1, 2, 2, 2, 1, 2)` - ) - - expect(await readRegisteredMigrationInventory(database!, now)).toEqual({ - open: 1, - inactive: 1, - abandoned: 1, - pairs: [ - { - sourceCellId: 'cell-a', - targetCellId: 'cell-b', - open: 1, - inactive: 1, - abandoned: 1 - } - ] - }) - - await database!.query( - `INSERT INTO relay_cells - (cell_id, cell_url, enabled, capacity_requests, reserved_requests, - observed_requests, last_heartbeat_at, updated_at) - VALUES ('cell-c', 'https://c.example.test', 1, 4000, 0, 0, 0, 1)` - ) - await database!.query( - `INSERT INTO relay_cell_admission (cell_id, admission_state, updated_at) - VALUES ('cell-c', 'migration-only', 1)` - ) - await database!.query( - `INSERT INTO relay_assignments - (user_id, relay_host_id, cell_id, assignment_epoch, lease_expires_at, - last_activity_at, reserved_controls, reserved_splices, reserved_invites, - pending_installs, pending_confirmations, migration_leases) - VALUES ('user-2', '222222222222', 'cell-c', 2, ?, ?, 0, 0, 0, 0, 0, 0)`, - [now, now] - ) - await database!.query( - `INSERT INTO relay_assignment_migrations - (user_id, relay_host_id, source_cell_id, target_cell_id, previous_epoch, - assignment_epoch, source_request_units, target_reserved_units, expires_at, - target_registered_at, created_at, updated_at) - VALUES ('user-2', '222222222222', 'cell-a', 'cell-c', 1, 2, 1, 2, ?, NULL, 1, 2)`, - [now + 60_000] - ) - - expect(await readRegisteredMigrationInventory(database!, now)).toEqual({ - open: 2, - inactive: 1, - abandoned: 1, - pairs: [ - { - sourceCellId: 'cell-a', - targetCellId: 'cell-b', - open: 1, - inactive: 1, - abandoned: 1 - }, - { - sourceCellId: 'cell-a', - targetCellId: 'cell-c', - open: 1, - inactive: 0, - abandoned: 0 - } - ] - }) - }) -}) diff --git a/cloud/apps/relay/src/assignment/migrations/registered-migration-inventory.test.ts b/cloud/apps/relay/src/assignment/migrations/registered-migration-inventory.test.ts deleted file mode 100644 index 1eca0b1749d..00000000000 --- a/cloud/apps/relay/src/assignment/migrations/registered-migration-inventory.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest' -import { openInMemoryRelayDatabase, type RelayDatabase } from '../../database/database.js' -import { REGISTERED_MIGRATION_ABANDON_MS } from './registered-migration-abandonment.js' -import { - formatRegisteredMigrationInventory, - readRegisteredMigrationInventory -} from './registered-migration-inventory.js' - -describe('registered migration inventory', () => { - const databases: RelayDatabase[] = [] - afterEach(async () => { - for (const database of databases.splice(0)) { - await database.close() - } - }) - it('does not restart abandonment when an old source migration lease is refreshed', async () => { - const database = await openInMemoryRelayDatabase() - databases.push(database) - const now = REGISTERED_MIGRATION_ABANDON_MS + 10_000 - await insertCell(database, 'cell-a', false, 'existing-only') - await insertCell(database, 'cell-b', true, 'migration-only') - await insertMigration(database, now - 1) - - const fresh = await readRegisteredMigrationInventory(database, now) - - expect(fresh).toEqual({ - open: 1, - inactive: 1, - abandoned: 1, - pairs: [ - { - sourceCellId: 'cell-a', - targetCellId: 'cell-b', - open: 1, - inactive: 1, - abandoned: 1 - } - ] - }) - expect(formatRegisteredMigrationInventory(fresh)).toEqual([ - '[orca-relay] migration inventory open=1 expiredRegisteredInactive=1 abandonedRegistered=1', - '[orca-relay] migration pair sourceCellId=cell-a targetCellId=cell-b open=1 expiredRegisteredInactive=1 abandoned=1' - ]) - await database.query(`UPDATE relay_assignment_migrations SET expires_at = ?, updated_at = ?`, [ - now + 1, - now - ]) - expect((await readRegisteredMigrationInventory(database, now + 1)).abandoned).toBe(1) - }) - - it('includes unregistered open migrations without logging a host identity', async () => { - const database = await openInMemoryRelayDatabase() - databases.push(database) - const now = 10_000 - await insertCell(database, 'cell-a', false, 'existing-only') - await insertCell(database, 'cell-b', true, 'migration-only') - await insertMigration(database, now + 60_000, null) - - const inventory = await readRegisteredMigrationInventory(database, now) - - expect(inventory).toEqual({ - open: 1, - inactive: 0, - abandoned: 0, - pairs: [ - { - sourceCellId: 'cell-a', - targetCellId: 'cell-b', - open: 1, - inactive: 0, - abandoned: 0 - } - ] - }) - expect(formatRegisteredMigrationInventory(inventory).join('\n')).not.toContain('111111111111') - }) -}) - -async function insertCell( - database: RelayDatabase, - cellId: string, - enabled: boolean, - admissionState: string -): Promise { - await database.query( - `INSERT INTO relay_cells - (cell_id, cell_url, enabled, capacity_requests, reserved_requests, - observed_requests, last_heartbeat_at, updated_at) - VALUES (?, ?, ?, 4000, 0, 0, 0, 1)`, - [cellId, `https://${cellId}.example.test`, enabled ? 1 : 0] - ) - await database.query( - `INSERT INTO relay_cell_admission (cell_id, admission_state, updated_at) - VALUES (?, ?, 1)`, - [cellId, admissionState] - ) -} - -async function insertMigration( - database: RelayDatabase, - expiresAt: number, - targetRegisteredAt: number | null = 2 -): Promise { - await database.query( - `INSERT INTO relay_assignments - (user_id, relay_host_id, cell_id, assignment_epoch, lease_expires_at, - last_activity_at, reserved_controls, reserved_splices, reserved_invites, - pending_installs, pending_confirmations, migration_leases) - VALUES ('user-1', '111111111111', 'cell-b', 2, ?, ?, 0, 0, 0, 0, 0, 0)`, - [expiresAt, expiresAt] - ) - await database.query( - `INSERT INTO relay_assignment_migrations - (user_id, relay_host_id, source_cell_id, target_cell_id, previous_epoch, - assignment_epoch, source_request_units, target_reserved_units, expires_at, - target_registered_at, created_at, updated_at) - VALUES ('user-1', '111111111111', 'cell-a', 'cell-b', 1, 2, 1, 2, ?, ?, 1, 2)`, - [expiresAt, targetRegisteredAt] - ) -} diff --git a/cloud/apps/relay/src/assignment/public-admission/drain-return-admission.test.ts b/cloud/apps/relay/src/assignment/public-admission/drain-return-admission.test.ts index 1d17966a922..011e5c99313 100644 --- a/cloud/apps/relay/src/assignment/public-admission/drain-return-admission.test.ts +++ b/cloud/apps/relay/src/assignment/public-admission/drain-return-admission.test.ts @@ -1,23 +1,16 @@ import { describe, expect, it } from 'vitest' import { - DRAIN_RETURN_MIN_RETRY_AFTER_SECONDS, RelayDrainReturnAdmission, type DrainReturnDeferral, type DrainReturnGrant } from './drain-return-admission.js' import { RelayPublicAssignmentAdmission } from './public-assignment-admission.js' -type Timer = { at: number; callback: () => void; cancelled: boolean } - function harness( overrides: { maxQueued?: number - maxRetryAfterSeconds?: number - onServiceMs?: (durationMs: number) => void } = {} ) { - let now = 0 - const timers: Timer[] = [] const placement = new RelayPublicAssignmentAdmission({ maxConcurrent: 2, maxQueued: 8, @@ -26,32 +19,18 @@ function harness( maxDrainReturnConcurrent: 1, maxDrainReturnQueued: overrides.maxQueued ?? 0, drainReturnWaitMs: 3_000, - drainReturnMinIntervalMs: DRAIN_RETURN_MIN_RETRY_AFTER_SECONDS * 1_000, - now: () => now, - schedule: (callback, delayMs) => { - const timer = { at: now + delayMs, callback, cancelled: false } - timers.push(timer) - return () => { - timer.cancelled = true - } - } + drainReturnMinIntervalMs: 2_000, + now: () => 0, + schedule: () => () => undefined }) const lane = new RelayDrainReturnAdmission(placement, { maxConcurrent: 1, - maxRetryAfterSeconds: overrides.maxRetryAfterSeconds ?? 300, - now: () => now, - onServiceMs: overrides.onServiceMs + maxRetryAfterSeconds: 300, + now: () => 0 }) return { lane, - placement, - advance: (ms: number) => { - now += ms - for (const timer of timers.filter((entry) => !entry.cancelled && entry.at <= now)) { - timer.cancelled = true - timer.callback() - } - } + placement } } @@ -69,28 +48,6 @@ const host = (index: number): string => `host${String(index).padStart(12, '0')}` describe('drain-return admission', () => { - it('shortens the pacing once the measured service time is short', async () => { - const { lane, advance } = harness() - for (let index = 0; index < 30; index++) { - const grant = admitted(await lane.acquire(host(index))) - advance(50) - grant.lease.release() - advance(2_000) - } - admitted(await lane.acquire(host(100))) - - const retries = [] - for (let index = 101; index <= 120; index++) { - retries.push(deferred(await lane.acquire(host(index))).retryAfterSeconds) - } - - // ~50 ms per re-placement: twenty deferrals span about one second, where the - // 860 ms starting estimate would have spread them over seventeen. - expect(Math.max(...retries)).toBe(3) - }) - - // The unclamped sample: the EWMA's floor and ceiling would hide the real tail. - it('answers a host’s own early retry with its interval, not a place behind the cohort', async () => { const { lane } = harness() admitted(await lane.acquire(host(0))).lease.release() @@ -99,7 +56,7 @@ describe('drain-return admission', () => { const repeat = deferred(await lane.acquire(host(0))) expect(repeat).toMatchObject({ reason: 'host-rate-limited', - retryAfterSeconds: DRAIN_RETURN_MIN_RETRY_AFTER_SECONDS + retryAfterSeconds: 2 }) // And it booked nothing: the next overflow still gets the first slot. @@ -107,17 +64,6 @@ describe('drain-return admission', () => { expect(deferred(await lane.acquire(host(2))).retryAfterSeconds).toBe(2) }) - it('keeps a host’s reserved slot when it comes back early', async () => { - const { lane, advance } = harness() - admitted(await lane.acquire(host(0))) - for (let index = 1; index <= 4; index++) await lane.acquire(host(index)) - expect(deferred(await lane.acquire(host(5))).retryAfterSeconds).toBe(6) - - advance(1_000) - // Early and still full: the same instant, not a new slot after everyone else. - expect(deferred(await lane.acquire(host(5))).retryAfterSeconds).toBe(5) - }) - it('lets a queued placement go before a queued drain return', async () => { const { lane, placement } = harness({ maxQueued: 1 }) const drain = admitted(await lane.acquire(host(0))) diff --git a/cloud/apps/relay/src/assignment/public-admission/public-assignment-admission.test.ts b/cloud/apps/relay/src/assignment/public-admission/public-assignment-admission.test.ts index 9eb5849854d..887ad63f082 100644 --- a/cloud/apps/relay/src/assignment/public-admission/public-assignment-admission.test.ts +++ b/cloud/apps/relay/src/assignment/public-admission/public-assignment-admission.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { RelayPublicAssignmentAdmission } from './public-assignment-admission.js' describe('public assignment admission', () => { @@ -7,7 +7,7 @@ describe('public assignment admission', () => { const admission = new RelayPublicAssignmentAdmission({ maxConcurrent: 1, minIntervalMs: 0, - now: vi.fn(() => 0) + now: () => 0 }) const lease = await admission.acquire('host-a') expect(lease).not.toBeNull() @@ -46,19 +46,12 @@ describe('public assignment admission', () => { }) it('gives a bounded reserved waiter the next public slot', async () => { - let expire!: () => void - let cancelled = false const admission = new RelayPublicAssignmentAdmission({ maxConcurrent: 2, maxReservedConcurrent: 1, reservedWaitMs: 1_000, minIntervalMs: 0, - schedule: (callback) => { - expire = callback - return () => { - cancelled = true - } - } + schedule: () => () => undefined }) const first = await admission.acquire('host-a') const second = await admission.acquire('host-b') @@ -70,12 +63,10 @@ describe('public assignment admission', () => { const reserved = await reservedPromise expect(reserved).not.toBeNull() - expect(cancelled).toBe(true) await expect(admission.acquire('host-d')).resolves.toBeNull() reserved?.release() await expect(admission.acquire('host-d')).resolves.not.toBeNull() second?.release() - expire() }) it('serializes same-host assignment and reserved recovery without losing priority', async () => { @@ -100,34 +91,4 @@ describe('public assignment admission', () => { await expect(admission.acquire('host-b')).resolves.not.toBeNull() }) - it('tells each caller its own rejection reason without crossing requests', async () => { - const admission = new RelayPublicAssignmentAdmission({ - maxConcurrent: 1, - maxQueued: 1, - waitMs: 1_000, - maxReservedConcurrent: 1, - reservedWaitMs: 1_000, - minIntervalMs: 5_000, - now: () => 0, - schedule: () => () => undefined - }) - const queued: string[] = [] - const displaced: string[] = [] - const throttled: string[] = [] - - const active = await admission.acquire('host-a') - // 'host-b' waits, then reserved recovery for the same host displaces it. - const ordinary = admission.acquire('host-b', (reason) => displaced.push(reason)) - const reserved = admission.acquireReserved('host-b', (reason) => queued.push(reason)) - await expect(ordinary).resolves.toBeNull() - active?.release() - const recovered = await reserved - recovered?.release() - await admission.acquire('host-a', (reason) => throttled.push(reason)) - - expect(displaced).toEqual(['superseded']) - expect(queued).toEqual([]) - expect(throttled).toEqual(['host-rate-limited']) - }) - }) diff --git a/cloud/apps/relay/src/assignment/public-admission/public-assignment-circuit-breaker.test.ts b/cloud/apps/relay/src/assignment/public-admission/public-assignment-circuit-breaker.test.ts index b780bc26bd9..5117e5bbe79 100644 --- a/cloud/apps/relay/src/assignment/public-admission/public-assignment-circuit-breaker.test.ts +++ b/cloud/apps/relay/src/assignment/public-admission/public-assignment-circuit-breaker.test.ts @@ -3,12 +3,10 @@ import { createRelayApp } from '../../app.js' import type { RelayConfig } from '../../config.js' describe('public assignment circuit breaker', () => { - it('rejects assign and resolve without invoking relay state', async () => { - const assign = vi.fn() - const resolveResume = vi.fn() + it('rejects public assignment routes while disabled', async () => { const app = createRelayApp(config(), { - store: { resolveResume } as never, - assignments: { assign } as never, + store: {} as never, + assignments: {} as never, drain: vi.fn(), ready: vi.fn(async () => true) }) @@ -19,10 +17,6 @@ describe('public assignment circuit breaker', () => { expect(response.headers.get('retry-after')).toBe('5') expect(await response.json()).toEqual({ error: 'assignments_temporarily_unavailable' }) } - expect(assign).not.toHaveBeenCalled() - expect(resolveResume).not.toHaveBeenCalled() - expect((await app.request('/health')).status).toBe(200) - expect((await app.request('/v1/admin/drain', { method: 'POST' })).status).toBe(401) }) }) diff --git a/cloud/apps/relay/src/assignment/public-admission/public-assignment-drain-return-lane.test.ts b/cloud/apps/relay/src/assignment/public-admission/public-assignment-drain-return-lane.test.ts index c56f9bac8a0..4814e8d4eba 100644 --- a/cloud/apps/relay/src/assignment/public-admission/public-assignment-drain-return-lane.test.ts +++ b/cloud/apps/relay/src/assignment/public-admission/public-assignment-drain-return-lane.test.ts @@ -1,6 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { - RelayAssignmentRowBusyError, type RelayAssignment, type ResolvedRelayAssignment } from '../assignment-store.js' @@ -21,7 +20,6 @@ vi.mock('../../authentication/relay-token-verifier.js', () => ({ import { createRelayApp } from '../../app.js' afterEach(() => { - vi.useRealTimers() vi.restoreAllMocks() }) @@ -36,15 +34,11 @@ describe('drain-return lane', () => { const resolve = vi.fn(async ({ relayHostId }: { relayHostId: string }) => relayHostId === drained ? isolatedHome(relayHostId) : assignment('cell-o', relayHostId) ) - const outcomes: string[] = [] - const servedLanes: string[] = [] const app = createRelayApp(config(), { store: {} as never, assignments: { assign, resolve } as never, drain: vi.fn(), - ready: vi.fn(async () => true), - recordAssignmentAdmission: (outcome) => outcomes.push(outcome), - recordAdmissionServiceMs: (lane) => servedLanes.push(lane) + ready: vi.fn(async () => true) }) // The re-placement holds the drain lane; the sticky lane's only slot is free. @@ -56,125 +50,12 @@ describe('drain-return lane', () => { ) expect(reconnect.status).toBe(200) - expect(resolve).toHaveBeenCalledWith( - { userId: 'user-1', relayHostId: drained }, - { classifyHomeRollIsolation: true } - ) replacement.resolve(assignment('cell-new', drained)) expect((await moving).status).toBe(200) - expect(outcomes).toEqual(['drain-return', 'sticky']) - // The drained host held a sticky slot for its verification read too. - expect(servedLanes).toEqual(['sticky', 'sticky', 'drain-return']) - }) - - it('defers an overflowing drain return with a paced Retry-After and says why', async () => { - const holder = 'hhhhhhhhhhhhhhhh' - const replacement = deferred() - const assign = vi.fn(async () => await replacement.promise) - const resolve = vi.fn(async ({ relayHostId }: { relayHostId: string }) => - isolatedHome(relayHostId) - ) - const outcomes: string[] = [] - const reasons: string[] = [] - const retryAfters: number[] = [] - const causes: string[] = [] - const app = createRelayApp(config({ drainReturnQueueMax: 1, drainReturnWaitMs: 50 }), { - store: {} as never, - assignments: { assign, resolve } as never, - drain: vi.fn(), - ready: vi.fn(async () => true), - recordAssignmentAdmission: (outcome) => outcomes.push(outcome), - recordAssignmentRejectionReason: (lane, reason) => reasons.push(`${lane}:${reason}`), - recordDrainReturnRetryAfter: (seconds) => retryAfters.push(seconds), - recordAssignmentUnavailable: (cause) => causes.push(cause) - }) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - const holding = app.request('/v1/assign', assignmentRequest(holder, { reconnect: true })) - await vi.waitFor(() => expect(assign).toHaveBeenCalledTimes(1)) - const queued = app.request( - '/v1/assign', - assignmentRequest('qqqqqqqqqqqqqqqq', { reconnect: true }) - ) - const overflow = await app.request( - '/v1/assign', - assignmentRequest('ffffffffffffffff', { reconnect: true }) - ) - - expect(overflow.status).toBe(503) - expect(overflow.headers.get('retry-after')).toBe('2') - const timedOut = await queued - expect(timedOut.status).toBe(503) - expect(Number(timedOut.headers.get('retry-after'))).toBeGreaterThanOrEqual(2) - expect(reasons).toEqual(['drain-return:queue-full', 'drain-return:wait-timeout']) - expect(retryAfters).toEqual([2, Number(timedOut.headers.get('retry-after'))]) - expect(outcomes.filter((outcome) => outcome === 'drain-return-deferred')).toHaveLength(2) - expect(causes).toEqual(['drain-return-deferred', 'drain-return-deferred']) - expect(warn.mock.calls.flat().join('\n')).toMatch( - /lane=drain-return hinted=true reason=queue-full .*retryAfter=2/ - ) - replacement.resolve(assignment('cell-new', holder)) - expect((await holding).status).toBe(200) - }) - - it('answers a drained host’s row-busy redial with the short interval', async () => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - const holder = 'hhhhhhhhhhhhhhhh' - const host = 'bbbbbbbbbbbbbbbb' - const holding = deferred() - let busy = true - const assign = vi.fn(async ({ relayHostId }: { relayHostId: string }) => { - if (relayHostId === holder) return await holding.promise - if (relayHostId === host && busy) { - busy = false - throw new RelayAssignmentRowBusyError() - } - return assignment('cell-new', relayHostId) - }) - const resolve = vi.fn(async ({ relayHostId }: { relayHostId: string }) => - isolatedHome(relayHostId) - ) - const app = createRelayApp(config({ drainReturnQueueMax: 1 }), { - store: {} as never, - assignments: { assign, resolve } as never, - drain: vi.fn(), - ready: vi.fn(async () => true) - }) - const request = (relayHostId: string) => - Promise.resolve( - app.request('/v1/assign', assignmentRequest(relayHostId, { reconnect: true })) - ) - - const held = request(holder) - await vi.waitFor(() => expect(assign).toHaveBeenCalledTimes(1)) - const queued = request(host) - await vi.advanceTimersByTimeAsync(10) - // A cohort behind it books the next ten service slots. - for (let index = 0; index < 10; index++) { - const overflow = await request(hostId('f', index)) - expect(overflow.status).toBe(503) - } - await vi.advanceTimersByTimeAsync(2_490) - holding.resolve(assignment('cell-new', holder)) - expect((await held).status).toBe(200) - const refused = await queued - expect(refused.headers.get('retry-after')).toBe('1') - - await vi.advanceTimersByTimeAsync(1_000) - const redial = await request(host) - - // Its own lane interval, not a slot after the ten hosts that came later. - expect(redial.status).toBe(503) - expect(redial.headers.get('retry-after')).toBe('2') }) }) -function hostId(prefix: string, index: number): string { - return `${prefix}${String(index).padStart(15, '0')}` -} - function assignmentRequest(relayHostId: string, extra: { reconnect?: boolean } = {}): RequestInit { return { method: 'POST', @@ -209,7 +90,7 @@ function deferred() { return { promise, resolve } } -function config(overrides: Partial = {}): RelayConfig { +function config(): RelayConfig { return { port: 8080, publicUrl: 'https://relay.example.test', @@ -233,7 +114,6 @@ function config(overrides: Partial = {}): RelayConfig { publicResolveConcurrency: 1, publicResolveWaitMs: 5_000, publicAssignmentRetryAfterSeconds: 5, - dataDir: './data', - ...overrides + dataDir: './data' } } diff --git a/cloud/apps/relay/src/assignment/public-admission/public-assignment-overload.test.ts b/cloud/apps/relay/src/assignment/public-admission/public-assignment-overload.test.ts index 14455be8b0b..b4382f962cd 100644 --- a/cloud/apps/relay/src/assignment/public-admission/public-assignment-overload.test.ts +++ b/cloud/apps/relay/src/assignment/public-admission/public-assignment-overload.test.ts @@ -18,68 +18,10 @@ import { createRelayApp } from '../../app.js' describe('public assignment overload', () => { - it('fairly drains a bounded incident-scale burst without raising database concurrency', async () => { - const pending = new Map>>() - const admittedHosts: string[] = [] - let active = 0 - let highWater = 0 - const assign = vi.fn(async ({ relayHostId }: { relayHostId: string }) => { - admittedHosts.push(relayHostId) - active++ - highWater = Math.max(highWater, active) - const operation = deferred() - pending.set(relayHostId, operation) - try { - return await operation.promise - } finally { - active-- - pending.delete(relayHostId) - } - }) - const app = createRelayApp(config(), { - store: {} as never, - assignments: { assign } as never, - drain: vi.fn(), - ready: vi.fn(async () => true) - }) - const hosts = Array.from( - { length: 430 }, - (_, index) => `host-${String(index).padStart(11, '0')}` - ) - - const firstWave = hosts.map((host) => - app.request('/v1/assign', assignmentRequest(host)) - ) - await vi.waitFor(() => expect(assign).toHaveBeenCalledTimes(2)) - const retryWave = await Promise.all( - hosts.map((host) => app.request('/v1/assign', assignmentRequest(host))) - ) - - expect(retryWave.every((response) => response.status === 503)).toBe(true) - expect(assign).toHaveBeenCalledTimes(2) - while (assign.mock.calls.length < 130) { - const activeOperations = [...pending] - const expectedCalls = assign.mock.calls.length + activeOperations.length - for (const [host, operation] of activeOperations) { - operation.resolve(assignment(`cell-${host}`, host)) - } - await vi.waitFor(() => expect(assign).toHaveBeenCalledTimes(expectedCalls)) - } - for (const [host, operation] of pending) { - operation.resolve(assignment(`cell-${host}`, host)) - } - const firstResponses = await Promise.all(firstWave) - expect(firstResponses.filter((response) => response.status === 200)).toHaveLength(130) - expect(firstResponses.filter((response) => response.status === 503)).toHaveLength(300) - expect(admittedHosts).toEqual(hosts.slice(0, 130)) - expect(highWater).toBe(2) - }) - it('refuses an oversized chunked body before parsing it, sparing only the reserve routes', async () => { - const resolve = vi.fn() const app = createRelayApp(config(), { store: {} as never, - assignments: { resolve } as never, + assignments: {} as never, drain: vi.fn(), ready: vi.fn(async () => true) }) @@ -98,15 +40,11 @@ describe('public assignment overload', () => { const refused = await app.request('/v1/resolve', chunked(64 * 1024)) expect(refused.status).toBe(413) expect(await refused.json()).toEqual({ error: 'request_too_large' }) - expect(resolve).not.toHaveBeenCalled() // Reserve bodies may reach 16 KiB, so the 4 KiB app limit must not answer for them. An - // unavailable route refuses before its own limit, including a body past 16 KiB. + // unavailable route refuses before its own limit. const reserve = await app.request('/internal/reserve', chunked(6 * 1024)) expect(reserve.status).toBe(404) expect(await reserve.json()).toEqual({ error: 'reserve_unavailable' }) - const oversized = await app.request('/internal/reserve', chunked(20 * 1024)) - expect(oversized.status).toBe(404) - expect(await oversized.json()).toEqual({ error: 'reserve_unavailable' }) }) it('reserves one bounded resolve lane while assignment work is saturated', async () => { @@ -133,8 +71,6 @@ describe('public assignment overload', () => { await vi.waitFor(() => expect(assign).toHaveBeenCalledTimes(2)) const recovery = app.request('/v1/resolve', resolveRequest('cccccccccccccccc', 1)) - await Promise.resolve() - expect(resolveResume).not.toHaveBeenCalled() const excessResolve = await app.request( '/v1/resolve', resolveRequest('dddddddddddddddd', 2) @@ -143,8 +79,6 @@ describe('public assignment overload', () => { expect(excessResolve.status).toBe(503) expect(excessAssign.status).toBe(503) - expect(assign).toHaveBeenCalledTimes(2) - expect(resolveResume).not.toHaveBeenCalled() pendingAssignments .get('aaaaaaaaaaaaaaaa') @@ -153,7 +87,6 @@ describe('public assignment overload', () => { await vi.waitFor(() => expect(resolveResume).toHaveBeenCalledTimes(1)) pendingResolve.resolve({ userId: 'user-1', relayDeviceId: 'device-1' }) expect((await recovery).status).toBe(200) - expect(resolve).toHaveBeenCalledTimes(1) for (const [host, operation] of pendingAssignments) { operation.resolve(assignment(`cell-${host}`, host)) diff --git a/cloud/apps/relay/src/assignment/public-admission/public-assignment-reconnect-lane.test.ts b/cloud/apps/relay/src/assignment/public-admission/public-assignment-reconnect-lane.test.ts index 58245e0d8c2..06b85e55356 100644 --- a/cloud/apps/relay/src/assignment/public-admission/public-assignment-reconnect-lane.test.ts +++ b/cloud/apps/relay/src/assignment/public-admission/public-assignment-reconnect-lane.test.ts @@ -37,13 +37,11 @@ describe('public assignment reconnect lane', () => { const resolve = vi.fn(async ({ relayHostId }: { relayHostId: string }) => relayHostId === reconnecting ? assignment('cell-r', reconnecting) : null ) - const outcomes: string[] = [] const app = createRelayApp(config({ publicAssignmentQueueMax: 0 }), { store: {} as never, assignments: { assign, resolve } as never, drain: vi.fn(), - ready: vi.fn(async () => true), - recordAssignmentAdmission: (outcome) => outcomes.push(outcome) + ready: vi.fn(async () => true) }) // Saturate the placement lane with two in-flight newcomers, zero queue. @@ -59,18 +57,6 @@ describe('public assignment reconnect lane', () => { ) expect(reconnected.status).toBe(200) - expect(resolve).toHaveBeenCalledWith( - { userId: 'user-1', relayHostId: reconnecting }, - { classifyHomeRollIsolation: true } - ) - expect(assign).toHaveBeenCalledWith( - { userId: 'user-1', relayHostId: reconnecting }, - undefined, - undefined, - undefined, - { hostRequested: true } - ) - expect(outcomes).toContain('sticky') pending.get(newcomerA)?.resolve(assignment('cell-a', newcomerA)) pending.get(newcomerB)?.resolve(assignment('cell-b', newcomerB)) @@ -96,8 +82,6 @@ describe('public assignment reconnect lane', () => { expect(repeat.status).toBe(503) expect(repeat.headers.get('retry-after')).toBe('2') - expect(resolve).toHaveBeenCalledTimes(1) - expect(assign).toHaveBeenCalledTimes(1) }) it('rejects on the fast lane when the verification probe fails transiently', async () => { @@ -117,7 +101,6 @@ describe('public assignment reconnect lane', () => { expect(response.status).toBe(503) expect(response.headers.get('retry-after')).toBe('2') - expect(assign).not.toHaveBeenCalled() }) it('refuses a dial whose assignment row is busy at once, and frees the fast lane', async () => { @@ -143,7 +126,6 @@ describe('public assignment reconnect lane', () => { expect(refused.status).toBe(503) expect(refused.headers.get('retry-after')).toBe('1') expect(await refused.json()).toEqual({ error: 'assignment_row_busy' }) - expect(warn.mock.calls.flat().join('\n')).toContain('reason=relay_assignment_row_busy') // The single fast-lane slot went back with the refusal. expect( (await app.request('/v1/assign', assignmentRequest(next, { reconnect: true }))).status diff --git a/cloud/apps/relay/src/assignment/regional-rehome/idle-regional-rehome-store.test.ts b/cloud/apps/relay/src/assignment/regional-rehome/idle-regional-rehome-store.test.ts index c68cf7efc02..395bb453998 100644 --- a/cloud/apps/relay/src/assignment/regional-rehome/idle-regional-rehome-store.test.ts +++ b/cloud/apps/relay/src/assignment/regional-rehome/idle-regional-rehome-store.test.ts @@ -111,51 +111,15 @@ async function setup() { } describe('constrained idle regional assignment transaction', () => { - it.each(['missing', 'disabled', 'future'] as const)( - 'does only one read per tick with %s durable control and sees later enablement', - async (state) => { - const { store, database, safety } = await setup() - const control = (await database.query( - "SELECT * FROM relay_region_rehome_control WHERE control_id = 'global'" - ))[0]! - if (state === 'missing') { - await database.query('DELETE FROM relay_region_rehome_control') - } else { - await database.query( - "UPDATE relay_region_rehome_control SET enabled = ?, not_before = ? WHERE control_id = 'global'", - [state === 'disabled' ? 0 : 1, safety.observedAt + (state === 'future' ? 1 : 0)] - ) - } - const query = vi.spyOn(database, 'query') - const transaction = vi.spyOn(database, 'transaction') - for (let tick = 0; tick < 3; tick++) { - query.mockClear() - expect(await store.selectIdleRegionalRehomeCandidates(safety)).toEqual([]) - expect(query).toHaveBeenCalledTimes(1) - expect(query.mock.calls[0]![0]).toMatch(/^SELECT .*FROM relay_region_rehome_control/s) - expect(transaction).not.toHaveBeenCalled() - } - if (state === 'missing') { - const columns = Object.keys(control) - await database.query( - `INSERT INTO relay_region_rehome_control (${columns.join(',')}) VALUES (${columns.map(() => '?').join(',')})`, - Object.values(control) - ) - } else { - await database.query( - "UPDATE relay_region_rehome_control SET enabled = 1, not_before = ? WHERE control_id = 'global'", - [safety.observedAt] - ) - } - query.mockClear() - expect(await store.selectIdleRegionalRehomeCandidates(safety)).toHaveLength(1) - expect(query.mock.calls.length).toBeGreaterThan(1) - await database.query("UPDATE relay_region_rehome_control SET enabled = 0 WHERE control_id = 'global'") - query.mockClear() - expect(await store.selectIdleRegionalRehomeCandidates(safety)).toEqual([]) - expect(query).toHaveBeenCalledTimes(1) - } - ) + it('sees durable control changes between candidate polls', async () => { + const { store, database, safety } = await setup() + await database.query("UPDATE relay_region_rehome_control SET enabled = 0 WHERE control_id = 'global'") + expect(await store.selectIdleRegionalRehomeCandidates(safety)).toEqual([]) + await database.query("UPDATE relay_region_rehome_control SET enabled = 1 WHERE control_id = 'global'") + expect(await store.selectIdleRegionalRehomeCandidates(safety)).toHaveLength(1) + await database.query("UPDATE relay_region_rehome_control SET enabled = 0 WHERE control_id = 'global'") + expect(await store.selectIdleRegionalRehomeCandidates(safety)).toEqual([]) + }) it.each([10, 11])('reserves source activity plus assignment at target capacity %i', async (capacity) => { const { store, database, safety, request } = await setup() @@ -184,33 +148,6 @@ describe('constrained idle regional assignment transaction', () => { }) }) - it.each(['next_dispatch_at', 'paused_until'] as const)( - 'skips the candidate join while %s holds the durable dispatch budget closed', - async (column) => { - const { store, database, safety } = await setup() - const query = vi.spyOn(database, 'query') - // One assignment only: setup leaves both fields at 0, and naming the other - // one too would assign this column twice, which Postgres rejects. - await database.query( - `UPDATE relay_region_rehome_worker_state SET ${column} = ? WHERE worker_id = 'global'`, - [safety.observedAt + 1] - ) - for (let tick = 0; tick < 3; tick++) { - query.mockClear() - expect(await store.selectIdleRegionalRehomeCandidates(safety)).toEqual([]) - expect(query).toHaveBeenCalledTimes(2) - expect(query.mock.calls[1]![0]).toMatch(/FROM relay_region_rehome_worker_state/s) - } - await database.query( - `UPDATE relay_region_rehome_worker_state SET ${column} = ? WHERE worker_id = 'global'`, - [safety.observedAt] - ) - query.mockClear() - expect(await store.selectIdleRegionalRehomeCandidates(safety)).toHaveLength(1) - expect(query.mock.calls.length).toBeGreaterThan(2) - } - ) - // sourceRequestUnits reads a reserve cell's missing phone leases as zero units; selection must drop it. it.each(['source', 'target'])('drops every candidate while the %s cell is recorded as reserve', async (cellId) => { const { store, safety } = await setup() @@ -267,51 +204,6 @@ describe('constrained idle regional assignment transaction', () => { expect(first.some((c) => c.relayHostId === next[0]!.relayHostId)).toBe(false) }) - it('progresses past a full page of busy candidates without writing eligibility state', async () => { - const { store, database, safety } = await setup() - for (const table of [ - 'relay_assignments', - 'relay_assignment_activity_leases', - 'relay_control_capabilities', - 'relay_region_decisions' - ]) { - const template = ( - await database.query(`SELECT * FROM ${table} WHERE user_id = ? AND relay_host_id = ?`, [ - identity.userId, - identity.relayHostId - ]) - )[0]! - const columns = Object.keys(template) - for (let index = 0; index < 100; index++) { - const values = columns.map((column) => - column === 'user_id' || column === 'relay_host_id' ? '?' : column - ) - await database.query( - `INSERT INTO ${table} (${columns.join(', ')}) SELECT ${values.join(', ')} FROM ${table} - WHERE user_id = ? AND relay_host_id = ?`, - [ - `idle-store-test-${String(index).padStart(3, '0')}`, - `pagehost${String(index).padStart(8, '0')}`, - identity.userId, - identity.relayHostId - ] - ) - } - } - const first = await store.selectIdleRegionalRehomeCandidates(safety) - const next = await store.selectIdleRegionalRehomeCandidates(safety) - expect(first).toHaveLength(100) - expect(next).toHaveLength(1) - expect(next[0]!.relayHostId).toBe('pagehost00000099') - const restarted = new RelayAssignmentStore(database, () => safety.observedAt, { - regionalRehomeCohortPercent: 100 - }) - expect(await restarted.selectIdleRegionalRehomeCandidates(safety)).toEqual(first) - expect(await database.query('SELECT * FROM relay_region_rehome_attempts')).toEqual([]) - const decisions = await database.query('SELECT last_considered_at FROM relay_region_decisions') - expect(decisions.every((decision) => Number(decision.last_considered_at) === 0)).toBe(true) - }) - it.runIf(Boolean(process.env.ORCA_IDLE_REHOME_POSTGRES_URL))( 'rechecks generation when replacement wins after the initial authority lookup', async () => { diff --git a/cloud/apps/relay/src/assignment/regional-rehome/idle-regional-rehome-window.test.ts b/cloud/apps/relay/src/assignment/regional-rehome/idle-regional-rehome-window.test.ts index 403ccf16830..a53521e721d 100644 --- a/cloud/apps/relay/src/assignment/regional-rehome/idle-regional-rehome-window.test.ts +++ b/cloud/apps/relay/src/assignment/regional-rehome/idle-regional-rehome-window.test.ts @@ -151,52 +151,6 @@ describe('idle regional rehome candidate window', () => { expect(new Set(candidates.map((candidate) => candidate.attemptId)).size).toBe(3) }) - it('drops only the targets without room for the host plus its source activity', async () => { - const { store, database, safety } = await setup() - await seedHost(store, identity) - await database.query( - 'UPDATE relay_assignment_activity_leases SET request_units = 4 WHERE user_id = ?', - [identity.userId] - ) - // Five units needed: four source units plus the assignment the move reserves. - await database.query("UPDATE relay_cells SET capacity_requests = 4 WHERE cell_id = 'asia-idle'") - const short = await store.selectIdleRegionalRehomeCandidates(safety) - expect(short.map((candidate) => candidate.targetCellId)).toEqual(['asia-mid', 'asia-busy']) - // Exactly enough room is enough; it ranks last because the ratio is per capacity. - await database.query("UPDATE relay_cells SET capacity_requests = 5 WHERE cell_id = 'asia-idle'") - const exact = await store.selectIdleRegionalRehomeCandidates(safety) - expect(exact.map((candidate) => candidate.targetCellId)).toEqual([ - 'asia-mid', - 'asia-busy', - 'asia-idle' - ]) - }) - - it('reads a bounded window of decisions however many hosts are eligible', async () => { - const { store, database, safety } = await setup() - await seedHost(store, identity) - await cloneHosts(database, identity, 700) - const query = database.query.bind(database) - const decisionRows: number[] = [] - vi.spyOn(database, 'query').mockImplementation(async (sql, params) => { - const rows = await query(sql, params) - if (/^SELECT user_id, relay_host_id FROM relay_region_decisions/.test(sql)) { - decisionRows.push(rows.length) - } - return rows - }) - const first = await store.selectIdleRegionalRehomeCandidates(safety) - const next = await store.selectIdleRegionalRehomeCandidates(safety) - expect(decisionRows.length).toBeGreaterThan(0) - for (const count of decisionRows) { - expect(count).toBeLessThanOrEqual(500) - } - expect(first).toHaveLength(102) - expect(next).toHaveLength(102) - const firstKeys = new Set(first.map((c) => `${c.userId}/${c.targetCellId}`)) - expect(next.every((c) => !firstKeys.has(`${c.userId}/${c.targetCellId}`))).toBe(true) - }) - it("keeps the window's last host when a decision turns eligible between the two reads", async () => { const { store, database, safety, now } = await setup() await seedHost(store, identity) @@ -252,7 +206,6 @@ describe('idle regional rehome candidate window', () => { for (let poll = 0; poll < 20 && !wrapped; poll++) { const page = await store.selectIdleRegionalRehomeCandidates(safety) expect(page.length).toBeGreaterThan(0) - expect(page.length).toBeLessThanOrEqual(102) for (const candidate of page) { const key = `${candidate.userId}/${candidate.targetCellId}` if (seen.has(key)) { diff --git a/cloud/apps/relay/src/assignment/regional-rehome/region-correction-restart.test.ts b/cloud/apps/relay/src/assignment/regional-rehome/region-correction-restart.test.ts index b13e1b9d3a5..beb6a3c58d1 100644 --- a/cloud/apps/relay/src/assignment/regional-rehome/region-correction-restart.test.ts +++ b/cloud/apps/relay/src/assignment/regional-rehome/region-correction-restart.test.ts @@ -80,7 +80,7 @@ describe('persisted region decisions across director restart', () => { expect(row.outcome).toBe('inconclusive') expect(Number(row.expires_at)).toBe(context.window.expiresAt) const successor = (await context.issue(restarted.store)).window! - expect(successor.generation).toBe(context.window.generation + 1) + expect(successor.generation).toBe(2) expect(await restarted.store.exchangeRegionCorrection(identity, context.report, epoch)) .toMatchObject({ reportStatus: 'stale' }) }) @@ -111,7 +111,7 @@ describe('persisted region decisions across director restart', () => { context.setNow(1_000_000_000 - 60_000) const restarted = await context.restart() const successor = (await context.issue(restarted.store)).window! - expect(successor.generation).toBe(context.window.generation + 1) + expect(successor.generation).toBe(2) expect(successor.expiresAt).toBe(context.window.expiresAt - 60_000) expect(await restarted.store.exchangeRegionCorrection(identity, context.report, context.window.assignmentEpoch)) .toMatchObject({ reportStatus: 'stale' }) From 8dd3b710902e1db345cdf60e19a2c81b4fd25db9 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 13 Nov 2026 05:10:41 -0800 Subject: [PATCH 23/55] refactor: organize mobile files and host-screen domain owners (#42019) --- ...obile-localization-coverage-allowlist.json | 2 +- .../app/h/[hostId]/files/[worktreeId].web.tsx | 2 +- .../h/[hostId]/files/preview/[worktreeId].tsx | 2 +- .../files/preview/[worktreeId].web.tsx | 2 +- mobile/rpc-foundation/pilot-scenarios.json | 60 +++--- .../mobile-ai-vault-resume-operations.ts | 2 +- .../mobile-session-browser-operations.ts | 2 +- .../use-new-workspace-repositories.ts | 2 +- mobile/src/files/MobileFilePreviewScreen.tsx | 39 ++-- .../AttachmentTextScrollView.tsx | 6 +- .../MobileChatAttachmentPreview.tsx | 24 +-- .../mobile-chat-attachment-read.ts | 16 +- .../MobileFileExplorerPanel.test.ts | 18 +- .../MobileFileExplorerPanel.tsx | 40 ++-- .../file-explorer-reply-schema.ts | 2 +- .../{ => explorer}/file-list-fallback.ts | 0 .../files/{ => explorer}/file-tree.test.ts | 0 mobile/src/files/{ => explorer}/file-tree.ts | 4 +- .../mobile-file-explorer-operations.ts | 19 +- ...mobile-file-explorer-row-actions-sheet.tsx | 8 +- .../mobile-file-explorer-row.tsx | 14 +- .../mobile-file-explorer-rows.tsx | 0 .../mobile-file-explorer-styles.ts | 4 +- .../native-chat-file-search-operations.ts | 6 +- .../use-mobile-file-explorer-paging.ts | 8 +- .../workspace-file-paths-reply-schema.ts | 2 +- .../MobileFileMarkdownPreview.tsx | 12 +- .../MobileMarkdownPreviewImage.tsx | 18 +- .../MobileMarkdownPreviewModeBar.tsx | 10 +- .../mobile-markdown-file-link.test.ts | 2 +- .../mobile-markdown-file-link.ts | 6 +- .../mobile-markdown-image-source.test.ts | 24 --- .../mobile-markdown-image-source.ts | 12 +- .../{ => media}/MobileFileMediaPreview.tsx | 16 +- .../files/{ => media}/MobileMediaPlayback.tsx | 4 +- .../files/{ => media}/MobilePdfPreview.tsx | 16 +- .../{ => media}/mobile-file-media-download.ts | 8 +- .../files/{ => media}/mobile-file-media.ts | 2 +- .../{ => media}/mobile-media-preview-cache.ts | 0 .../file-exists-error.ts | 2 +- .../file-ownership-reply-schema.ts | 2 +- .../mobile-file-mutation-ownership.ts | 14 +- .../mobile-file-ownership-operations.ts | 6 +- .../{ => preview}/MobileFilePreviewBody.tsx | 20 +- .../MobileFilePreviewDiscardModal.tsx | 4 +- .../MobileFilePreviewEditableSource.tsx | 4 +- .../MobileFilePreviewSourceText.tsx | 9 +- .../file-preview-reply-schema.ts | 2 +- .../{ => preview}/mobile-artifact-kind.ts | 0 .../mobile-file-preview-line-column.ts | 0 .../mobile-file-preview-navigation.ts | 4 +- .../mobile-file-preview-operations.ts | 4 +- .../mobile-file-preview-request.test.ts | 4 +- .../mobile-file-preview-request.ts | 17 +- .../mobile-file-preview-response.ts | 12 +- .../mobile-file-preview-retry.test.tsx | 49 +---- .../mobile-file-preview-route.ts | 2 +- .../mobile-file-preview-source.ts | 0 .../mobile-file-preview-styles.ts | 4 +- .../mobile-file-preview-syntax.ts | 2 +- .../use-mobile-file-preview-back.test.ts | 17 -- .../use-mobile-file-preview-back.ts | 2 +- .../MobileSessionFileReader.tsx | 4 +- .../file-tab-doc-reply-schema.ts | 2 +- .../mobile-diff-image-preview.ts | 2 +- .../mobile-file-tab-doc-operations.ts | 7 +- .../mobile-file-tab-doc.ts | 8 +- .../session-documents/mobile-file-tap-open.ts | 6 +- .../mobile-file-tap-operations.ts | 2 +- .../mobile-markdown-reader-status.ts | 2 +- .../mobile-markdown-tab-operations.ts | 2 +- ...mobile-session-document-read-settlement.ts | 2 +- .../session-document-state.ts | 2 +- .../use-mobile-file-tap-handlers.ts | 2 +- .../use-mobile-session-document-readers.ts | 4 +- .../MobileFilePreviewEditDraft.tsx | 18 +- .../mobile-file-preview-editability.ts | 6 +- .../mobile-terminal-artifact-grant-refresh.ts | 6 +- .../terminal-artifact-grant-error.ts | 0 mobile/src/home/MobileHomeScreen.tsx | 2 +- mobile/src/home/home-query-restore.ts | 2 +- mobile/src/home/use-mobile-home-data.ts | 2 +- .../{ => catalog}/host-list-queries.ts | 24 +-- .../{ => catalog}/host-repo-looks.ts | 6 +- .../host-ssh-target-labels-read.ts | 15 +- .../use-host-project-sections.ts | 22 ++- .../use-host-repo-icon-data-urls.ts | 9 +- .../{ => catalog}/use-host-repo-metadata.ts | 16 +- .../use-host-worktree-catalog.ts | 46 ++--- .../src/host-screen/host-edit-navigation.ts | 35 ---- .../src/host-screen/host-screen-overlays.tsx | 9 +- .../host-screen-reply-schema.test.ts | 176 ------------------ mobile/src/host-screen/host-screen-view.tsx | 2 +- .../host-sidebar-feed-sections.test.ts | 111 ----------- .../host-sidebar-feed.test-fixture.ts | 139 -------------- .../identity/host-edit-navigation.ts | 22 +++ .../{ => identity}/host-stored-identity.ts | 4 +- .../use-host-screen-identity.ts | 22 +-- .../use-open-mobile-host-edit.ts | 2 +- .../host-sidebar-feed-connection.ts | 4 +- .../host-sidebar-feed-inputs.ts | 12 +- .../host-sidebar-feed-operations.ts | 14 +- .../host-sidebar-feed-sections.ts | 33 ++-- .../use-host-sidebar-feed.ts | 12 +- .../host-screen/use-host-screen-controller.ts | 21 ++- .../src/host-screen/use-host-screen-state.ts | 12 +- .../host-list-settings-operations.ts | 8 +- .../{ => view-settings}/host-view-memory.ts | 8 +- .../host-view-pending-changes.ts | 2 +- .../use-host-pending-view-changes.test.ts | 10 +- .../use-host-pending-view-changes.ts | 13 +- .../use-host-view-settings.ts | 20 +- .../use-host-worktree-actions.ts | 63 +++---- .../use-host-worktree-delete.ts | 24 +-- .../workspace-details-sheet.tsx | 24 +-- .../workspace-rename-mutation.ts | 22 +++ .../workspace-rename-sheet.tsx | 14 +- .../worktree-remove-request.ts | 6 +- .../host-pending-creation-row.tsx | 10 +- .../host-workspace-list.tsx | 50 ++--- .../use-host-display-worktrees.ts | 12 +- .../use-host-list-sections.ts | 45 ++--- .../use-slow-pending-keys.test.tsx | 0 .../use-slow-pending-keys.ts | 0 .../phone-card-property-choices.ts | 4 +- .../workspace-option-controls-copy.ts | 10 +- .../workspace-option-controls.tsx | 26 +-- .../workspace-option-rows.tsx | 6 +- .../workspace-option-values.ts | 0 .../workspace-options-sheet.tsx | 32 ++-- .../worktree-remove-request.test.ts | 27 --- .../bridge/bridge-native-verbs.ts | 2 +- .../shell-screen-route.test.ts | 70 ------- .../mobile-web-shell/shell-screen-route.ts | 2 +- .../src/new-workspace/use-host-label-by-id.ts | 2 +- .../new-workspace/use-host-worktree-rows.ts | 2 +- mobile/src/platform/native-pdf-view.ts | 4 +- mobile/src/query/first-paint-query-keys.ts | 6 +- .../src/query/host-query-persistence.test.ts | 51 +---- .../session/MobileSessionActiveContent.tsx | 2 +- .../src/session/MobileSourceControlButton.tsx | 2 +- .../src/session/SessionDockPanelContent.tsx | 2 +- .../mobile-structured-create-operations.ts | 2 +- .../MobileNativeChatComposerAttachments.tsx | 2 +- .../use-mobile-native-chat-file-search.ts | 2 +- .../mobile-native-chat-options-operations.ts | 2 +- .../mobile-native-chat-image-preview.ts | 2 +- ...e-mobile-session-content-create-actions.ts | 4 +- .../use-mobile-folder-attached-worktrees.ts | 2 +- mobile/src/stats/home-stats-summary-read.ts | 2 +- .../client-event-stream-mount-adapters.ts | 4 +- .../file-explorer-screen-mount-adapters.ts | 5 +- .../adapters/file-request-mount-adapters.ts | 19 +- .../adapters/host-screen-mount-adapters.ts | 7 +- .../host-worktree-action-mount-adapters.ts | 7 +- .../adapters/settings-mount-adapters.ts | 8 +- .../src/worktree/home-host-worktree-fetch.ts | 2 +- mobile/src/worktree/host-worktree-refresh.ts | 2 +- mobile/tsconfig.test.mobile-declarations.json | 2 +- tests/e2e/helpers/phone-feed-list.ts | 10 +- 160 files changed, 747 insertions(+), 1361 deletions(-) rename mobile/src/files/{ => attachments}/AttachmentTextScrollView.tsx (95%) rename mobile/src/files/{ => attachments}/MobileChatAttachmentPreview.tsx (87%) rename mobile/src/files/{ => attachments}/mobile-chat-attachment-read.ts (83%) rename mobile/src/files/{ => explorer}/MobileFileExplorerPanel.test.ts (95%) rename mobile/src/files/{ => explorer}/MobileFileExplorerPanel.tsx (90%) rename mobile/src/files/{ => explorer}/file-explorer-reply-schema.ts (92%) rename mobile/src/files/{ => explorer}/file-list-fallback.ts (100%) rename mobile/src/files/{ => explorer}/file-tree.test.ts (100%) rename mobile/src/files/{ => explorer}/file-tree.ts (97%) rename mobile/src/files/{ => explorer}/mobile-file-explorer-operations.ts (90%) rename mobile/src/files/{ => explorer}/mobile-file-explorer-row-actions-sheet.tsx (83%) rename mobile/src/files/{ => explorer}/mobile-file-explorer-row.tsx (95%) rename mobile/src/files/{ => explorer}/mobile-file-explorer-rows.tsx (100%) rename mobile/src/files/{ => explorer}/mobile-file-explorer-styles.ts (94%) rename mobile/src/files/{ => explorer}/native-chat-file-search-operations.ts (84%) rename mobile/src/files/{ => explorer}/use-mobile-file-explorer-paging.ts (93%) rename mobile/src/files/{ => explorer}/workspace-file-paths-reply-schema.ts (84%) rename mobile/src/files/{ => markdown}/MobileFileMarkdownPreview.tsx (79%) rename mobile/src/files/{ => markdown}/MobileMarkdownPreviewImage.tsx (82%) rename mobile/src/files/{ => markdown}/MobileMarkdownPreviewModeBar.tsx (85%) rename mobile/src/files/{ => markdown}/mobile-markdown-file-link.test.ts (96%) rename mobile/src/files/{ => markdown}/mobile-markdown-file-link.ts (86%) rename mobile/src/files/{ => markdown}/mobile-markdown-image-source.test.ts (55%) rename mobile/src/files/{ => markdown}/mobile-markdown-image-source.ts (79%) rename mobile/src/files/{ => media}/MobileFileMediaPreview.tsx (84%) rename mobile/src/files/{ => media}/MobileMediaPlayback.tsx (91%) rename mobile/src/files/{ => media}/MobilePdfPreview.tsx (82%) rename mobile/src/files/{ => media}/mobile-file-media-download.ts (93%) rename mobile/src/files/{ => media}/mobile-file-media.ts (90%) rename mobile/src/files/{ => media}/mobile-media-preview-cache.ts (100%) rename mobile/src/files/{ => mutation-ownership}/file-exists-error.ts (85%) rename mobile/src/files/{ => mutation-ownership}/file-ownership-reply-schema.ts (93%) rename mobile/src/files/{ => mutation-ownership}/mobile-file-mutation-ownership.ts (85%) rename mobile/src/files/{ => mutation-ownership}/mobile-file-ownership-operations.ts (83%) rename mobile/src/files/{ => preview}/MobileFilePreviewBody.tsx (88%) rename mobile/src/files/{ => preview}/MobileFilePreviewDiscardModal.tsx (86%) rename mobile/src/files/{ => preview}/MobileFilePreviewEditableSource.tsx (93%) rename mobile/src/files/{ => preview}/MobileFilePreviewSourceText.tsx (89%) rename mobile/src/files/{ => preview}/file-preview-reply-schema.ts (96%) rename mobile/src/files/{ => preview}/mobile-artifact-kind.ts (100%) rename mobile/src/files/{ => preview}/mobile-file-preview-line-column.ts (100%) rename mobile/src/files/{ => preview}/mobile-file-preview-navigation.ts (89%) rename mobile/src/files/{ => preview}/mobile-file-preview-operations.ts (95%) rename mobile/src/files/{ => preview}/mobile-file-preview-request.test.ts (95%) rename mobile/src/files/{ => preview}/mobile-file-preview-request.ts (94%) rename mobile/src/files/{ => preview}/mobile-file-preview-response.ts (94%) rename mobile/src/files/{ => preview}/mobile-file-preview-retry.test.tsx (72%) rename mobile/src/files/{ => preview}/mobile-file-preview-route.ts (98%) rename mobile/src/files/{ => preview}/mobile-file-preview-source.ts (100%) rename mobile/src/files/{ => preview}/mobile-file-preview-styles.ts (95%) rename mobile/src/files/{ => preview}/mobile-file-preview-syntax.ts (93%) rename mobile/src/files/{ => preview}/use-mobile-file-preview-back.test.ts (85%) rename mobile/src/files/{ => preview}/use-mobile-file-preview-back.ts (96%) rename mobile/src/files/{ => session-documents}/file-tab-doc-reply-schema.ts (96%) rename mobile/src/files/{ => session-documents}/mobile-diff-image-preview.ts (91%) rename mobile/src/files/{ => session-documents}/mobile-file-tab-doc-operations.ts (92%) rename mobile/src/files/{ => session-documents}/mobile-file-tab-doc.ts (91%) rename mobile/src/files/{ => terminal-artifacts}/MobileFilePreviewEditDraft.tsx (71%) rename mobile/src/files/{ => terminal-artifacts}/mobile-file-preview-editability.ts (89%) rename mobile/src/files/{ => terminal-artifacts}/mobile-terminal-artifact-grant-refresh.ts (93%) rename mobile/src/files/{ => terminal-artifacts}/terminal-artifact-grant-error.ts (100%) rename mobile/src/host-screen/{ => catalog}/host-list-queries.ts (93%) rename mobile/src/host-screen/{ => catalog}/host-repo-looks.ts (88%) rename mobile/src/host-screen/{ => catalog}/host-ssh-target-labels-read.ts (74%) rename mobile/src/host-screen/{ => catalog}/use-host-project-sections.ts (81%) rename mobile/src/host-screen/{ => catalog}/use-host-repo-icon-data-urls.ts (87%) rename mobile/src/host-screen/{ => catalog}/use-host-repo-metadata.ts (80%) rename mobile/src/host-screen/{ => catalog}/use-host-worktree-catalog.ts (86%) delete mode 100644 mobile/src/host-screen/host-edit-navigation.ts delete mode 100644 mobile/src/host-screen/host-screen-reply-schema.test.ts delete mode 100644 mobile/src/host-screen/host-sidebar-feed-sections.test.ts delete mode 100644 mobile/src/host-screen/host-sidebar-feed.test-fixture.ts create mode 100644 mobile/src/host-screen/identity/host-edit-navigation.ts rename mobile/src/host-screen/{ => identity}/host-stored-identity.ts (83%) rename mobile/src/host-screen/{ => identity}/use-host-screen-identity.ts (80%) rename mobile/src/host-screen/{ => identity}/use-open-mobile-host-edit.ts (81%) rename mobile/src/host-screen/{ => sidebar-feed}/host-sidebar-feed-connection.ts (82%) rename mobile/src/host-screen/{ => sidebar-feed}/host-sidebar-feed-inputs.ts (75%) rename mobile/src/host-screen/{ => sidebar-feed}/host-sidebar-feed-operations.ts (79%) rename mobile/src/host-screen/{ => sidebar-feed}/host-sidebar-feed-sections.ts (89%) rename mobile/src/host-screen/{ => sidebar-feed}/use-host-sidebar-feed.ts (87%) rename mobile/src/host-screen/{ => view-settings}/host-list-settings-operations.ts (80%) rename mobile/src/host-screen/{ => view-settings}/host-view-memory.ts (86%) rename mobile/src/host-screen/{ => view-settings}/host-view-pending-changes.ts (98%) rename mobile/src/host-screen/{ => view-settings}/use-host-pending-view-changes.test.ts (94%) rename mobile/src/host-screen/{ => view-settings}/use-host-pending-view-changes.ts (93%) rename mobile/src/host-screen/{ => view-settings}/use-host-view-settings.ts (93%) rename mobile/src/host-screen/{ => workspace-actions}/use-host-worktree-actions.ts (83%) rename mobile/src/host-screen/{ => workspace-actions}/use-host-worktree-delete.ts (91%) rename mobile/src/host-screen/{ => workspace-actions}/workspace-details-sheet.tsx (90%) create mode 100644 mobile/src/host-screen/workspace-actions/workspace-rename-mutation.ts rename mobile/src/host-screen/{ => workspace-actions}/workspace-rename-sheet.tsx (78%) rename mobile/src/host-screen/{ => workspace-actions}/worktree-remove-request.ts (78%) rename mobile/src/host-screen/{ => workspace-list}/host-pending-creation-row.tsx (80%) rename mobile/src/host-screen/{ => workspace-list}/host-workspace-list.tsx (88%) rename mobile/src/host-screen/{ => workspace-list}/use-host-display-worktrees.ts (82%) rename mobile/src/host-screen/{ => workspace-list}/use-host-list-sections.ts (78%) rename mobile/src/host-screen/{ => workspace-list}/use-slow-pending-keys.test.tsx (100%) rename mobile/src/host-screen/{ => workspace-list}/use-slow-pending-keys.ts (100%) rename mobile/src/host-screen/{ => workspace-options}/phone-card-property-choices.ts (75%) rename mobile/src/host-screen/{ => workspace-options}/workspace-option-controls-copy.ts (92%) rename mobile/src/host-screen/{ => workspace-options}/workspace-option-controls.tsx (91%) rename mobile/src/host-screen/{ => workspace-options}/workspace-option-rows.tsx (88%) rename mobile/src/host-screen/{ => workspace-options}/workspace-option-values.ts (100%) rename mobile/src/host-screen/{ => workspace-options}/workspace-options-sheet.tsx (90%) delete mode 100644 mobile/src/host-screen/worktree-remove-request.test.ts delete mode 100644 mobile/src/mobile-web-shell/shell-screen-route.test.ts diff --git a/config/mobile-localization-coverage-allowlist.json b/config/mobile-localization-coverage-allowlist.json index d84333750f2..d6e910f1e4f 100644 --- a/config/mobile-localization-coverage-allowlist.json +++ b/config/mobile-localization-coverage-allowlist.json @@ -175,7 +175,7 @@ "count": 1 }, { - "filePath": "mobile/src/files/mobile-file-preview-route.ts", + "filePath": "mobile/src/files/preview/mobile-file-preview-route.ts", "kind": "object-property:message", "text": "Unable to load preview", "dynamic": false, diff --git a/mobile/app/h/[hostId]/files/[worktreeId].web.tsx b/mobile/app/h/[hostId]/files/[worktreeId].web.tsx index 596fbad3260..f27f5804bfb 100644 --- a/mobile/app/h/[hostId]/files/[worktreeId].web.tsx +++ b/mobile/app/h/[hostId]/files/[worktreeId].web.tsx @@ -1,6 +1,6 @@ import { withWorkspaceRoute } from '../../../../src/navigation/workspace-route' import { useLocalSearchParams } from 'expo-router' -import { MobileFileExplorerPanel } from '../../../../src/files/MobileFileExplorerPanel' +import { MobileFileExplorerPanel } from '../../../../src/files/explorer/MobileFileExplorerPanel' import { firstParam } from '../../../../src/navigation/route-param-reader' // Render screen directly: native sibling imports a shell module that throws in browsers, and manifest imports every route. diff --git a/mobile/app/h/[hostId]/files/preview/[worktreeId].tsx b/mobile/app/h/[hostId]/files/preview/[worktreeId].tsx index a70935e4fa6..16a0891a4b6 100644 --- a/mobile/app/h/[hostId]/files/preview/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/files/preview/[worktreeId].tsx @@ -7,7 +7,7 @@ import { useLocalSearchParams } from 'expo-router' import { mobileFilePreviewShellParams, normalizeMobileFilePreviewRouteParams -} from '../../../../../src/files/mobile-file-preview-route' +} from '../../../../../src/files/preview/mobile-file-preview-route' import { shellScreenRoute, shellScreenRouteKey diff --git a/mobile/app/h/[hostId]/files/preview/[worktreeId].web.tsx b/mobile/app/h/[hostId]/files/preview/[worktreeId].web.tsx index 4170e5f8a44..d903f7a3bff 100644 --- a/mobile/app/h/[hostId]/files/preview/[worktreeId].web.tsx +++ b/mobile/app/h/[hostId]/files/preview/[worktreeId].web.tsx @@ -1,7 +1,7 @@ import { withWorkspaceRoute } from '../../../../../src/navigation/workspace-route' import { useLocalSearchParams } from 'expo-router' import { MobileFilePreviewScreen } from '../../../../../src/files/MobileFilePreviewScreen' -import { normalizeMobileFilePreviewRouteParams } from '../../../../../src/files/mobile-file-preview-route' +import { normalizeMobileFilePreviewRouteParams } from '../../../../../src/files/preview/mobile-file-preview-route' // Render screen directly: native sibling imports a shell module that throws in browsers, and manifest imports every route. export default withWorkspaceRoute(function MobileFilePreviewRoute() { diff --git a/mobile/rpc-foundation/pilot-scenarios.json b/mobile/rpc-foundation/pilot-scenarios.json index 46f97142576..0f256d6a21f 100644 --- a/mobile/rpc-foundation/pilot-scenarios.json +++ b/mobile/rpc-foundation/pilot-scenarios.json @@ -930,7 +930,7 @@ "id": "settings-repo-metadata-fulfilled", "operation": "settings.repo-metadata", "family": "settings.repo-metadata", - "sites": ["mobile/src/host-screen/use-host-repo-metadata.ts"], + "sites": ["mobile/src/host-screen/catalog/use-host-repo-metadata.ts"], "schedules": ["settings-first"], "steps": [ { @@ -1023,7 +1023,7 @@ "id": "settings-repo-metadata-icons", "operation": "settings.repo-metadata", "family": "settings.repo-metadata", - "sites": ["mobile/src/host-screen/use-host-repo-metadata.ts"], + "sites": ["mobile/src/host-screen/catalog/use-host-repo-metadata.ts"], "schedules": ["settings-first"], "steps": [ { @@ -1134,7 +1134,7 @@ "id": "settings-repo-metadata-refused", "operation": "settings.repo-metadata", "family": "settings.repo-metadata", - "sites": ["mobile/src/host-screen/use-host-repo-metadata.ts"], + "sites": ["mobile/src/host-screen/catalog/use-host-repo-metadata.ts"], "schedules": ["settings-first"], "steps": [ { @@ -1222,7 +1222,7 @@ "id": "settings-repo-metadata-transport-error", "operation": "settings.repo-metadata", "family": "settings.repo-metadata", - "sites": ["mobile/src/host-screen/use-host-repo-metadata.ts"], + "sites": ["mobile/src/host-screen/catalog/use-host-repo-metadata.ts"], "schedules": ["settings-first"], "steps": [ { @@ -2101,7 +2101,7 @@ "id": "settings-repo-cache-expiry", "operation": "settings.repo-metadata", "family": "settings.repo-metadata", - "sites": ["mobile/src/host-screen/use-host-repo-metadata.ts"], + "sites": ["mobile/src/host-screen/catalog/use-host-repo-metadata.ts"], "schedules": ["follow-up-cache-observation", "timer-expiry"], "steps": [ { @@ -2424,7 +2424,7 @@ "id": "settings-repo-metadata-single-host", "operation": "settings.repo-metadata", "family": "settings.repo-metadata", - "sites": ["mobile/src/host-screen/use-host-repo-metadata.ts"], + "sites": ["mobile/src/host-screen/catalog/use-host-repo-metadata.ts"], "schedules": ["single-ssh-host"], "steps": [ { @@ -2855,7 +2855,7 @@ "id": "settings-repo-metadata-refuse-after-data", "operation": "settings.repo-metadata", "family": "settings.repo-metadata", - "sites": ["mobile/src/host-screen/use-host-repo-metadata.ts"], + "sites": ["mobile/src/host-screen/catalog/use-host-repo-metadata.ts"], "schedules": ["success-then-refused"], "steps": [ { @@ -6809,7 +6809,7 @@ "id": "files-ownership-ssh", "operation": "files.mutation-ownership", "family": "files.mutation-ownership", - "sites": ["mobile/src/files/mobile-file-mutation-ownership.ts"], + "sites": ["mobile/src/files/mutation-ownership/mobile-file-mutation-ownership.ts"], "schedules": [], "steps": [ { @@ -6872,7 +6872,7 @@ "id": "files-ownership-local", "operation": "files.mutation-ownership", "family": "files.mutation-ownership", - "sites": ["mobile/src/files/mobile-file-mutation-ownership.ts"], + "sites": ["mobile/src/files/mutation-ownership/mobile-file-mutation-ownership.ts"], "schedules": [], "steps": [ { @@ -6915,8 +6915,8 @@ "operation": "files.preview-load", "family": "files.preview-load", "sites": [ - "mobile/src/files/mobile-file-preview-request.ts", - "mobile/src/files/mobile-terminal-artifact-grant-refresh.ts" + "mobile/src/files/preview/mobile-file-preview-request.ts", + "mobile/src/files/terminal-artifacts/mobile-terminal-artifact-grant-refresh.ts" ], "schedules": [], "steps": [ @@ -6989,8 +6989,8 @@ "operation": "files.preview-load", "family": "files.preview-load", "sites": [ - "mobile/src/files/mobile-file-preview-request.ts", - "mobile/src/files/mobile-terminal-artifact-grant-refresh.ts" + "mobile/src/files/preview/mobile-file-preview-request.ts", + "mobile/src/files/terminal-artifacts/mobile-terminal-artifact-grant-refresh.ts" ], "schedules": [], "steps": [ @@ -7023,7 +7023,7 @@ "id": "files-preview-worktree", "operation": "files.preview-load", "family": "files.preview-load", - "sites": ["mobile/src/files/mobile-file-preview-request.ts"], + "sites": ["mobile/src/files/preview/mobile-file-preview-request.ts"], "schedules": [], "steps": [ { @@ -7054,7 +7054,7 @@ "id": "files-preview-worktree-image", "operation": "files.preview-load", "family": "files.preview-load", - "sites": ["mobile/src/files/mobile-file-preview-request.ts"], + "sites": ["mobile/src/files/preview/mobile-file-preview-request.ts"], "schedules": [], "steps": [ { @@ -7089,7 +7089,7 @@ "id": "files-preview-artifact-image", "operation": "files.preview-load", "family": "files.preview-load", - "sites": ["mobile/src/files/mobile-file-preview-request.ts"], + "sites": ["mobile/src/files/preview/mobile-file-preview-request.ts"], "schedules": [], "steps": [ { @@ -7126,8 +7126,8 @@ "operation": "files.preview-save", "family": "files.preview-save", "sites": [ - "mobile/src/files/mobile-file-preview-request.ts", - "mobile/src/files/mobile-terminal-artifact-grant-refresh.ts" + "mobile/src/files/preview/mobile-file-preview-request.ts", + "mobile/src/files/terminal-artifacts/mobile-terminal-artifact-grant-refresh.ts" ], "schedules": [], "steps": [ @@ -7179,8 +7179,8 @@ "operation": "files.preview-save", "family": "files.preview-save", "sites": [ - "mobile/src/files/mobile-file-preview-request.ts", - "mobile/src/files/mobile-terminal-artifact-grant-refresh.ts" + "mobile/src/files/preview/mobile-file-preview-request.ts", + "mobile/src/files/terminal-artifacts/mobile-terminal-artifact-grant-refresh.ts" ], "schedules": [], "steps": [ @@ -7212,7 +7212,7 @@ "id": "files-tab-doc-shapes", "operation": "files.tab-doc", "family": "files.tab-doc", - "sites": ["mobile/src/files/mobile-file-tab-doc.ts"], + "sites": ["mobile/src/files/session-documents/mobile-file-tab-doc.ts"], "schedules": [], "steps": [ { @@ -7416,7 +7416,7 @@ "id": "host-view-settings-sync", "operation": "host.view-settings", "family": "host.view-settings", - "sites": ["mobile/src/host-screen/use-host-view-settings.ts"], + "sites": ["mobile/src/host-screen/view-settings/use-host-view-settings.ts"], "schedules": [], "steps": [ { @@ -7640,7 +7640,7 @@ "id": "host-worktree-actions-pin-open-delete", "operation": "host.worktree-actions", "family": "host.worktree-actions", - "sites": ["mobile/src/host-screen/use-host-worktree-actions.ts"], + "sites": ["mobile/src/host-screen/workspace-actions/use-host-worktree-actions.ts"], "schedules": [], "steps": [ { @@ -7731,7 +7731,7 @@ "id": "host-worktree-delete-refused", "operation": "host.worktree-actions", "family": "host.worktree-actions", - "sites": ["mobile/src/host-screen/use-host-worktree-actions.ts"], + "sites": ["mobile/src/host-screen/workspace-actions/use-host-worktree-actions.ts"], "schedules": [], "steps": [ { @@ -7786,7 +7786,7 @@ "id": "host-worktree-delete-archive-hook-failed", "operation": "host.worktree-actions", "family": "host.worktree-actions", - "sites": ["mobile/src/host-screen/use-host-worktree-actions.ts"], + "sites": ["mobile/src/host-screen/workspace-actions/use-host-worktree-actions.ts"], "schedules": [], "steps": [ { @@ -20218,7 +20218,7 @@ "id": "files-explorer-legacy-fallback", "operation": "files.explorer-screen", "family": "files.explorer-screen", - "sites": ["mobile/src/files/MobileFileExplorerPanel.tsx"], + "sites": ["mobile/src/files/explorer/MobileFileExplorerPanel.tsx"], "schedules": [], "steps": [ { @@ -20277,7 +20277,7 @@ "id": "files-explorer-readdir", "operation": "files.explorer-screen", "family": "files.explorer-screen", - "sites": ["mobile/src/files/MobileFileExplorerPanel.tsx"], + "sites": ["mobile/src/files/explorer/MobileFileExplorerPanel.tsx"], "schedules": [], "steps": [ { @@ -23762,7 +23762,7 @@ "id": "files-preview-worktree-text-read", "operation": "files.preview-load", "family": "files.preview-worktree-text", - "sites": ["mobile/src/files/mobile-file-preview-request.ts"], + "sites": ["mobile/src/files/preview/mobile-file-preview-request.ts"], "schedules": [], "steps": [ { @@ -23793,7 +23793,7 @@ "id": "files-preview-worktree-image-read", "operation": "files.preview-load", "family": "files.preview-worktree-image", - "sites": ["mobile/src/files/mobile-file-preview-request.ts"], + "sites": ["mobile/src/files/preview/mobile-file-preview-request.ts"], "schedules": [], "steps": [ { @@ -23828,7 +23828,7 @@ "id": "files-preview-artifact-image-read", "operation": "files.preview-load", "family": "files.preview-artifact-image", - "sites": ["mobile/src/files/mobile-file-preview-request.ts"], + "sites": ["mobile/src/files/preview/mobile-file-preview-request.ts"], "schedules": [], "steps": [ { diff --git a/mobile/src/agent-history/mobile-ai-vault-resume-operations.ts b/mobile/src/agent-history/mobile-ai-vault-resume-operations.ts index 48e9c2ad335..1a0ece8eead 100644 --- a/mobile/src/agent-history/mobile-ai-vault-resume-operations.ts +++ b/mobile/src/agent-history/mobile-ai-vault-resume-operations.ts @@ -1,6 +1,6 @@ import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc/rpc-operation' import { rpcResultVariant } from '../transport/rpc/rpc-operation-result-reader' -import '../files/mobile-file-preview-operations' +import '../files/preview/mobile-file-preview-operations' import { aiVaultResumePreparationSchema } from './mobile-ai-vault-resume-reply-schema' import '../browser/tab-navigation/mobile-session-browser-reply-schema' import '../files/session-documents/mobile-file-tap-reply-schema' diff --git a/mobile/src/browser/tab-navigation/mobile-session-browser-operations.ts b/mobile/src/browser/tab-navigation/mobile-session-browser-operations.ts index 561f607ff2b..956dc42f809 100644 --- a/mobile/src/browser/tab-navigation/mobile-session-browser-operations.ts +++ b/mobile/src/browser/tab-navigation/mobile-session-browser-operations.ts @@ -1,6 +1,6 @@ import { bindDeferredRpcOperation, defineRpcOperation } from '../../transport/rpc/rpc-operation' import { rpcResultVariant } from '../../transport/rpc/rpc-operation-result-reader' -import '../../files/mobile-file-preview-operations' +import '../../files/preview/mobile-file-preview-operations' import { browserTabCreatedSchema } from './mobile-session-browser-reply-schema' import '../../agent-history/mobile-ai-vault-resume-reply-schema' import '../../files/session-documents/mobile-file-tap-reply-schema' diff --git a/mobile/src/components/use-new-workspace-repositories.ts b/mobile/src/components/use-new-workspace-repositories.ts index 15a0c14e57e..b34fa4d8cfa 100644 --- a/mobile/src/components/use-new-workspace-repositories.ts +++ b/mobile/src/components/use-new-workspace-repositories.ts @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react' import { useQueryClient } from '@tanstack/react-query' import { getRepoExecutionHostId, type ExecutionHostId } from '../../../src/shared/execution-host' import type { RpcClient } from '../transport/rpc/rpc-client-contract' -import { cachedHostRepos } from '../host-screen/host-list-queries' +import { cachedHostRepos } from '../host-screen/catalog/host-list-queries' import { listHostRepos } from '../worktree/server-repos' import { useServerRepos } from '../worktree/use-server-repos' import { useLastVisitedWorktreeRepoId } from '../worktree/use-last-visited-worktree-repo' diff --git a/mobile/src/files/MobileFilePreviewScreen.tsx b/mobile/src/files/MobileFilePreviewScreen.tsx index 37dab60d168..dbf347ede61 100644 --- a/mobile/src/files/MobileFilePreviewScreen.tsx +++ b/mobile/src/files/MobileFilePreviewScreen.tsx @@ -1,5 +1,9 @@ -import { saveErrorMessageFromPreviewResult } from './mobile-file-preview-response' -import { MobileChatAttachmentPreview } from './MobileChatAttachmentPreview' +import { + saveErrorMessageFromPreviewResult, + previewError, + type MobileFilePreviewResult +} from './preview/mobile-file-preview-response' +import { MobileChatAttachmentPreview } from './attachments/MobileChatAttachmentPreview' import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { Alert, View } from 'react-native' import { SafeAreaView } from 'react-native-safe-area-context' @@ -15,33 +19,34 @@ import { useWorkspaceClient } from '../worktree/use-workspace-client' import { connectionRetryAction } from '../transport/connection/connection-retry-action' import { loadMobileFilePreview, - previewError, - saveMobileTerminalArtifactPreview, - type MobileFilePreviewResult -} from './mobile-file-preview-request' -import { MobileFilePreviewDiscardModal } from './MobileFilePreviewDiscardModal' + saveMobileTerminalArtifactPreview +} from './preview/mobile-file-preview-request' +import { MobileFilePreviewDiscardModal } from './preview/MobileFilePreviewDiscardModal' import { NavBar } from '../components/nav-bar/NavBar' import { NavBarButton } from '../components/nav-bar/NavBarButton' -import { MobileFilePreviewBody } from './MobileFilePreviewBody' +import { MobileFilePreviewBody } from './preview/MobileFilePreviewBody' import { MobileMarkdownPreviewModeBar, useMarkdownPreviewMode -} from './MobileMarkdownPreviewModeBar' +} from './markdown/MobileMarkdownPreviewModeBar' import { displayNameFromPreviewPath, type MobileFilePreviewRouteState -} from './mobile-file-preview-route' -import { previewSourceFromRoute, sourceKeyForPreview } from './mobile-file-preview-source' -import { normalizeMobileFilePreviewLineColumn } from './mobile-file-preview-line-column' +} from './preview/mobile-file-preview-route' +import { previewSourceFromRoute, sourceKeyForPreview } from './preview/mobile-file-preview-source' +import { normalizeMobileFilePreviewLineColumn } from './preview/mobile-file-preview-line-column' import { hasUnsavedMobileTerminalArtifactDraft, isEditableMobileTerminalArtifactPreview, shouldKeepDirtyDraftOnPreviewLoadResult -} from './mobile-file-preview-editability' -import { filePreviewStyles as styles } from './mobile-file-preview-styles' -import { mobileMarkdownFileLink } from './mobile-markdown-file-link' -import { useMobileFilePreviewBack } from './use-mobile-file-preview-back' -import { filePreviewEditDraftKey, MobileFilePreviewEditDraft } from './MobileFilePreviewEditDraft' +} from './terminal-artifacts/mobile-file-preview-editability' +import { filePreviewStyles as styles } from './preview/mobile-file-preview-styles' +import { mobileMarkdownFileLink } from './markdown/mobile-markdown-file-link' +import { useMobileFilePreviewBack } from './preview/use-mobile-file-preview-back' +import { + filePreviewEditDraftKey, + MobileFilePreviewEditDraft +} from './terminal-artifacts/MobileFilePreviewEditDraft' import { dropDocumentEditDraft } from '../drafts/document-edit-draft-rows' type Props = { diff --git a/mobile/src/files/AttachmentTextScrollView.tsx b/mobile/src/files/attachments/AttachmentTextScrollView.tsx similarity index 95% rename from mobile/src/files/AttachmentTextScrollView.tsx rename to mobile/src/files/attachments/AttachmentTextScrollView.tsx index 07c84177e19..b9d5255f358 100644 --- a/mobile/src/files/AttachmentTextScrollView.tsx +++ b/mobile/src/files/attachments/AttachmentTextScrollView.tsx @@ -3,9 +3,9 @@ import { Platform, ScrollView, Text, View, type ScrollViewInstance } from 'react import type { AttachmentTextSource, AttachmentTextWindow -} from '../../../src/shared/attachment-text/attachment-text-source' -import { AttachmentTextViewport } from '../../../src/shared/attachment-text/attachment-text-viewport' -import { chatAttachmentPreviewStyles as styles } from './mobile-file-preview-styles' +} from '../../../../src/shared/attachment-text/attachment-text-source' +import { AttachmentTextViewport } from '../../../../src/shared/attachment-text/attachment-text-viewport' +import { chatAttachmentPreviewStyles as styles } from '../preview/mobile-file-preview-styles' function scrollElement(view: ScrollViewInstance | null): HTMLElement | null { if (Platform.OS !== 'web') { diff --git a/mobile/src/files/MobileChatAttachmentPreview.tsx b/mobile/src/files/attachments/MobileChatAttachmentPreview.tsx similarity index 87% rename from mobile/src/files/MobileChatAttachmentPreview.tsx rename to mobile/src/files/attachments/MobileChatAttachmentPreview.tsx index c1ab21efabb..eee059e3f2c 100644 --- a/mobile/src/files/MobileChatAttachmentPreview.tsx +++ b/mobile/src/files/attachments/MobileChatAttachmentPreview.tsx @@ -1,20 +1,20 @@ -import { useMobileTranslation } from '../i18n/use-mobile-translation' -import type { Translate } from '../i18n/mobile-i18n' +import { useMobileTranslation } from '../../i18n/use-mobile-translation' +import type { Translate } from '../../i18n/mobile-i18n' import { createMobileChatAttachmentReader } from './mobile-chat-attachment-read' -import { useMobileColorScheme } from '../theme/use-mobile-color-scheme' +import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme' import { useMemo, useState, useEffect, useCallback } from 'react' import { Pressable, Text, View } from 'react-native' import { AttachmentTextScrollView } from './AttachmentTextScrollView' import { SafeAreaView } from 'react-native-safe-area-context' -import { useWorkspaceRouteHandoff } from '../navigation/workspace-route-handoff' -import { useWorkspaceClient } from '../worktree/use-workspace-client' -import { createAttachmentTextPreviewHook } from '../../../src/shared/attachment-text/use-attachment-text-preview' -import { chatTextAttachmentName } from '../../../src/shared/native-chat-text-attachment-references' -import { MOBILE_BRIDGE_MAX_FRAME_BYTES } from '../../../src/shared/mobile-bridge-frame-bytes' -import { useClipboardWriter } from '../platform/clipboard' -import { getUiIntlLocale } from '../i18n/ui-intl-locale' -import { chatAttachmentPreviewStyles as styles } from './mobile-file-preview-styles' -import type { MobileFilePreviewRouteParams } from './mobile-file-preview-route' +import { useWorkspaceRouteHandoff } from '../../navigation/workspace-route-handoff' +import { useWorkspaceClient } from '../../worktree/use-workspace-client' +import { createAttachmentTextPreviewHook } from '../../../../src/shared/attachment-text/use-attachment-text-preview' +import { chatTextAttachmentName } from '../../../../src/shared/native-chat-text-attachment-references' +import { MOBILE_BRIDGE_MAX_FRAME_BYTES } from '../../../../src/shared/mobile-bridge-frame-bytes' +import { useClipboardWriter } from '../../platform/clipboard' +import { getUiIntlLocale } from '../../i18n/ui-intl-locale' +import { chatAttachmentPreviewStyles as styles } from '../preview/mobile-file-preview-styles' +import type { MobileFilePreviewRouteParams } from '../preview/mobile-file-preview-route' const useAttachmentTextPreview = createAttachmentTextPreviewHook({ useState, diff --git a/mobile/src/files/mobile-chat-attachment-read.ts b/mobile/src/files/attachments/mobile-chat-attachment-read.ts similarity index 83% rename from mobile/src/files/mobile-chat-attachment-read.ts rename to mobile/src/files/attachments/mobile-chat-attachment-read.ts index c3f2953ff98..0a9ae36f7a8 100644 --- a/mobile/src/files/mobile-chat-attachment-read.ts +++ b/mobile/src/files/attachments/mobile-chat-attachment-read.ts @@ -1,14 +1,14 @@ -import { sharedPromiseSettlementWaiters } from '../../../src/shared/promise-settlement-waiters' -import { hostStatusProbe } from '../transport/connection/host-status-probe-operations' -import { readHostStatusReply } from '../query/host-status-query' -import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc/rpc-operation' -import { rpcResultVariant } from '../transport/rpc/rpc-operation-result-reader' +import { sharedPromiseSettlementWaiters } from '../../../../src/shared/promise-settlement-waiters' +import { hostStatusProbe } from '../../transport/connection/host-status-probe-operations' +import { readHostStatusReply } from '../../query/host-status-query' +import { bindDeferredRpcOperation, defineRpcOperation } from '../../transport/rpc/rpc-operation' +import { rpcResultVariant } from '../../transport/rpc/rpc-operation-result-reader' import { attachmentTextChunkSchema, type AttachmentTextRead -} from '../../../src/shared/attachment-text/attachment-text-source' -import type { RpcClient } from '../transport/rpc/rpc-client-contract' -import type { UnvalidatedRpcRequestPort } from '../transport/rpc/unvalidated-rpc-request-port' +} from '../../../../src/shared/attachment-text/attachment-text-source' +import type { RpcClient } from '../../transport/rpc/rpc-client-contract' +import type { UnvalidatedRpcRequestPort } from '../../transport/rpc/unvalidated-rpc-request-port' // Match the shared wire identifier without importing the host capability aggregate into this route. const AGENT_SESSION_TEXT_ATTACHMENT_READ_CAPABILITY = 'native-chat.text-attachment-read.v1' diff --git a/mobile/src/files/MobileFileExplorerPanel.test.ts b/mobile/src/files/explorer/MobileFileExplorerPanel.test.ts similarity index 95% rename from mobile/src/files/MobileFileExplorerPanel.test.ts rename to mobile/src/files/explorer/MobileFileExplorerPanel.test.ts index 4d3863a0356..dcf2feb3561 100644 --- a/mobile/src/files/MobileFileExplorerPanel.test.ts +++ b/mobile/src/files/explorer/MobileFileExplorerPanel.test.ts @@ -4,12 +4,12 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, describe, expect, it, vi } from 'vitest' import { MobileFileExplorerPanel } from './MobileFileExplorerPanel' import type { MobileDirEntry } from './file-tree' -import { createOrcaQueryClient } from '../query/orca-query-client' -import { settleQueryNotifications } from '../test-support/rpc-recording/hook-mount' -import type { RpcClient } from '../transport/rpc/rpc-client-contract' -import { stubRpcClient } from '../transport/rpc/rpc-client-test-stub' -import type { ConnectionState } from '../transport/connection/connection-state' -import type { RpcResponse } from '../transport/rpc/rpc-wire-types' +import { createOrcaQueryClient } from '../../query/orca-query-client' +import { settleQueryNotifications } from '../../test-support/rpc-recording/hook-mount' +import type { RpcClient } from '../../transport/rpc/rpc-client-contract' +import { stubRpcClient } from '../../transport/rpc/rpc-client-test-stub' +import type { ConnectionState } from '../../transport/connection/connection-state' +import type { RpcResponse } from '../../transport/rpc/rpc-wire-types' const mounted: ReactTestRenderer[] = [] @@ -25,7 +25,7 @@ const mockTransport = vi.hoisted( }) ) -vi.mock('../platform/use-reduced-motion', () => ({ useReducedMotionEnabled: () => true })) +vi.mock('../../platform/use-reduced-motion', () => ({ useReducedMotionEnabled: () => true })) vi.mock('react-native-reanimated', () => ({ Easing: { bezier: () => (t: number) => t }, default: { View: 'AnimatedView' }, @@ -110,7 +110,7 @@ vi.mock('lucide-react-native', () => ({ X: 'X' })) -vi.mock('../platform/haptics', () => ({ +vi.mock('../../platform/haptics', () => ({ triggerSelection: vi.fn() })) @@ -118,7 +118,7 @@ vi.mock('./mobile-file-explorer-row-actions-sheet', () => ({ useMobileFileExplorerRowActions: () => ({ openRowActions: () => {}, rowActionsSheet: null }) })) -vi.mock('../transport/connection/host-client-hooks', () => ({ +vi.mock('../../transport/connection/host-client-hooks', () => ({ useForceReconnect: () => mockTransport.forceReconnect, useHostClient: () => ({ client: mockTransport.client, diff --git a/mobile/src/files/MobileFileExplorerPanel.tsx b/mobile/src/files/explorer/MobileFileExplorerPanel.tsx similarity index 90% rename from mobile/src/files/MobileFileExplorerPanel.tsx rename to mobile/src/files/explorer/MobileFileExplorerPanel.tsx index b0bcb0dea2c..d87d3061071 100644 --- a/mobile/src/files/MobileFileExplorerPanel.tsx +++ b/mobile/src/files/explorer/MobileFileExplorerPanel.tsx @@ -17,38 +17,38 @@ import { type QueryKey, type UseQueryResult } from '@tanstack/react-query' -import { useWorkspaceExecutionHost } from '../navigation/workspace-execution-host' -import { useWorkspaceRouteHandoff } from '../navigation/workspace-route-handoff' -import { useHostQueries, useHostQuery } from '../query/host-query' -import { hostQueryKey, hostScopeKey } from '../query/host-query-key' -import { hostScreenState } from '../query/host-query-screen-state' -import { useForceReconnect } from '../transport/connection/host-client-hooks' -import { useWorkspaceClient } from '../worktree/use-workspace-client' -import { connectionRetryAction } from '../transport/connection/connection-retry-action' -import type { ConnectionState } from '../transport/connection/connection-state' -import { getWorktreeLabel } from '../worktree/worktree-label' +import { useWorkspaceExecutionHost } from '../../navigation/workspace-execution-host' +import { useWorkspaceRouteHandoff } from '../../navigation/workspace-route-handoff' +import { useHostQueries, useHostQuery } from '../../query/host-query' +import { hostQueryKey, hostScopeKey } from '../../query/host-query-key' +import { hostScreenState } from '../../query/host-query-screen-state' +import { useForceReconnect } from '../../transport/connection/host-client-hooks' +import { useWorkspaceClient } from '../../worktree/use-workspace-client' +import { connectionRetryAction } from '../../transport/connection/connection-retry-action' +import type { ConnectionState } from '../../transport/connection/connection-state' +import { getWorktreeLabel } from '../../worktree/worktree-label' import type { DirectoryCache, DirectoryState, FileExplorerRow } from './file-tree' -import { spacing } from '../theme/mobile-theme' -import { useMobileColorScheme } from '../theme/use-mobile-color-scheme' +import { spacing } from '../../theme/mobile-theme' +import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme' import { fileDirectoryQuery, type DirectoryRead } from './mobile-file-explorer-operations' import { fileExplorerStyles as styles } from './mobile-file-explorer-styles' import { useMobileFileExplorerRows } from './mobile-file-explorer-rows' import { MobileFileExplorerRow } from './mobile-file-explorer-row' import { useMobileFileExplorerRowActions } from './mobile-file-explorer-row-actions-sheet' import { useMobileFileExplorerPaging } from './use-mobile-file-explorer-paging' -import { useRefreshHint } from '../query/refresh-hint' -import { navigateToMobileFilePreview } from './mobile-file-preview-navigation' +import { useRefreshHint } from '../../query/refresh-hint' +import { navigateToMobileFilePreview } from '../preview/mobile-file-preview-navigation' import { MobileLoadErrorState, MobileNoMatchState, MobileOfflineState, MobileStateView -} from '../components/MobileStateView' -import { BottomSearchBar } from '../components/nav-bar/BottomSearchBar' -import { NavBar } from '../components/nav-bar/NavBar' -import { NavBarButton } from '../components/nav-bar/NavBarButton' -import { translate } from '../i18n/mobile-i18n' -import { useMobileTranslation } from '../i18n/use-mobile-translation' +} from '../../components/MobileStateView' +import { BottomSearchBar } from '../../components/nav-bar/BottomSearchBar' +import { NavBar } from '../../components/nav-bar/NavBar' +import { NavBarButton } from '../../components/nav-bar/NavBarButton' +import { translate } from '../../i18n/mobile-i18n' +import { useMobileTranslation } from '../../i18n/use-mobile-translation' const ROOT = { relativePath: '' } const NO_FOLDERS: ReadonlySet = new Set() diff --git a/mobile/src/files/file-explorer-reply-schema.ts b/mobile/src/files/explorer/file-explorer-reply-schema.ts similarity index 92% rename from mobile/src/files/file-explorer-reply-schema.ts rename to mobile/src/files/explorer/file-explorer-reply-schema.ts index f02c6492e4f..0d91d11e337 100644 --- a/mobile/src/files/file-explorer-reply-schema.ts +++ b/mobile/src/files/explorer/file-explorer-reply-schema.ts @@ -1,5 +1,5 @@ import * as z from 'zod' -import { salvagedOptional, salvagingArray } from '../../../src/shared/zod-salvage' +import { salvagedOptional, salvagingArray } from '../../../../src/shared/zod-salvage' // Require an array because the cache sorts/walks it unguarded. Drop rows without name or isDirectory; salvage decorative isSymlink. export const fileDirectoryEntriesSchema = salvagingArray( diff --git a/mobile/src/files/file-list-fallback.ts b/mobile/src/files/explorer/file-list-fallback.ts similarity index 100% rename from mobile/src/files/file-list-fallback.ts rename to mobile/src/files/explorer/file-list-fallback.ts diff --git a/mobile/src/files/file-tree.test.ts b/mobile/src/files/explorer/file-tree.test.ts similarity index 100% rename from mobile/src/files/file-tree.test.ts rename to mobile/src/files/explorer/file-tree.test.ts diff --git a/mobile/src/files/file-tree.ts b/mobile/src/files/explorer/file-tree.ts similarity index 97% rename from mobile/src/files/file-tree.ts rename to mobile/src/files/explorer/file-tree.ts index eeecd29128e..9d283aecfd1 100644 --- a/mobile/src/files/file-tree.ts +++ b/mobile/src/files/explorer/file-tree.ts @@ -1,5 +1,5 @@ -import { MEDIA_FILE_MIME_TYPES } from '../../../src/shared/file-preview/media-mime-types' -import { compareFileNames } from '../../../src/shared/file-listing/file-name-sort' +import { MEDIA_FILE_MIME_TYPES } from '../../../../src/shared/file-preview/media-mime-types' +import { compareFileNames } from '../../../../src/shared/file-listing/file-name-sort' export type MobileDirEntry = { name: string diff --git a/mobile/src/files/mobile-file-explorer-operations.ts b/mobile/src/files/explorer/mobile-file-explorer-operations.ts similarity index 90% rename from mobile/src/files/mobile-file-explorer-operations.ts rename to mobile/src/files/explorer/mobile-file-explorer-operations.ts index 5e3e7f35ea4..d3858727544 100644 --- a/mobile/src/files/mobile-file-explorer-operations.ts +++ b/mobile/src/files/explorer/mobile-file-explorer-operations.ts @@ -1,11 +1,14 @@ -import { translate } from '../i18n/mobile-i18n' -import { defineHostQuery } from '../query/host-query' -import type { RpcClient } from '../transport/rpc/rpc-client-contract' -import { isMobileMethodUnavailableError } from '../transport/rpc/mobile-method-unavailable' -import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc/rpc-operation' -import { rpcResultVariant, rpcResultVariants } from '../transport/rpc/rpc-operation-result-reader' -import { FILE_READ_DIR_MAX_PAGE_ENTRIES } from '../../../src/shared/file-listing/file-dir-page' -import { compareDirEntries } from '../../../src/shared/file-listing/file-name-sort' +import { translate } from '../../i18n/mobile-i18n' +import { defineHostQuery } from '../../query/host-query' +import type { RpcClient } from '../../transport/rpc/rpc-client-contract' +import { isMobileMethodUnavailableError } from '../../transport/rpc/mobile-method-unavailable' +import { bindDeferredRpcOperation, defineRpcOperation } from '../../transport/rpc/rpc-operation' +import { + rpcResultVariant, + rpcResultVariants +} from '../../transport/rpc/rpc-operation-result-reader' +import { FILE_READ_DIR_MAX_PAGE_ENTRIES } from '../../../../src/shared/file-listing/file-dir-page' +import { compareDirEntries } from '../../../../src/shared/file-listing/file-name-sort' import { fileDirectoryEntriesSchema, fileDirectoryPageSchema, diff --git a/mobile/src/files/mobile-file-explorer-row-actions-sheet.tsx b/mobile/src/files/explorer/mobile-file-explorer-row-actions-sheet.tsx similarity index 83% rename from mobile/src/files/mobile-file-explorer-row-actions-sheet.tsx rename to mobile/src/files/explorer/mobile-file-explorer-row-actions-sheet.tsx index 5a1f0db5025..1fb90aa511b 100644 --- a/mobile/src/files/mobile-file-explorer-row-actions-sheet.tsx +++ b/mobile/src/files/explorer/mobile-file-explorer-row-actions-sheet.tsx @@ -1,9 +1,9 @@ import { useState } from 'react' import { Copy } from 'lucide-react-native' -import { ActionSheetModal, type ActionSheetAction } from '../components/ActionSheetModal' -import { useMobileTranslation } from '../i18n/use-mobile-translation' -import { useClipboardWriter } from '../platform/clipboard' -import { triggerError, triggerSuccess } from '../platform/haptics' +import { ActionSheetModal, type ActionSheetAction } from '../../components/ActionSheetModal' +import { useMobileTranslation } from '../../i18n/use-mobile-translation' +import { useClipboardWriter } from '../../platform/clipboard' +import { triggerError, triggerSuccess } from '../../platform/haptics' import type { TreeNode } from './file-tree' /** Which row's actions are open, and the sheet that shows them. */ diff --git a/mobile/src/files/mobile-file-explorer-row.tsx b/mobile/src/files/explorer/mobile-file-explorer-row.tsx similarity index 95% rename from mobile/src/files/mobile-file-explorer-row.tsx rename to mobile/src/files/explorer/mobile-file-explorer-row.tsx index c6923495607..c1da0c60af6 100644 --- a/mobile/src/files/mobile-file-explorer-row.tsx +++ b/mobile/src/files/explorer/mobile-file-explorer-row.tsx @@ -1,4 +1,4 @@ -import { classifyMobileArtifact } from './mobile-artifact-kind' +import { classifyMobileArtifact } from '../preview/mobile-artifact-kind' import { ActivityIndicator, Pressable, Text, View } from 'react-native' import { ChevronDown, @@ -8,10 +8,10 @@ import { Folder, Image as ImageIcon } from 'lucide-react-native' -import { triggerMediumImpact, triggerSelection } from '../platform/haptics' -import { useMobileTranslation } from '../i18n/use-mobile-translation' -import { colors, spacing } from '../theme/mobile-theme' -import { useMobileColorScheme } from '../theme/use-mobile-color-scheme' +import { triggerMediumImpact, triggerSelection } from '../../platform/haptics' +import { useMobileTranslation } from '../../i18n/use-mobile-translation' +import { colors, spacing } from '../../theme/mobile-theme' +import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme' import { DIRECTORY_PAGE_SIZE, type FileExplorerRow, @@ -20,9 +20,9 @@ import { type TreeNode } from './file-tree' import type { FolderPagingStatus } from './use-mobile-file-explorer-paging' -import { getUiIntlLocale } from '../i18n/ui-intl-locale' +import { getUiIntlLocale } from '../../i18n/ui-intl-locale' import { fileExplorerStyles as styles } from './mobile-file-explorer-styles' -import { canPreviewMobileFileRow } from './mobile-file-preview-navigation' +import { canPreviewMobileFileRow } from '../preview/mobile-file-preview-navigation' type Props = { item: FileExplorerRow diff --git a/mobile/src/files/mobile-file-explorer-rows.tsx b/mobile/src/files/explorer/mobile-file-explorer-rows.tsx similarity index 100% rename from mobile/src/files/mobile-file-explorer-rows.tsx rename to mobile/src/files/explorer/mobile-file-explorer-rows.tsx diff --git a/mobile/src/files/mobile-file-explorer-styles.ts b/mobile/src/files/explorer/mobile-file-explorer-styles.ts similarity index 94% rename from mobile/src/files/mobile-file-explorer-styles.ts rename to mobile/src/files/explorer/mobile-file-explorer-styles.ts index 4233138fe49..88b1e8589f1 100644 --- a/mobile/src/files/mobile-file-explorer-styles.ts +++ b/mobile/src/files/explorer/mobile-file-explorer-styles.ts @@ -1,6 +1,6 @@ import { StyleSheet } from 'react-native' -import { colors, radii, spacing, typography } from '../theme/mobile-theme' -import { themedStyleSheet } from '../theme/themed-styles' +import { colors, radii, spacing, typography } from '../../theme/mobile-theme' +import { themedStyleSheet } from '../../theme/themed-styles' export const fileExplorerStyles = themedStyleSheet(() => ({ container: { diff --git a/mobile/src/files/native-chat-file-search-operations.ts b/mobile/src/files/explorer/native-chat-file-search-operations.ts similarity index 84% rename from mobile/src/files/native-chat-file-search-operations.ts rename to mobile/src/files/explorer/native-chat-file-search-operations.ts index 8f39e7d5873..f23e7279c76 100644 --- a/mobile/src/files/native-chat-file-search-operations.ts +++ b/mobile/src/files/explorer/native-chat-file-search-operations.ts @@ -1,6 +1,6 @@ -import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc/rpc-operation' -import { rpcResultVariant } from '../transport/rpc/rpc-operation-result-reader' -import '../terminal/terminal-reply-schema' +import { bindDeferredRpcOperation, defineRpcOperation } from '../../transport/rpc/rpc-operation' +import { rpcResultVariant } from '../../transport/rpc/rpc-operation-result-reader' +import '../../terminal/terminal-reply-schema' import { workspaceFilePathsSchema } from './workspace-file-paths-reply-schema' // Skip refusals to retain suggestions; inspect method_not_found first to fall back from search to inventory. diff --git a/mobile/src/files/use-mobile-file-explorer-paging.ts b/mobile/src/files/explorer/use-mobile-file-explorer-paging.ts similarity index 93% rename from mobile/src/files/use-mobile-file-explorer-paging.ts rename to mobile/src/files/explorer/use-mobile-file-explorer-paging.ts index 767e4294d5b..2b004108626 100644 --- a/mobile/src/files/use-mobile-file-explorer-paging.ts +++ b/mobile/src/files/explorer/use-mobile-file-explorer-paging.ts @@ -1,9 +1,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useQueryClient } from '@tanstack/react-query' -import { FILE_READ_DIR_MAX_PAGE_ENTRIES } from '../../../src/shared/file-listing/file-dir-page' -import { hostQueryKey, type HostQueryTarget } from '../query/host-query-key' -import { hostReadClient } from '../query/host-read-client' -import type { RpcClient } from '../transport/rpc/rpc-client-contract' +import { FILE_READ_DIR_MAX_PAGE_ENTRIES } from '../../../../src/shared/file-listing/file-dir-page' +import { hostQueryKey, type HostQueryTarget } from '../../query/host-query-key' +import { hostReadClient } from '../../query/host-read-client' +import type { RpcClient } from '../../transport/rpc/rpc-client-contract' import { DIRECTORY_PAGE_SIZE } from './file-tree' import { fileDirectoryQuery, diff --git a/mobile/src/files/workspace-file-paths-reply-schema.ts b/mobile/src/files/explorer/workspace-file-paths-reply-schema.ts similarity index 84% rename from mobile/src/files/workspace-file-paths-reply-schema.ts rename to mobile/src/files/explorer/workspace-file-paths-reply-schema.ts index 743c7eb409f..6f86037daea 100644 --- a/mobile/src/files/workspace-file-paths-reply-schema.ts +++ b/mobile/src/files/explorer/workspace-file-paths-reply-schema.ts @@ -1,5 +1,5 @@ import * as z from 'zod' -import { salvagedOptional, salvagingArray } from '../../../src/shared/zod-salvage' +import { salvagedOptional, salvagingArray } from '../../../../src/shared/zod-salvage' // Keep legacy missing/unreadable file lists empty; validate the container before projecting nonempty relative paths. export const workspaceFilePathsSchema = z diff --git a/mobile/src/files/MobileFileMarkdownPreview.tsx b/mobile/src/files/markdown/MobileFileMarkdownPreview.tsx similarity index 79% rename from mobile/src/files/MobileFileMarkdownPreview.tsx rename to mobile/src/files/markdown/MobileFileMarkdownPreview.tsx index 6ee17d248e5..1a2b08723f2 100644 --- a/mobile/src/files/MobileFileMarkdownPreview.tsx +++ b/mobile/src/files/markdown/MobileFileMarkdownPreview.tsx @@ -1,15 +1,15 @@ import { MobileMarkdownPreviewImage } from './MobileMarkdownPreviewImage' -import type { MobileFilePreviewRpcSender } from './mobile-file-preview-operations' +import type { MobileFilePreviewRpcSender } from '../preview/mobile-file-preview-operations' import { ScrollView } from 'react-native' -import { MobileMarkdown } from '../components/MobileMarkdown' -import { MOBILE_MARKDOWN_DOCUMENT_SCALE } from '../components/mobile-markdown-styles' +import { MobileMarkdown } from '../../components/MobileMarkdown' +import { MOBILE_MARKDOWN_DOCUMENT_SCALE } from '../../components/mobile-markdown-styles' import { MobileFilePreviewSourceText, MobileFilePreviewTruncatedNote -} from './MobileFilePreviewSourceText' +} from '../preview/MobileFilePreviewSourceText' import type { MarkdownPreviewMode } from './MobileMarkdownPreviewModeBar' -import { filePreviewStyles as styles } from './mobile-file-preview-styles' -import { useMobileColorScheme } from '../theme/use-mobile-color-scheme' +import { filePreviewStyles as styles } from '../preview/mobile-file-preview-styles' +import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme' type Props = { mode: MarkdownPreviewMode diff --git a/mobile/src/files/MobileMarkdownPreviewImage.tsx b/mobile/src/files/markdown/MobileMarkdownPreviewImage.tsx similarity index 82% rename from mobile/src/files/MobileMarkdownPreviewImage.tsx rename to mobile/src/files/markdown/MobileMarkdownPreviewImage.tsx index d3c61a10a26..07dd6e735eb 100644 --- a/mobile/src/files/MobileMarkdownPreviewImage.tsx +++ b/mobile/src/files/markdown/MobileMarkdownPreviewImage.tsx @@ -1,15 +1,15 @@ import { useEffect, useState } from 'react' import { ActivityIndicator, Image, Pressable, Text, View } from 'react-native' -import { colors, spacing, typography, pressedStyle } from '../theme/mobile-theme' -import { themedStyleSheet } from '../theme/themed-styles' -import { useMobileColorScheme } from '../theme/use-mobile-color-scheme' -import { MobileLoadErrorState } from '../components/MobileStateView' -import { MobileNativeChatImageViewer } from '../session/native-chat/visual/MobileNativeChatImageViewer' -import { MobileImageViewerActions } from '../session/native-chat/visual/MobileImageViewerActions' -import type { MobileFilePreviewRpcSender } from './mobile-file-preview-operations' +import { colors, spacing, typography, pressedStyle } from '../../theme/mobile-theme' +import { themedStyleSheet } from '../../theme/themed-styles' +import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme' +import { MobileLoadErrorState } from '../../components/MobileStateView' +import { MobileNativeChatImageViewer } from '../../session/native-chat/visual/MobileNativeChatImageViewer' +import { MobileImageViewerActions } from '../../session/native-chat/visual/MobileImageViewerActions' +import type { MobileFilePreviewRpcSender } from '../preview/mobile-file-preview-operations' import { loadMarkdownPreviewImage } from './mobile-markdown-image-source' -import { translate } from '../i18n/mobile-i18n' -import { useMobileTranslation } from '../i18n/use-mobile-translation' +import { translate } from '../../i18n/mobile-i18n' +import { useMobileTranslation } from '../../i18n/use-mobile-translation' type Props = { client: MobileFilePreviewRpcSender | null diff --git a/mobile/src/files/MobileMarkdownPreviewModeBar.tsx b/mobile/src/files/markdown/MobileMarkdownPreviewModeBar.tsx similarity index 85% rename from mobile/src/files/MobileMarkdownPreviewModeBar.tsx rename to mobile/src/files/markdown/MobileMarkdownPreviewModeBar.tsx index 5a85333f346..7a8371c1bb9 100644 --- a/mobile/src/files/MobileMarkdownPreviewModeBar.tsx +++ b/mobile/src/files/markdown/MobileMarkdownPreviewModeBar.tsx @@ -1,10 +1,10 @@ import { useState } from 'react' import { View } from 'react-native' -import { MobileSegmentedControl } from '../components/MobileSegmentedControl' -import { MobileSourceCopyButton } from '../components/MobileSourceCopyButton' -import { useMobileTranslation } from '../i18n/use-mobile-translation' -import { useMobileColorScheme } from '../theme/use-mobile-color-scheme' -import { filePreviewStyles as styles } from './mobile-file-preview-styles' +import { MobileSegmentedControl } from '../../components/MobileSegmentedControl' +import { MobileSourceCopyButton } from '../../components/MobileSourceCopyButton' +import { useMobileTranslation } from '../../i18n/use-mobile-translation' +import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme' +import { filePreviewStyles as styles } from '../preview/mobile-file-preview-styles' export type MarkdownPreviewMode = 'preview' | 'source' diff --git a/mobile/src/files/mobile-markdown-file-link.test.ts b/mobile/src/files/markdown/mobile-markdown-file-link.test.ts similarity index 96% rename from mobile/src/files/mobile-markdown-file-link.test.ts rename to mobile/src/files/markdown/mobile-markdown-file-link.test.ts index 34d926ef441..412dc80519f 100644 --- a/mobile/src/files/mobile-markdown-file-link.test.ts +++ b/mobile/src/files/markdown/mobile-markdown-file-link.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { routeMarkdownHref } from '../components/markdown-href-routing' +import { routeMarkdownHref } from '../../components/markdown-href-routing' import { mobileMarkdownFileLink } from './mobile-markdown-file-link' const params = { diff --git a/mobile/src/files/mobile-markdown-file-link.ts b/mobile/src/files/markdown/mobile-markdown-file-link.ts similarity index 86% rename from mobile/src/files/mobile-markdown-file-link.ts rename to mobile/src/files/markdown/mobile-markdown-file-link.ts index c5f8ea0552b..9d8a615d059 100644 --- a/mobile/src/files/mobile-markdown-file-link.ts +++ b/mobile/src/files/markdown/mobile-markdown-file-link.ts @@ -2,12 +2,12 @@ import { isWindowsAbsolutePathLike, relativePathInsideRoot, resolveRuntimePath -} from '../../../src/shared/cross-platform-path' -import { splitFilePathLineSuffix } from '../components/markdown-file-path-detection' +} from '../../../../src/shared/cross-platform-path' +import { splitFilePathLineSuffix } from '../../components/markdown-file-path-detection' import { createMobileFilePreviewHref, type MobileFilePreviewRouteParams -} from './mobile-file-preview-route' +} from '../preview/mobile-file-preview-route' export function mobileMarkdownFileLink(params: MobileFilePreviewRouteParams, pathText: string) { if (params.source === 'terminalArtifact' || !params.relativePath) { diff --git a/mobile/src/files/mobile-markdown-image-source.test.ts b/mobile/src/files/markdown/mobile-markdown-image-source.test.ts similarity index 55% rename from mobile/src/files/mobile-markdown-image-source.test.ts rename to mobile/src/files/markdown/mobile-markdown-image-source.test.ts index 547969f1852..0aa0fb60404 100644 --- a/mobile/src/files/mobile-markdown-image-source.test.ts +++ b/mobile/src/files/markdown/mobile-markdown-image-source.test.ts @@ -30,28 +30,4 @@ describe('Markdown preview images', () => { }) } ) - - it.each([ - '../../private.png', - '/private.png', - 'C:\\private.png', - 'file:///tmp/private.png', - 'javascript:alert(1)', - 'notes.txt' - ])('does not read unauthorized or unsupported image locations: %s', async (href) => { - const client = host() - await expect(loadMarkdownPreviewImage(client, 'wt', 'docs/readme.md', href)).rejects.toThrow() - expect(client.sendRequest).not.toHaveBeenCalled() - }) - - it.each(['/Users/me/notes/readme.md', 'C:\\notes\\readme.md'])( - 'does not read workspace images for a document outside the workspace: %s', - async (documentPath) => { - const client = host() - await expect( - loadMarkdownPreviewImage(client, 'wt', documentPath, 'images/d.png') - ).rejects.toThrow() - expect(client.sendRequest).not.toHaveBeenCalled() - } - ) }) diff --git a/mobile/src/files/mobile-markdown-image-source.ts b/mobile/src/files/markdown/mobile-markdown-image-source.ts similarity index 79% rename from mobile/src/files/mobile-markdown-image-source.ts rename to mobile/src/files/markdown/mobile-markdown-image-source.ts index de157ae1db5..209467b11d1 100644 --- a/mobile/src/files/mobile-markdown-image-source.ts +++ b/mobile/src/files/markdown/mobile-markdown-image-source.ts @@ -2,12 +2,12 @@ import { isWindowsAbsolutePathLike, relativePathInsideRoot, resolveRuntimePath -} from '../../../src/shared/cross-platform-path' -import { routeMarkdownHref } from '../components/markdown-href-routing' -import type { MobileFilePreviewRpcSender } from './mobile-file-preview-operations' -import { loadMobileFilePreview } from './mobile-file-preview-request' -import { classifyMobileArtifact } from './mobile-artifact-kind' -import { svgMarkupDataUri } from '../../../src/shared/file-preview/svg-document' +} from '../../../../src/shared/cross-platform-path' +import { routeMarkdownHref } from '../../components/markdown-href-routing' +import type { MobileFilePreviewRpcSender } from '../preview/mobile-file-preview-operations' +import { loadMobileFilePreview } from '../preview/mobile-file-preview-request' +import { classifyMobileArtifact } from '../preview/mobile-artifact-kind' +import { svgMarkupDataUri } from '../../../../src/shared/file-preview/svg-document' // Absolute paths leave the workspace; an external-file tab's document has no workspace images. function isOutsideWorkspace(path: string): boolean { diff --git a/mobile/src/files/MobileFileMediaPreview.tsx b/mobile/src/files/media/MobileFileMediaPreview.tsx similarity index 84% rename from mobile/src/files/MobileFileMediaPreview.tsx rename to mobile/src/files/media/MobileFileMediaPreview.tsx index e62bdaddff2..d9251974d7c 100644 --- a/mobile/src/files/MobileFileMediaPreview.tsx +++ b/mobile/src/files/media/MobileFileMediaPreview.tsx @@ -1,18 +1,18 @@ import { useEffect, useState } from 'react' import { ActivityIndicator, Text, View } from 'react-native' -import { colors } from '../theme/mobile-theme' -import { useMobileColorScheme } from '../theme/use-mobile-color-scheme' -import type { MobileFilePreviewRpcSender } from './mobile-file-preview-operations' +import { colors } from '../../theme/mobile-theme' +import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme' +import type { MobileFilePreviewRpcSender } from '../preview/mobile-file-preview-operations' import { PDF_MIME_TYPE, type MobileFileMedia } from './mobile-file-media' import { downloadMobileFileMedia, type MobileMediaSink } from './mobile-file-media-download' import { createMobileMediaSink } from './mobile-media-preview-cache' import { MobileMediaPlayback } from './MobileMediaPlayback' import { MobilePdfPreview } from './MobilePdfPreview' -import { filePreviewStyles as styles } from './mobile-file-preview-styles' -import { formatPreviewByteLength } from './mobile-file-preview-response' -import { MobileLoadErrorState } from '../components/MobileStateView' -import { translate } from '../i18n/mobile-i18n' -import { useMobileTranslation } from '../i18n/use-mobile-translation' +import { filePreviewStyles as styles } from '../preview/mobile-file-preview-styles' +import { formatPreviewByteLength } from '../preview/mobile-file-preview-response' +import { MobileLoadErrorState } from '../../components/MobileStateView' +import { translate } from '../../i18n/mobile-i18n' +import { useMobileTranslation } from '../../i18n/use-mobile-translation' type State = { uri: string } | { error: string } | { bytes: number; total: number } type Props = { media: MobileFileMedia; client: MobileFilePreviewRpcSender | null; title: string } diff --git a/mobile/src/files/MobileMediaPlayback.tsx b/mobile/src/files/media/MobileMediaPlayback.tsx similarity index 91% rename from mobile/src/files/MobileMediaPlayback.tsx rename to mobile/src/files/media/MobileMediaPlayback.tsx index 855532848b4..86acdc9afc4 100644 --- a/mobile/src/files/MobileMediaPlayback.tsx +++ b/mobile/src/files/media/MobileMediaPlayback.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from 'react' import { Text, View } from 'react-native' -import { useMobileColorScheme } from '../theme/use-mobile-color-scheme' -import { filePreviewStyles as styles } from './mobile-file-preview-styles' +import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme' +import { filePreviewStyles as styles } from '../preview/mobile-file-preview-styles' export type MobileMediaPlaybackProps = { uri: string; mimeType: string; title: string } diff --git a/mobile/src/files/MobilePdfPreview.tsx b/mobile/src/files/media/MobilePdfPreview.tsx similarity index 82% rename from mobile/src/files/MobilePdfPreview.tsx rename to mobile/src/files/media/MobilePdfPreview.tsx index 2fb92e4db68..d76848660ee 100644 --- a/mobile/src/files/MobilePdfPreview.tsx +++ b/mobile/src/files/media/MobilePdfPreview.tsx @@ -1,15 +1,15 @@ import { useCallback, useRef, useState } from 'react' import { useFocusEffect } from 'expo-router' import { ActivityIndicator, Text, View } from 'react-native' -import { colors } from '../theme/mobile-theme' -import { useMobileColorScheme } from '../theme/use-mobile-color-scheme' -import { usePdfOpener } from '../platform/pdf-opener' -import { PdfOpenRefusedError } from '../platform/pdf-opener-contract' +import { colors } from '../../theme/mobile-theme' +import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme' +import { usePdfOpener } from '../../platform/pdf-opener' +import { PdfOpenRefusedError } from '../../platform/pdf-opener-contract' import type { MobileFileMedia } from './mobile-file-media' -import { filePreviewStyles as styles } from './mobile-file-preview-styles' -import { MobileLoadErrorState, MobileStateButton } from '../components/MobileStateView' -import { translate } from '../i18n/mobile-i18n' -import { useMobileTranslation } from '../i18n/use-mobile-translation' +import { filePreviewStyles as styles } from '../preview/mobile-file-preview-styles' +import { MobileLoadErrorState, MobileStateButton } from '../../components/MobileStateView' +import { translate } from '../../i18n/mobile-i18n' +import { useMobileTranslation } from '../../i18n/use-mobile-translation' type State = | { status: 'opening' } diff --git a/mobile/src/files/mobile-file-media-download.ts b/mobile/src/files/media/mobile-file-media-download.ts similarity index 93% rename from mobile/src/files/mobile-file-media-download.ts rename to mobile/src/files/media/mobile-file-media-download.ts index 71ae4eaa484..29ffe879839 100644 --- a/mobile/src/files/mobile-file-media-download.ts +++ b/mobile/src/files/media/mobile-file-media-download.ts @@ -1,10 +1,10 @@ import { Buffer } from 'buffer/index.js' import * as z from 'zod' -import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc/rpc-operation' -import { rpcResultVariant } from '../transport/rpc/rpc-operation-result-reader' -import type { MobileFilePreviewRpcSender } from './mobile-file-preview-operations' +import { bindDeferredRpcOperation, defineRpcOperation } from '../../transport/rpc/rpc-operation' +import { rpcResultVariant } from '../../transport/rpc/rpc-operation-result-reader' +import type { MobileFilePreviewRpcSender } from '../preview/mobile-file-preview-operations' import type { MobileFileMedia } from './mobile-file-media' -import { isMobileMethodUnavailableError } from '../transport/rpc/mobile-method-unavailable' +import { isMobileMethodUnavailableError } from '../../transport/rpc/mobile-method-unavailable' export const MOBILE_MEDIA_CHUNK_BYTES = 384 * 1024 export const MOBILE_MEDIA_MAX_BYTES = 256 * 1024 * 1024 diff --git a/mobile/src/files/mobile-file-media.ts b/mobile/src/files/media/mobile-file-media.ts similarity index 90% rename from mobile/src/files/mobile-file-media.ts rename to mobile/src/files/media/mobile-file-media.ts index c6fbeeddb18..3a745d654e2 100644 --- a/mobile/src/files/mobile-file-media.ts +++ b/mobile/src/files/media/mobile-file-media.ts @@ -1,4 +1,4 @@ -import { MEDIA_FILE_MIME_TYPES } from '../../../src/shared/file-preview/media-mime-types' +import { MEDIA_FILE_MIME_TYPES } from '../../../../src/shared/file-preview/media-mime-types' export const PDF_MIME_TYPE = 'application/pdf' diff --git a/mobile/src/files/mobile-media-preview-cache.ts b/mobile/src/files/media/mobile-media-preview-cache.ts similarity index 100% rename from mobile/src/files/mobile-media-preview-cache.ts rename to mobile/src/files/media/mobile-media-preview-cache.ts diff --git a/mobile/src/files/file-exists-error.ts b/mobile/src/files/mutation-ownership/file-exists-error.ts similarity index 85% rename from mobile/src/files/file-exists-error.ts rename to mobile/src/files/mutation-ownership/file-exists-error.ts index 929774fb201..f00b9e913cd 100644 --- a/mobile/src/files/file-exists-error.ts +++ b/mobile/src/files/mutation-ownership/file-exists-error.ts @@ -1,4 +1,4 @@ -import '../i18n/mobile-i18n' +import '../../i18n/mobile-i18n' export function isFileExistsErrorMessage(message: string): boolean { const normalized = message.toLowerCase() diff --git a/mobile/src/files/file-ownership-reply-schema.ts b/mobile/src/files/mutation-ownership/file-ownership-reply-schema.ts similarity index 93% rename from mobile/src/files/file-ownership-reply-schema.ts rename to mobile/src/files/mutation-ownership/file-ownership-reply-schema.ts index 916fcff091f..4e8f1c07202 100644 --- a/mobile/src/files/file-ownership-reply-schema.ts +++ b/mobile/src/files/mutation-ownership/file-ownership-reply-schema.ts @@ -1,5 +1,5 @@ import * as z from 'zod' -import { salvagedOptional } from '../../../src/shared/zod-salvage' +import { salvagedOptional } from '../../../../src/shared/zod-salvage' // Ownership routes writes: unreadable routing members must fail, since salvaging them to absent could send a remote mutation locally. diff --git a/mobile/src/files/mobile-file-mutation-ownership.ts b/mobile/src/files/mutation-ownership/mobile-file-mutation-ownership.ts similarity index 85% rename from mobile/src/files/mobile-file-mutation-ownership.ts rename to mobile/src/files/mutation-ownership/mobile-file-mutation-ownership.ts index 31fd49806ec..e1bc71cb0db 100644 --- a/mobile/src/files/mobile-file-mutation-ownership.ts +++ b/mobile/src/files/mutation-ownership/mobile-file-mutation-ownership.ts @@ -1,13 +1,13 @@ -import { parseExecutionHostId } from '../../../src/shared/execution-host' -import { assertFileMutationOwnershipCapability } from '../../../src/shared/file-write/file-mutation-ownership' -import type { SshConnectionState, SshMutationExpectation } from '../../../src/shared/ssh-types' -import { translate } from '../i18n/mobile-i18n' +import { parseExecutionHostId } from '../../../../src/shared/execution-host' +import { assertFileMutationOwnershipCapability } from '../../../../src/shared/file-write/file-mutation-ownership' +import type { SshConnectionState, SshMutationExpectation } from '../../../../src/shared/ssh-types' +import { translate } from '../../i18n/mobile-i18n' import { - fileOwnershipRuntimeStatusRead, fileOwnershipSshStateRead, fileOwnershipWorktreeRead, type MobileFileOwnershipRpcSender } from './mobile-file-ownership-operations' +import { taskRuntimeStatusRead } from '../../tasks/screen-state/mobile-task-runtime-operations' const FILE_MUTATION_TIMEOUT_MS = 15_000 @@ -53,10 +53,10 @@ export async function captureMobileFileMutationOwnership( client: MobileFileOwnershipRpcSender, worktree: string ): Promise { - const statusReply = await fileOwnershipRuntimeStatusRead.request(client, undefined, { + const statusReply = await taskRuntimeStatusRead.request(client, undefined, { timeoutMs: FILE_MUTATION_TIMEOUT_MS }) - const status = fileOwnershipRuntimeStatusRead.interpret(statusReply) + const status = taskRuntimeStatusRead.interpret(statusReply) assertFileMutationOwnershipCapability(status) const worktreeReply = await fileOwnershipWorktreeRead.request( diff --git a/mobile/src/files/mobile-file-ownership-operations.ts b/mobile/src/files/mutation-ownership/mobile-file-ownership-operations.ts similarity index 83% rename from mobile/src/files/mobile-file-ownership-operations.ts rename to mobile/src/files/mutation-ownership/mobile-file-ownership-operations.ts index 15943df7f30..7db0e0cc98a 100644 --- a/mobile/src/files/mobile-file-ownership-operations.ts +++ b/mobile/src/files/mutation-ownership/mobile-file-ownership-operations.ts @@ -1,5 +1,5 @@ -import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc/rpc-operation' -import { rpcResultVariant } from '../transport/rpc/rpc-operation-result-reader' +import { bindDeferredRpcOperation, defineRpcOperation } from '../../transport/rpc/rpc-operation' +import { rpcResultVariant } from '../../transport/rpc/rpc-operation-result-reader' import { fileOwnershipSshStateSchema, fileOwnershipWorktreeSchema @@ -8,7 +8,7 @@ import { // All ownership reads must succeed before a write: refusal or malformed replies abort with a named error rather than risking the wrong host. // Reuse the Tasks runtime-status operation; this gate needs the same fields. -export { taskRuntimeStatusRead as fileOwnershipRuntimeStatusRead } from '../tasks/screen-state/mobile-task-runtime-operations' +import '../../tasks/screen-state/mobile-task-runtime-operations' export const fileOwnershipWorktreeRead = bindDeferredRpcOperation( defineRpcOperation({ diff --git a/mobile/src/files/MobileFilePreviewBody.tsx b/mobile/src/files/preview/MobileFilePreviewBody.tsx similarity index 88% rename from mobile/src/files/MobileFilePreviewBody.tsx rename to mobile/src/files/preview/MobileFilePreviewBody.tsx index 811223a0b20..7f8d5fddb3b 100644 --- a/mobile/src/files/MobileFilePreviewBody.tsx +++ b/mobile/src/files/preview/MobileFilePreviewBody.tsx @@ -1,24 +1,24 @@ -import { MobileFileMediaPreview } from './MobileFileMediaPreview' +import { MobileFileMediaPreview } from '../media/MobileFileMediaPreview' import type { MobileFilePreviewRpcSender } from './mobile-file-preview-operations' import { Text, View } from 'react-native' import { FileText } from 'lucide-react-native' -import { useMobileColorScheme } from '../theme/use-mobile-color-scheme' -import type { MobileFilePreviewResult } from './mobile-file-preview-request' -import { MobileFileMarkdownPreview } from './MobileFileMarkdownPreview' -import type { MarkdownPreviewMode } from './MobileMarkdownPreviewModeBar' +import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme' +import type { MobileFilePreviewResult } from './mobile-file-preview-response' +import { MobileFileMarkdownPreview } from '../markdown/MobileFileMarkdownPreview' +import type { MarkdownPreviewMode } from '../markdown/MobileMarkdownPreviewModeBar' import { MobileFilePreviewEditableSource } from './MobileFilePreviewEditableSource' import { MobileFilePreviewSourceText } from './MobileFilePreviewSourceText' import type { MobileFilePreviewLineColumn } from './mobile-file-preview-line-column' import { filePreviewStyles as styles } from './mobile-file-preview-styles' -import { MobilePanZoomImage } from '../components/MobilePanZoomImage' -import { MobileHtmlPreview } from '../components/MobileHtmlPreview' -import { MobileSvgPreview } from '../components/MobileSvgPreview' +import { MobilePanZoomImage } from '../../components/MobilePanZoomImage' +import { MobileHtmlPreview } from '../../components/MobileHtmlPreview' +import { MobileSvgPreview } from '../../components/MobileSvgPreview' import { MobileLoadErrorState, MobileOfflineState, MobileStateView -} from '../components/MobileStateView' -import { useMobileTranslation } from '../i18n/use-mobile-translation' +} from '../../components/MobileStateView' +import { useMobileTranslation } from '../../i18n/use-mobile-translation' type Props = { preview: MobileFilePreviewResult diff --git a/mobile/src/files/MobileFilePreviewDiscardModal.tsx b/mobile/src/files/preview/MobileFilePreviewDiscardModal.tsx similarity index 86% rename from mobile/src/files/MobileFilePreviewDiscardModal.tsx rename to mobile/src/files/preview/MobileFilePreviewDiscardModal.tsx index ddf40459f1b..50d8047746f 100644 --- a/mobile/src/files/MobileFilePreviewDiscardModal.tsx +++ b/mobile/src/files/preview/MobileFilePreviewDiscardModal.tsx @@ -1,5 +1,5 @@ -import { ConfirmModal } from '../components/ConfirmModal' -import { useMobileTranslation } from '../i18n/use-mobile-translation' +import { ConfirmModal } from '../../components/ConfirmModal' +import { useMobileTranslation } from '../../i18n/use-mobile-translation' export function MobileFilePreviewDiscardModal(props: { visible: boolean diff --git a/mobile/src/files/MobileFilePreviewEditableSource.tsx b/mobile/src/files/preview/MobileFilePreviewEditableSource.tsx similarity index 93% rename from mobile/src/files/MobileFilePreviewEditableSource.tsx rename to mobile/src/files/preview/MobileFilePreviewEditableSource.tsx index 4d41b14d780..d10c509c6d9 100644 --- a/mobile/src/files/MobileFilePreviewEditableSource.tsx +++ b/mobile/src/files/preview/MobileFilePreviewEditableSource.tsx @@ -2,9 +2,9 @@ import { useEffect, useState } from 'react' import { Text, TextInput, View } from 'react-native' import type { MobileFilePreviewLineColumn } from './mobile-file-preview-line-column' import { textOffsetForLineColumn } from './mobile-file-preview-line-column' -import { useMobileColorScheme } from '../theme/use-mobile-color-scheme' +import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme' import { filePreviewStyles as styles } from './mobile-file-preview-styles' -import { useMobileTranslation } from '../i18n/use-mobile-translation' +import { useMobileTranslation } from '../../i18n/use-mobile-translation' type Props = { title: string diff --git a/mobile/src/files/MobileFilePreviewSourceText.tsx b/mobile/src/files/preview/MobileFilePreviewSourceText.tsx similarity index 89% rename from mobile/src/files/MobileFilePreviewSourceText.tsx rename to mobile/src/files/preview/MobileFilePreviewSourceText.tsx index 1b694e801cc..d98dcbfa287 100644 --- a/mobile/src/files/MobileFilePreviewSourceText.tsx +++ b/mobile/src/files/preview/MobileFilePreviewSourceText.tsx @@ -1,12 +1,13 @@ import { useEffect, useMemo, useRef } from 'react' import { ScrollView, Text, type ScrollViewInstance } from 'react-native' -import { MobileSyntaxSegments } from '../components/MobileSyntaxSegments' -import { formatPreviewByteLength } from './mobile-file-preview-request' +import { MobileSyntaxSegments } from '../../components/MobileSyntaxSegments' +import './mobile-file-preview-request' +import { formatPreviewByteLength } from './mobile-file-preview-response' import { scrollOffsetForPreviewLine } from './mobile-file-preview-line-column' import { buildMobileFilePreviewSyntax } from './mobile-file-preview-syntax' -import { useMobileColorScheme } from '../theme/use-mobile-color-scheme' +import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme' import { filePreviewStyles as styles } from './mobile-file-preview-styles' -import { useMobileTranslation } from '../i18n/use-mobile-translation' +import { useMobileTranslation } from '../../i18n/use-mobile-translation' export function MobileFilePreviewSourceText({ relativePath, diff --git a/mobile/src/files/file-preview-reply-schema.ts b/mobile/src/files/preview/file-preview-reply-schema.ts similarity index 96% rename from mobile/src/files/file-preview-reply-schema.ts rename to mobile/src/files/preview/file-preview-reply-schema.ts index 5b84f0268b7..7aa7d223a76 100644 --- a/mobile/src/files/file-preview-reply-schema.ts +++ b/mobile/src/files/preview/file-preview-reply-schema.ts @@ -1,5 +1,5 @@ import * as z from 'zod' -import { salvagedOptional } from '../../../src/shared/zod-salvage' +import { salvagedOptional } from '../../../../src/shared/zod-salvage' // Require unguarded members, salvage typed guards to their existing fallbacks, and retain unknown for truthiness-only reads. diff --git a/mobile/src/files/mobile-artifact-kind.ts b/mobile/src/files/preview/mobile-artifact-kind.ts similarity index 100% rename from mobile/src/files/mobile-artifact-kind.ts rename to mobile/src/files/preview/mobile-artifact-kind.ts diff --git a/mobile/src/files/mobile-file-preview-line-column.ts b/mobile/src/files/preview/mobile-file-preview-line-column.ts similarity index 100% rename from mobile/src/files/mobile-file-preview-line-column.ts rename to mobile/src/files/preview/mobile-file-preview-line-column.ts diff --git a/mobile/src/files/mobile-file-preview-navigation.ts b/mobile/src/files/preview/mobile-file-preview-navigation.ts similarity index 89% rename from mobile/src/files/mobile-file-preview-navigation.ts rename to mobile/src/files/preview/mobile-file-preview-navigation.ts index e9e3fbcc5bc..f050fac9961 100644 --- a/mobile/src/files/mobile-file-preview-navigation.ts +++ b/mobile/src/files/preview/mobile-file-preview-navigation.ts @@ -1,6 +1,6 @@ -import { mobileFileMediaMime } from './mobile-file-media' +import { mobileFileMediaMime } from '../media/mobile-file-media' import { classifyMobileArtifact } from './mobile-artifact-kind' -import { defaultScheduleTimer } from '../platform/timer-scheduler' +import { defaultScheduleTimer } from '../../platform/timer-scheduler' import { createMobileFilePreviewHref, type MobileFilePreviewHref, diff --git a/mobile/src/files/mobile-file-preview-operations.ts b/mobile/src/files/preview/mobile-file-preview-operations.ts similarity index 95% rename from mobile/src/files/mobile-file-preview-operations.ts rename to mobile/src/files/preview/mobile-file-preview-operations.ts index a75f58e44df..b88b8b86a0c 100644 --- a/mobile/src/files/mobile-file-preview-operations.ts +++ b/mobile/src/files/preview/mobile-file-preview-operations.ts @@ -1,5 +1,5 @@ -import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc/rpc-operation' -import { rpcResultVariant } from '../transport/rpc/rpc-operation-result-reader' +import { bindDeferredRpcOperation, defineRpcOperation } from '../../transport/rpc/rpc-operation' +import { rpcResultVariant } from '../../transport/rpc/rpc-operation-result-reader' import { filePreviewImageSchema, filePreviewTextSchema, diff --git a/mobile/src/files/mobile-file-preview-request.test.ts b/mobile/src/files/preview/mobile-file-preview-request.test.ts similarity index 95% rename from mobile/src/files/mobile-file-preview-request.test.ts rename to mobile/src/files/preview/mobile-file-preview-request.test.ts index 89692886a59..51150105f98 100644 --- a/mobile/src/files/mobile-file-preview-request.test.ts +++ b/mobile/src/files/preview/mobile-file-preview-request.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it, vi } from 'vitest' -import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/rpc/rpc-wire-types' +import type { RpcFailure, RpcResponse, RpcSuccess } from '../../transport/rpc/rpc-wire-types' import { saveMobileTerminalArtifactPreview } from './mobile-file-preview-request' -import '../transport/rpc/rpc-incompatible-reply-error' +import '../../transport/rpc/rpc-incompatible-reply-error' function ok(result: unknown): RpcSuccess { return { id: '1', ok: true, result, _meta: { runtimeId: 'runtime-1' } } diff --git a/mobile/src/files/mobile-file-preview-request.ts b/mobile/src/files/preview/mobile-file-preview-request.ts similarity index 94% rename from mobile/src/files/mobile-file-preview-request.ts rename to mobile/src/files/preview/mobile-file-preview-request.ts index 52288a8b17b..546dba823ab 100644 --- a/mobile/src/files/mobile-file-preview-request.ts +++ b/mobile/src/files/preview/mobile-file-preview-request.ts @@ -1,8 +1,8 @@ -import { terminalArtifactPreviewMatchesBase } from './mobile-file-preview-editability' -import { mobileFileMedia } from './mobile-file-media' +import { terminalArtifactPreviewMatchesBase } from '../terminal-artifacts/mobile-file-preview-editability' +import { mobileFileMedia } from '../media/mobile-file-media' import { classifyMobileArtifact } from './mobile-artifact-kind' -import type { RpcAcceptedResult } from '../transport/rpc/rpc-accepted-result' -import type { RpcFailure, RpcResponse } from '../transport/rpc/rpc-wire-types' +import type { RpcAcceptedResult } from '../../transport/rpc/rpc-accepted-result' +import type { RpcFailure, RpcResponse } from '../../transport/rpc/rpc-wire-types' import { filePreviewImageRead, filePreviewTextRead, @@ -22,14 +22,7 @@ import { refreshTerminalArtifactSourceAfterGrantFailure, type MobileTerminalArtifactPreviewSource, type TerminalArtifactRetryOptions -} from './mobile-terminal-artifact-grant-refresh' - -export { formatPreviewByteLength, previewError } from './mobile-file-preview-response' - -export type { - MobileFilePreviewResult, - MobileFilePreviewTextKind -} from './mobile-file-preview-response' +} from '../terminal-artifacts/mobile-terminal-artifact-grant-refresh' export type MobileFilePreviewReadMethod = 'files.read' | 'files.readPreview' export type MobileTerminalArtifactPreviewReadMethod = diff --git a/mobile/src/files/mobile-file-preview-response.ts b/mobile/src/files/preview/mobile-file-preview-response.ts similarity index 94% rename from mobile/src/files/mobile-file-preview-response.ts rename to mobile/src/files/preview/mobile-file-preview-response.ts index ef2e7324d8b..3f7501613de 100644 --- a/mobile/src/files/mobile-file-preview-response.ts +++ b/mobile/src/files/preview/mobile-file-preview-response.ts @@ -1,10 +1,10 @@ -import type { MobileFileMedia } from './mobile-file-media' +import type { MobileFileMedia } from '../media/mobile-file-media' import { classifyMobileArtifact } from './mobile-artifact-kind' -import { translate } from '../i18n/mobile-i18n' -import { getUiIntlLocale } from '../i18n/ui-intl-locale' -import type { RpcFailure } from '../transport/rpc/rpc-wire-types' -import { isMarkdownPath } from './file-tree' -import { isTerminalArtifactGrantError } from './terminal-artifact-grant-error' +import { translate } from '../../i18n/mobile-i18n' +import { getUiIntlLocale } from '../../i18n/ui-intl-locale' +import type { RpcFailure } from '../../transport/rpc/rpc-wire-types' +import { isMarkdownPath } from '../explorer/file-tree' +import { isTerminalArtifactGrantError } from '../terminal-artifacts/terminal-artifact-grant-error' export type MobileFilePreviewTextKind = 'html' | 'markdown' | 'svg' | 'text' diff --git a/mobile/src/files/mobile-file-preview-retry.test.tsx b/mobile/src/files/preview/mobile-file-preview-retry.test.tsx similarity index 72% rename from mobile/src/files/mobile-file-preview-retry.test.tsx rename to mobile/src/files/preview/mobile-file-preview-retry.test.tsx index c81519c9f02..5241f3f5bc4 100644 --- a/mobile/src/files/mobile-file-preview-retry.test.tsx +++ b/mobile/src/files/preview/mobile-file-preview-retry.test.tsx @@ -1,4 +1,4 @@ -vi.mock('./MobileFileMediaPreview', () => ({ MobileFileMediaPreview: () => null })) +vi.mock('../media/MobileFileMediaPreview', () => ({ MobileFileMediaPreview: () => null })) import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, expect, it, vi } from 'vitest' @@ -39,25 +39,24 @@ vi.mock('lucide-react-native', () => ({ Save: 'Icon', WifiOff: 'Icon' })) -vi.mock('../navigation/route-handoff', () => ({ +vi.mock('../../navigation/route-handoff', () => ({ useRouteHandoff: () => ({ back: () => {}, canGoBack: () => false, push }) })) -vi.mock('../components/ConfirmModal', () => ({ ConfirmModal: () => null })) -vi.mock('./MobileFileMarkdownPreview', () => ({ +vi.mock('../../components/ConfirmModal', () => ({ ConfirmModal: () => null })) +vi.mock('../markdown/MobileFileMarkdownPreview', () => ({ MobileFileMarkdownPreview: 'MobileFileMarkdownPreview' })) -vi.mock('../components/MobileSourceCopyButton', () => ({ MobileSourceCopyButton: () => null })) +vi.mock('../../components/MobileSourceCopyButton', () => ({ MobileSourceCopyButton: () => null })) vi.mock('./MobileFilePreviewSourceText', () => ({ MobileFilePreviewSourceText: () => null })) // No shell client initially: the screen must settle on waiting. -vi.mock('../transport/connection/host-client-hooks', () => ({ +vi.mock('../../transport/connection/host-client-hooks', () => ({ useForceReconnect: () => transport.forceReconnect, useHostClient: () => ({ ...transport.connection, clientId: null }) })) -import { MobileFilePreviewScreen } from './MobileFilePreviewScreen' -import { WorkspaceExecutionHostContext } from '../navigation/workspace-execution-host' -import { resetDocumentEditDraftsForTests } from '../drafts/document-edit-draft-rows' -import type { ExecutionHostId } from '../../../src/shared/execution-host' +import { MobileFilePreviewScreen } from '../MobileFilePreviewScreen' + +import { resetDocumentEditDraftsForTests } from '../../drafts/document-edit-draft-rows' let tree: ReactTestRenderer | null = null @@ -160,33 +159,3 @@ it('keeps an editable dirty draft when a reconnect read returns a truncated file expect(editor().props.value).toBe('My unsaved changes') expect(save().props.disabled).toBe(false) }) - -it.each(['host', 'executionHost'] as const)( - 'does not carry a draft across %s authority with the same workspace and path', - async (authority) => { - serveReads(async () => previewReply('Original')) - const withExecutionHost = - (value: ExecutionHostId | undefined) => (screen: ReturnType) => - createElement(WorkspaceExecutionHostContext.Provider, { value }, screen) - const { rendered, editor, save } = await mountArtifact(withExecutionHost(undefined)) - await act(async () => editor().props.onChangeText('My unsaved changes')) - await act(async () => { - rendered.update( - withExecutionHost(authority === 'executionHost' ? 'runtime:other-server' : undefined)( - createElement(MobileFilePreviewScreen, { - route: { - ...ARTIFACT_ROUTE, - params: { - ...ARTIFACT_ROUTE.params, - hostId: authority === 'host' ? 'host-b' : 'host-a', - grantId: 'grant-b' - } - } - }) - ) - ) - }) - expect(editor().props.value).toBe('Original') - expect(save().props.disabled).toBe(true) - } -) diff --git a/mobile/src/files/mobile-file-preview-route.ts b/mobile/src/files/preview/mobile-file-preview-route.ts similarity index 98% rename from mobile/src/files/mobile-file-preview-route.ts rename to mobile/src/files/preview/mobile-file-preview-route.ts index 7c1b2c401f4..bec6960059d 100644 --- a/mobile/src/files/mobile-file-preview-route.ts +++ b/mobile/src/files/preview/mobile-file-preview-route.ts @@ -1,4 +1,4 @@ -import { translate } from '../i18n/mobile-i18n' +import { translate } from '../../i18n/mobile-i18n' export type MobileFilePreviewParamValue = string | string[] | undefined diff --git a/mobile/src/files/mobile-file-preview-source.ts b/mobile/src/files/preview/mobile-file-preview-source.ts similarity index 100% rename from mobile/src/files/mobile-file-preview-source.ts rename to mobile/src/files/preview/mobile-file-preview-source.ts diff --git a/mobile/src/files/mobile-file-preview-styles.ts b/mobile/src/files/preview/mobile-file-preview-styles.ts similarity index 95% rename from mobile/src/files/mobile-file-preview-styles.ts rename to mobile/src/files/preview/mobile-file-preview-styles.ts index f0d91330251..ac11a88eb0d 100644 --- a/mobile/src/files/mobile-file-preview-styles.ts +++ b/mobile/src/files/preview/mobile-file-preview-styles.ts @@ -1,6 +1,6 @@ import { StyleSheet } from 'react-native' -import { colors, spacing, typography } from '../theme/mobile-theme' -import { themedStyleSheet } from '../theme/themed-styles' +import { colors, spacing, typography } from '../../theme/mobile-theme' +import { themedStyleSheet } from '../../theme/themed-styles' export const filePreviewStyles = themedStyleSheet(() => ({ container: { diff --git a/mobile/src/files/mobile-file-preview-syntax.ts b/mobile/src/files/preview/mobile-file-preview-syntax.ts similarity index 93% rename from mobile/src/files/mobile-file-preview-syntax.ts rename to mobile/src/files/preview/mobile-file-preview-syntax.ts index 5a67f6d7d90..c3f4de4d689 100644 --- a/mobile/src/files/mobile-file-preview-syntax.ts +++ b/mobile/src/files/preview/mobile-file-preview-syntax.ts @@ -2,7 +2,7 @@ import { highlightMobileCode, resolveMobileSyntaxLanguage, type MobileSyntaxSegment -} from './session-documents/mobile-file-syntax' +} from '../session-documents/mobile-file-syntax' export type MobileFilePreviewSyntax = { language: string diff --git a/mobile/src/files/use-mobile-file-preview-back.test.ts b/mobile/src/files/preview/use-mobile-file-preview-back.test.ts similarity index 85% rename from mobile/src/files/use-mobile-file-preview-back.test.ts rename to mobile/src/files/preview/use-mobile-file-preview-back.test.ts index a0ff5d5ad08..f29ff872f40 100644 --- a/mobile/src/files/use-mobile-file-preview-back.test.ts +++ b/mobile/src/files/preview/use-mobile-file-preview-back.test.ts @@ -135,21 +135,4 @@ describe('leaving the file preview', () => { }) // The bundler selects use-back-claim.web.ts for the shell’s Back key; the hook uses one seam on both platforms. - it('registers once across a rebuilt handler, rather than per dependency change', () => { - const leave = vi.fn() - const renderer = render(false, leave) - expect(native.addEventListener).toHaveBeenCalledTimes(1) - expect(native.addEventListener.mock.calls[0]?.[0]).toBe('hardwareBackPress') - act(() => { - renderer.update(createElement(Screen, { hasUnsavedDraft: true, leave })) - }) - expect(native.addEventListener).toHaveBeenCalledTimes(1) - let handled = false - act(() => { - handled = native.addEventListener.mock.calls[0]?.[1]?.() ?? false - }) - expect(handled).toBe(true) - expect(leave).not.toHaveBeenCalled() - expect(held.back?.confirmingDiscard).toBe(true) - }) }) diff --git a/mobile/src/files/use-mobile-file-preview-back.ts b/mobile/src/files/preview/use-mobile-file-preview-back.ts similarity index 96% rename from mobile/src/files/use-mobile-file-preview-back.ts rename to mobile/src/files/preview/use-mobile-file-preview-back.ts index a92d10747c1..ac2f4bed6a8 100644 --- a/mobile/src/files/use-mobile-file-preview-back.ts +++ b/mobile/src/files/preview/use-mobile-file-preview-back.ts @@ -1,5 +1,5 @@ import { useCallback, useState } from 'react' -import { useBackClaim } from '../navigation/use-back-claim' +import { useBackClaim } from '../../navigation/use-back-claim' export type MobileFilePreviewBack = { confirmingDiscard: boolean diff --git a/mobile/src/files/session-documents/MobileSessionFileReader.tsx b/mobile/src/files/session-documents/MobileSessionFileReader.tsx index d87903a4fe1..957963c2f15 100644 --- a/mobile/src/files/session-documents/MobileSessionFileReader.tsx +++ b/mobile/src/files/session-documents/MobileSessionFileReader.tsx @@ -1,5 +1,5 @@ -import { MobileFileMediaPreview } from '../MobileFileMediaPreview' -import type { MobileFilePreviewRpcSender } from '../mobile-file-preview-operations' +import { MobileFileMediaPreview } from '../media/MobileFileMediaPreview' +import type { MobileFilePreviewRpcSender } from '../preview/mobile-file-preview-operations' import { useState, useEffect, useCallback, useMemo } from 'react' import { FlatList, diff --git a/mobile/src/files/file-tab-doc-reply-schema.ts b/mobile/src/files/session-documents/file-tab-doc-reply-schema.ts similarity index 96% rename from mobile/src/files/file-tab-doc-reply-schema.ts rename to mobile/src/files/session-documents/file-tab-doc-reply-schema.ts index 8a9ad61b634..8dcdbdde1dc 100644 --- a/mobile/src/files/file-tab-doc-reply-schema.ts +++ b/mobile/src/files/session-documents/file-tab-doc-reply-schema.ts @@ -1,5 +1,5 @@ import * as z from 'zod' -import { salvagedOptional } from '../../../src/shared/zod-salvage' +import { salvagedOptional } from '../../../../src/shared/zod-salvage' // Tabs publish members directly into ready documents; their readers must be stricter than the preview screen’s guarded normalizers. diff --git a/mobile/src/files/mobile-diff-image-preview.ts b/mobile/src/files/session-documents/mobile-diff-image-preview.ts similarity index 91% rename from mobile/src/files/mobile-diff-image-preview.ts rename to mobile/src/files/session-documents/mobile-diff-image-preview.ts index 41599515a67..477a0fd3eaa 100644 --- a/mobile/src/files/mobile-diff-image-preview.ts +++ b/mobile/src/files/session-documents/mobile-diff-image-preview.ts @@ -1,4 +1,4 @@ -import { buildImageDataUri } from '../../../src/shared/file-preview/image-data-uri' +import { buildImageDataUri } from '../../../../src/shared/file-preview/image-data-uri' // Only modifiedDeleted proves deletion; read failures can also return empty modifiedContent with modifiedIsBinary false. // Omit kind: the reply reader admits every non-text arm, including unknown host arms. diff --git a/mobile/src/files/mobile-file-tab-doc-operations.ts b/mobile/src/files/session-documents/mobile-file-tab-doc-operations.ts similarity index 92% rename from mobile/src/files/mobile-file-tab-doc-operations.ts rename to mobile/src/files/session-documents/mobile-file-tab-doc-operations.ts index aef0cb50328..c25926fe980 100644 --- a/mobile/src/files/mobile-file-tab-doc-operations.ts +++ b/mobile/src/files/session-documents/mobile-file-tab-doc-operations.ts @@ -1,5 +1,8 @@ -import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc/rpc-operation' -import { rpcResultVariant, rpcResultVariants } from '../transport/rpc/rpc-operation-result-reader' +import { bindDeferredRpcOperation, defineRpcOperation } from '../../transport/rpc/rpc-operation' +import { + rpcResultVariant, + rpcResultVariants +} from '../../transport/rpc/rpc-operation-result-reader' import { fileTabBinaryDiffSchema, fileTabImageSchema, diff --git a/mobile/src/files/mobile-file-tab-doc.ts b/mobile/src/files/session-documents/mobile-file-tab-doc.ts similarity index 91% rename from mobile/src/files/mobile-file-tab-doc.ts rename to mobile/src/files/session-documents/mobile-file-tab-doc.ts index 1efca7607b1..70f82ab3db2 100644 --- a/mobile/src/files/mobile-file-tab-doc.ts +++ b/mobile/src/files/session-documents/mobile-file-tab-doc.ts @@ -1,10 +1,10 @@ -import { mobileFileMedia, type MobileFileMedia } from './mobile-file-media' -import { buildImageDataUri } from '../../../src/shared/file-preview/image-data-uri' -import { classifyMobileArtifact } from './mobile-artifact-kind' +import { mobileFileMedia, type MobileFileMedia } from '../media/mobile-file-media' +import { buildImageDataUri } from '../../../../src/shared/file-preview/image-data-uri' +import { classifyMobileArtifact } from '../preview/mobile-artifact-kind' import { buildMobileDiffLines, type MobileDiffLine -} from '../source-control/review/mobile-diff-lines' +} from '../../source-control/review/mobile-diff-lines' import { mobileDiffImageDataUri } from './mobile-diff-image-preview' import { fileTabDiffRead, diff --git a/mobile/src/files/session-documents/mobile-file-tap-open.ts b/mobile/src/files/session-documents/mobile-file-tap-open.ts index f08eed88cad..52a8dc84a37 100644 --- a/mobile/src/files/session-documents/mobile-file-tap-open.ts +++ b/mobile/src/files/session-documents/mobile-file-tap-open.ts @@ -2,10 +2,10 @@ import type { ParsedFileLinkLocation } from '../../../../src/shared/file-links/f import type { RuntimeNativeChatFileContext } from '../../../../src/shared/native-chat/native-chat-file-context' import type { RuntimeTerminalPathResolution } from '../../../../src/shared/terminal/runtime-terminal-file-path' import { filesystemPathToFileUri } from '../../../../src/shared/file-links/file-uri-path' -import { createMobileFilePreviewHref } from '../mobile-file-preview-route' -import { classifyMobileArtifact } from '../mobile-artifact-kind' +import { createMobileFilePreviewHref } from '../preview/mobile-file-preview-route' +import { classifyMobileArtifact } from '../preview/mobile-artifact-kind' import { fileTapOpenRun } from './mobile-file-tap-operations' -import { terminalArtifactPathResolve as fileTapPathResolve } from '../mobile-file-preview-operations' +import { terminalArtifactPathResolve as fileTapPathResolve } from '../preview/mobile-file-preview-operations' import { mapWithConcurrency } from '../../../../src/shared/map-with-concurrency' import { shouldActivateOpenedMobileSessionTab, diff --git a/mobile/src/files/session-documents/mobile-file-tap-operations.ts b/mobile/src/files/session-documents/mobile-file-tap-operations.ts index b150abc6d3e..36b4b26c4da 100644 --- a/mobile/src/files/session-documents/mobile-file-tap-operations.ts +++ b/mobile/src/files/session-documents/mobile-file-tap-operations.ts @@ -1,6 +1,6 @@ import { bindDeferredRpcOperation, defineRpcOperation } from '../../transport/rpc/rpc-operation' import { rpcResultVariant } from '../../transport/rpc/rpc-operation-result-reader' -import '../mobile-file-preview-operations' +import '../preview/mobile-file-preview-operations' import { fileTapOpenedSchema } from './mobile-file-tap-reply-schema' import '../../agent-history/mobile-ai-vault-resume-reply-schema' import '../../browser/tab-navigation/mobile-session-browser-reply-schema' diff --git a/mobile/src/files/session-documents/mobile-markdown-reader-status.ts b/mobile/src/files/session-documents/mobile-markdown-reader-status.ts index e238cda5922..0b5958e531e 100644 --- a/mobile/src/files/session-documents/mobile-markdown-reader-status.ts +++ b/mobile/src/files/session-documents/mobile-markdown-reader-status.ts @@ -1,5 +1,5 @@ import { translate } from '../../i18n/mobile-i18n' -import { formatPreviewByteLength } from '../mobile-file-preview-response' +import { formatPreviewByteLength } from '../preview/mobile-file-preview-response' import type { MarkdownDocState } from './session-document-state' export function markdownReaderStatusText( diff --git a/mobile/src/files/session-documents/mobile-markdown-tab-operations.ts b/mobile/src/files/session-documents/mobile-markdown-tab-operations.ts index cb863e4277a..7683a7d25d4 100644 --- a/mobile/src/files/session-documents/mobile-markdown-tab-operations.ts +++ b/mobile/src/files/session-documents/mobile-markdown-tab-operations.ts @@ -1,6 +1,6 @@ import { bindDeferredRpcOperation, defineRpcOperation } from '../../transport/rpc/rpc-operation' import { rpcResultVariant } from '../../transport/rpc/rpc-operation-result-reader' -import '../mobile-file-preview-operations' +import '../preview/mobile-file-preview-operations' import { sessionLaunchUnreadReplySchema } from '../../transport/rpc/rpc-uninterpreted-reply-schemas' import '../../agent-history/mobile-ai-vault-resume-reply-schema' import '../../browser/tab-navigation/mobile-session-browser-reply-schema' diff --git a/mobile/src/files/session-documents/mobile-session-document-read-settlement.ts b/mobile/src/files/session-documents/mobile-session-document-read-settlement.ts index 39d21b702e7..af20065d729 100644 --- a/mobile/src/files/session-documents/mobile-session-document-read-settlement.ts +++ b/mobile/src/files/session-documents/mobile-session-document-read-settlement.ts @@ -1,4 +1,4 @@ -import type { MobileFileMedia } from '../mobile-file-media' +import type { MobileFileMedia } from '../media/mobile-file-media' import type { MobileDiffLine } from '../../source-control/review/mobile-diff-lines' import type { FileDocState, MarkdownDocState, ReadyFileDocState } from './session-document-state' diff --git a/mobile/src/files/session-documents/session-document-state.ts b/mobile/src/files/session-documents/session-document-state.ts index 84574cdc51a..feb12e4cec3 100644 --- a/mobile/src/files/session-documents/session-document-state.ts +++ b/mobile/src/files/session-documents/session-document-state.ts @@ -1,4 +1,4 @@ -import type { MobileFileMedia } from '../mobile-file-media' +import type { MobileFileMedia } from '../media/mobile-file-media' import type { MobileDiffLine } from '../../source-control/review/mobile-diff-lines' import type { MobileSyntaxSegment } from './mobile-file-syntax' diff --git a/mobile/src/files/session-documents/use-mobile-file-tap-handlers.ts b/mobile/src/files/session-documents/use-mobile-file-tap-handlers.ts index 6e455fdbb86..5718a80e123 100644 --- a/mobile/src/files/session-documents/use-mobile-file-tap-handlers.ts +++ b/mobile/src/files/session-documents/use-mobile-file-tap-handlers.ts @@ -1,5 +1,5 @@ import { isCommittedChatAttachmentCandidate } from '../../../../src/shared/native-chat-text-attachment-references' -import { createMobileFilePreviewHref } from '../mobile-file-preview-route' +import { createMobileFilePreviewHref } from '../preview/mobile-file-preview-route' import { useCallback, useLayoutEffect, useRef, type MutableRefObject } from 'react' import type { ParsedFileLinkLocation } from '../../../../src/shared/file-links/file-link-location' import { hostStackRouteHref } from '../../navigation/host-stack-navigation' diff --git a/mobile/src/files/session-documents/use-mobile-session-document-readers.ts b/mobile/src/files/session-documents/use-mobile-session-document-readers.ts index e89617b35d5..4aaf0fe8e44 100644 --- a/mobile/src/files/session-documents/use-mobile-session-document-readers.ts +++ b/mobile/src/files/session-documents/use-mobile-session-document-readers.ts @@ -1,10 +1,10 @@ import { useCallback, useEffect, useRef } from 'react' import type { RpcFailure } from '../../transport/rpc/rpc-wire-types' import { translate } from '../../i18n/mobile-i18n' -import { resolveMobileFileTabDoc } from '../mobile-file-tab-doc' +import { resolveMobileFileTabDoc } from './mobile-file-tab-doc' import { shouldIgnoreTrimWhitespace } from '../../../../src/shared/diff-whitespace' import { useMobileSourceControlSettings } from '../../source-control/mobile-source-control-settings' -import { filePreviewTextRead } from '../mobile-file-preview-operations' +import { filePreviewTextRead } from '../preview/mobile-file-preview-operations' import { markdownTabRead } from './mobile-markdown-tab-operations' import { buildMarkdownDiskFallbackDoc, diff --git a/mobile/src/files/MobileFilePreviewEditDraft.tsx b/mobile/src/files/terminal-artifacts/MobileFilePreviewEditDraft.tsx similarity index 71% rename from mobile/src/files/MobileFilePreviewEditDraft.tsx rename to mobile/src/files/terminal-artifacts/MobileFilePreviewEditDraft.tsx index bea1c2ab81b..6e16aa7f29d 100644 --- a/mobile/src/files/MobileFilePreviewEditDraft.tsx +++ b/mobile/src/files/terminal-artifacts/MobileFilePreviewEditDraft.tsx @@ -1,16 +1,16 @@ -import { fileEditDraftKey } from '../drafts/document-edit-draft-rows' -import { DocumentEditDraftOfferBar } from '../drafts/DocumentEditDraftOfferBar' +import { fileEditDraftKey } from '../../drafts/document-edit-draft-rows' +import { DocumentEditDraftOfferBar } from '../../drafts/DocumentEditDraftOfferBar' import { useDocumentEditDraft, type DocumentEditDraftOffer -} from '../drafts/use-document-edit-draft' -import { useClipboardWriter } from '../platform/clipboard' -import { triggerError, triggerSuccess } from '../platform/haptics' -import { useKeyboardOcclusion } from '../platform/keyboard-occlusion' -import { spacing } from '../theme/mobile-theme' +} from '../../drafts/use-document-edit-draft' +import { useClipboardWriter } from '../../platform/clipboard' +import { triggerError, triggerSuccess } from '../../platform/haptics' +import { useKeyboardOcclusion } from '../../platform/keyboard-occlusion' +import { spacing } from '../../theme/mobile-theme' import { useSafeAreaInsets } from 'react-native-safe-area-context' -import type { MobileFilePreviewSource } from './mobile-file-preview-request' -import { resolveMarkdownFloatingActionsBottom } from './session-documents/markdown-floating-actions-layout' +import type { MobileFilePreviewSource } from '../preview/mobile-file-preview-request' +import { resolveMarkdownFloatingActionsBottom } from '../session-documents/markdown-floating-actions-layout' /** Only terminal artifacts are editable in the file preview. */ export function filePreviewEditDraftKey( diff --git a/mobile/src/files/mobile-file-preview-editability.ts b/mobile/src/files/terminal-artifacts/mobile-file-preview-editability.ts similarity index 89% rename from mobile/src/files/mobile-file-preview-editability.ts rename to mobile/src/files/terminal-artifacts/mobile-file-preview-editability.ts index 14313e813df..50c1ba5bca7 100644 --- a/mobile/src/files/mobile-file-preview-editability.ts +++ b/mobile/src/files/terminal-artifacts/mobile-file-preview-editability.ts @@ -1,7 +1,5 @@ -import type { - MobileFilePreviewResult, - MobileFilePreviewSource -} from './mobile-file-preview-request' +import type { MobileFilePreviewSource } from '../preview/mobile-file-preview-request' +import type { MobileFilePreviewResult } from '../preview/mobile-file-preview-response' export function isEditableMobileTerminalArtifactPreview( preview: MobileFilePreviewResult, diff --git a/mobile/src/files/mobile-terminal-artifact-grant-refresh.ts b/mobile/src/files/terminal-artifacts/mobile-terminal-artifact-grant-refresh.ts similarity index 93% rename from mobile/src/files/mobile-terminal-artifact-grant-refresh.ts rename to mobile/src/files/terminal-artifacts/mobile-terminal-artifact-grant-refresh.ts index c3ac115c907..ea3de1e2c37 100644 --- a/mobile/src/files/mobile-terminal-artifact-grant-refresh.ts +++ b/mobile/src/files/terminal-artifacts/mobile-terminal-artifact-grant-refresh.ts @@ -1,9 +1,9 @@ -import type { RuntimeNativeChatFileContext } from '../../../src/shared/native-chat/native-chat-file-context' -import type { RpcFailure } from '../transport/rpc/rpc-wire-types' +import type { RuntimeNativeChatFileContext } from '../../../../src/shared/native-chat/native-chat-file-context' +import type { RpcFailure } from '../../transport/rpc/rpc-wire-types' import { terminalArtifactPathResolve, type MobileFilePreviewRpcSender -} from './mobile-file-preview-operations' +} from '../preview/mobile-file-preview-operations' import { isTerminalArtifactGrantError } from './terminal-artifact-grant-error' export type MobileTerminalArtifactPreviewSource = { diff --git a/mobile/src/files/terminal-artifact-grant-error.ts b/mobile/src/files/terminal-artifacts/terminal-artifact-grant-error.ts similarity index 100% rename from mobile/src/files/terminal-artifact-grant-error.ts rename to mobile/src/files/terminal-artifacts/terminal-artifact-grant-error.ts diff --git a/mobile/src/home/MobileHomeScreen.tsx b/mobile/src/home/MobileHomeScreen.tsx index 048b43c3ee9..f860dd48ea2 100644 --- a/mobile/src/home/MobileHomeScreen.tsx +++ b/mobile/src/home/MobileHomeScreen.tsx @@ -26,7 +26,7 @@ import { resolveHomeHostConnectionState } from '../transport/hosts/home-host-aut import { removeHostAndCloseClient } from '../host-catalog/host-removal-lifecycle' import { loadHostCatalog } from '../host-catalog/host-store' import type { HostCatalogEntry, HostProfile } from '../host-catalog/host-profile' -import { useOpenMobileHostEdit } from '../host-screen/use-open-mobile-host-edit' +import { useOpenMobileHostEdit } from '../host-screen/identity/use-open-mobile-host-edit' import { mobileConnectionPathLabel } from '../transport/connection/mobile-connection-path-label' import { newWorkspaceOverlayHref } from '../new-workspace/new-workspace-overlay-route' import { useMobileHomeData } from './use-mobile-home-data' diff --git a/mobile/src/home/home-query-restore.ts b/mobile/src/home/home-query-restore.ts index 67f3cda09b4..9c6760408dc 100644 --- a/mobile/src/home/home-query-restore.ts +++ b/mobile/src/home/home-query-restore.ts @@ -5,7 +5,7 @@ import { HOST_LIST_TARGET, homeWorktreeCountsKey, NO_HOST_LIST_INPUT -} from '../host-screen/host-list-queries' +} from '../host-screen/catalog/host-list-queries' import { hostObserverOptions } from '../query/host-query' import { accountsSnapshotFamily, accountsSnapshotKey } from '../accounts/accounts-snapshot-stream' import { persistedHostQueryKey } from '../query/host-query-persistence' diff --git a/mobile/src/home/use-mobile-home-data.ts b/mobile/src/home/use-mobile-home-data.ts index 86db717735c..c5cbdb23736 100644 --- a/mobile/src/home/use-mobile-home-data.ts +++ b/mobile/src/home/use-mobile-home-data.ts @@ -21,7 +21,7 @@ import { HOST_LIST_TARGET, homeWorktreeCountsKey, NO_HOST_LIST_INPUT -} from '../host-screen/host-list-queries' +} from '../host-screen/catalog/host-list-queries' import { useNow } from '../hooks/use-now' import { loadMobileOnboardingSteps, diff --git a/mobile/src/host-screen/host-list-queries.ts b/mobile/src/host-screen/catalog/host-list-queries.ts similarity index 93% rename from mobile/src/host-screen/host-list-queries.ts rename to mobile/src/host-screen/catalog/host-list-queries.ts index 08c966625e2..940935375bb 100644 --- a/mobile/src/host-screen/host-list-queries.ts +++ b/mobile/src/host-screen/catalog/host-list-queries.ts @@ -3,32 +3,32 @@ import { getRepoExecutionHostId, parseExecutionHostId, type ExecutionHostId -} from '../../../src/shared/execution-host' -import { MOBILE_RELAY_HOST_WORKTREE_DELTAS_RUNTIME_CAPABILITY } from '../../../src/shared/mobile-desktop-relay-contract' -import type { MobileRelayHost } from '../../../src/shared/remote-runtime/relay/mobile-relay-hosts-contract' +} from '../../../../src/shared/execution-host' +import { MOBILE_RELAY_HOST_WORKTREE_DELTAS_RUNTIME_CAPABILITY } from '../../../../src/shared/mobile-desktop-relay-contract' +import type { MobileRelayHost } from '../../../../src/shared/remote-runtime/relay/mobile-relay-hosts-contract' import type { Query, QueryClient } from '@tanstack/react-query' -import { defineHostQuery } from '../query/host-query' -import { hostQueryKey, type HostQueryTarget } from '../query/host-query-key' -import type { RpcClient } from '../transport/rpc/rpc-client-contract' +import { defineHostQuery } from '../../query/host-query' +import { hostQueryKey, type HostQueryTarget } from '../../query/host-query-key' +import type { RpcClient } from '../../transport/rpc/rpc-client-contract' import { applyServerWorkspaces, fetchServerWorkspaces, NO_SERVER_WORKSPACES, ServerRowSnapshots, type ServerWorkspaces -} from '../worktree/server-workspaces' +} from '../../worktree/server-workspaces' import { readWorktreeCatalogSnapshot, type WorktreeCatalogSnapshot -} from '../worktree/worktree-catalog-snapshot-client' -import type { Worktree } from '../worktree/workspace-list-types' +} from '../../worktree/worktree-catalog-snapshot-client' +import type { Worktree } from '../../worktree/workspace-list-types' import { hostRepoCatalogRepos, hostSshTargetSummarySchema, type HostRepoCatalogRow -} from './host-screen-reply-schema' -import { hostPlatformRead, hostRepoCatalogSnapshotRead } from './host-screen-operations' -import { NODE_PLATFORM_NAMES } from '../transport/connection/mobile-runtime-host-platform' +} from '../host-screen-reply-schema' +import { hostPlatformRead, hostRepoCatalogSnapshotRead } from '../host-screen-operations' +import { NODE_PLATFORM_NAMES } from '../../transport/connection/mobile-runtime-host-platform' import { acceptedMetadata, readHostSshTargetLabels, diff --git a/mobile/src/host-screen/host-repo-looks.ts b/mobile/src/host-screen/catalog/host-repo-looks.ts similarity index 88% rename from mobile/src/host-screen/host-repo-looks.ts rename to mobile/src/host-screen/catalog/host-repo-looks.ts index be34ac661e9..b096d1a88a6 100644 --- a/mobile/src/host-screen/host-repo-looks.ts +++ b/mobile/src/host-screen/catalog/host-repo-looks.ts @@ -1,7 +1,7 @@ import { useState } from 'react' -import type { SectionRepo } from '../worktree/workspace-list-types' -import { sectionRepoColor } from '../worktree/workspace-filter-repos' -import { readMobileRepoIcon, type MobileHostRepoIcon } from './host-screen-reply-schema' +import type { SectionRepo } from '../../worktree/workspace-list-types' +import { sectionRepoColor } from '../../worktree/workspace-filter-repos' +import { readMobileRepoIcon, type MobileHostRepoIcon } from '../host-screen-reply-schema' export type RepoLook = { color: string; icon: MobileHostRepoIcon | null } diff --git a/mobile/src/host-screen/host-ssh-target-labels-read.ts b/mobile/src/host-screen/catalog/host-ssh-target-labels-read.ts similarity index 74% rename from mobile/src/host-screen/host-ssh-target-labels-read.ts rename to mobile/src/host-screen/catalog/host-ssh-target-labels-read.ts index a1efa773ecb..93e391da0b1 100644 --- a/mobile/src/host-screen/host-ssh-target-labels-read.ts +++ b/mobile/src/host-screen/catalog/host-ssh-target-labels-read.ts @@ -1,10 +1,11 @@ -import { isHostNotConnectedError } from '../query/host-query-errors' -import { optionalSettingsRead } from '../settings/mobile-settings-read-operations' -import type { RpcAcceptedResult } from '../transport/rpc/rpc-accepted-result' -import type { RpcClient } from '../transport/rpc/rpc-client-contract' -import type { RpcResponse } from '../transport/rpc/rpc-wire-types' -import { buildHostLabelById } from '../worktree/worktree-host-context-labels' -import { hostSshTargetSummariesRead } from './host-screen-operations' +import { isHostNotConnectedError } from '../../query/host-query-errors' +import { optionalSettingsRead } from '../../settings/mobile-settings-read-operations' +import type { RpcAcceptedResult } from '../../transport/rpc/rpc-accepted-result' +import type { RpcClient } from '../../transport/rpc/rpc-client-contract' +import type { RpcResponse } from '../../transport/rpc/rpc-wire-types' +import '../../worktree/worktree-host-context-labels' +import { buildHostLabelById } from '../../../../src/shared/worktree/host-context-labels' +import { hostSshTargetSummariesRead } from '../host-screen-operations' export async function settledMetadataReply( send: () => Promise diff --git a/mobile/src/host-screen/use-host-project-sections.ts b/mobile/src/host-screen/catalog/use-host-project-sections.ts similarity index 81% rename from mobile/src/host-screen/use-host-project-sections.ts rename to mobile/src/host-screen/catalog/use-host-project-sections.ts index 37337359f61..1b21e70269e 100644 --- a/mobile/src/host-screen/use-host-project-sections.ts +++ b/mobile/src/host-screen/catalog/use-host-project-sections.ts @@ -1,19 +1,21 @@ import { useEffect } from 'react' import * as z from 'zod' -import type { ProjectGroup } from '../../../src/shared/project-group-types' -import { ProjectSectionCatalogSchema } from '../../../src/shared/project-section-catalog-schema' -import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../../src/shared/sidebar-inputs-switch' -import { defineHostQuery, useHostQuery } from '../query/host-query' -import type { RpcClient } from '../transport/rpc/rpc-client-contract' +import type { ProjectGroup } from '../../../../src/shared/project-group-types' +import { + ProjectSectionCatalogSchema, + type ServedProjectSections +} from '../../../../src/shared/project-section-catalog-schema' +import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../../../src/shared/sidebar-inputs-switch' +import { defineHostQuery, useHostQuery } from '../../query/host-query' +import type { RpcClient } from '../../transport/rpc/rpc-client-contract' import { hostProjectGroupsRead, projectSectionsRead, - readServedProjectSections, - type ServedProjectSections -} from '../worktree/project-sections-operations' -import { readHostMethodReply } from './host-sidebar-feed-operations' + readServedProjectSections +} from '../../worktree/project-sections-operations' +import { readHostMethodReply } from '../sidebar-feed/host-sidebar-feed-operations' import { HOST_LIST_TARGET, NO_HOST_LIST_INPUT } from './host-list-queries' -import type { HostScreenState } from './use-host-screen-state' +import type { HostScreenState } from '../use-host-screen-state' type HostProjectSections = { sections: ServedProjectSections | null diff --git a/mobile/src/host-screen/use-host-repo-icon-data-urls.ts b/mobile/src/host-screen/catalog/use-host-repo-icon-data-urls.ts similarity index 87% rename from mobile/src/host-screen/use-host-repo-icon-data-urls.ts rename to mobile/src/host-screen/catalog/use-host-repo-icon-data-urls.ts index d6552f1ec61..933a4a2efd5 100644 --- a/mobile/src/host-screen/use-host-repo-icon-data-urls.ts +++ b/mobile/src/host-screen/catalog/use-host-repo-icon-data-urls.ts @@ -1,7 +1,10 @@ import { useEffect, useRef, useState } from 'react' -import { MAX_REPO_ICON_IDS_PER_READ } from '../../../src/shared/sidebar-list/repo-icon-data-urls-contract' -import type { RpcClient } from '../transport/rpc/rpc-client-contract' -import { readHostMethodReply, sidebarRepoIconsRead } from './host-sidebar-feed-operations' +import { MAX_REPO_ICON_IDS_PER_READ } from '../../../../src/shared/sidebar-list/repo-icon-data-urls-contract' +import type { RpcClient } from '../../transport/rpc/rpc-client-contract' +import { + readHostMethodReply, + sidebarRepoIconsRead +} from '../sidebar-feed/host-sidebar-feed-operations' const NO_DATA_URLS: ReadonlyMap = new Map() diff --git a/mobile/src/host-screen/use-host-repo-metadata.ts b/mobile/src/host-screen/catalog/use-host-repo-metadata.ts similarity index 80% rename from mobile/src/host-screen/use-host-repo-metadata.ts rename to mobile/src/host-screen/catalog/use-host-repo-metadata.ts index 2d306455441..ec77527a46d 100644 --- a/mobile/src/host-screen/use-host-repo-metadata.ts +++ b/mobile/src/host-screen/catalog/use-host-repo-metadata.ts @@ -1,10 +1,10 @@ import { useEffect } from 'react' -import { getRepoExecutionHostId } from '../../../src/shared/execution-host' -import { useHostQuery } from '../query/host-query' -import type { RpcClient } from '../transport/rpc/rpc-client-contract' -import { buildRepoHostIdByRepoId } from '../worktree/worktree-host-context-labels' -import { buildSshHostHealthById } from '../worktree/worktree-host-health' -import type { SectionRepo } from '../worktree/workspace-list-types' +import { getRepoExecutionHostId } from '../../../../src/shared/execution-host' +import { useHostQuery } from '../../query/host-query' +import type { RpcClient } from '../../transport/rpc/rpc-client-contract' +import { buildRepoHostIdByRepoId } from '../../worktree/worktree-host-context-labels' +import { buildSshHostHealthById } from '../../worktree/worktree-host-health' +import type { SectionRepo } from '../../worktree/workspace-list-types' import { catalogNeedsHostLabels, HOST_LIST_TARGET, @@ -12,8 +12,8 @@ import { hostRepoLabelsQuery, NO_HOST_LIST_INPUT } from './host-list-queries' -import type { HostRepoCatalogRow } from './host-screen-reply-schema' -import type { HostScreenState } from './use-host-screen-state' +import type { HostRepoCatalogRow } from '../host-screen-reply-schema' +import type { HostScreenState } from '../use-host-screen-state' function desktopSectionRepo(repo: HostRepoCatalogRow): SectionRepo { return { diff --git a/mobile/src/host-screen/use-host-worktree-catalog.ts b/mobile/src/host-screen/catalog/use-host-worktree-catalog.ts similarity index 86% rename from mobile/src/host-screen/use-host-worktree-catalog.ts rename to mobile/src/host-screen/catalog/use-host-worktree-catalog.ts index edbad9c48fc..6a1664ebec5 100644 --- a/mobile/src/host-screen/use-host-worktree-catalog.ts +++ b/mobile/src/host-screen/catalog/use-host-worktree-catalog.ts @@ -1,33 +1,33 @@ -import { MOBILE_RELAY_HOSTS_CHANGED_EVENT_RUNTIME_CAPABILITY } from '../../../src/shared/mobile-desktop-relay-contract' -import { WORKTREE_ROWS_CHANGED_EVENT_RUNTIME_CAPABILITY } from '../../../src/shared/worktree-runtime-capabilities' -import { readWorktreeFirstPage } from '../worktree/worktree-catalog-first-page' -import type { Worktree } from '../worktree/workspace-list-types' +import { MOBILE_RELAY_HOSTS_CHANGED_EVENT_RUNTIME_CAPABILITY } from '../../../../src/shared/mobile-desktop-relay-contract' +import { WORKTREE_ROWS_CHANGED_EVENT_RUNTIME_CAPABILITY } from '../../../../src/shared/worktree-runtime-capabilities' +import { readWorktreeFirstPage } from '../../worktree/worktree-catalog-first-page' +import type { Worktree } from '../../worktree/workspace-list-types' import { useCallback, useEffect, useRef, useState } from 'react' import { AppState } from 'react-native' -import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../../src/shared/sidebar-inputs-switch' +import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../../../src/shared/sidebar-inputs-switch' import { useFocusEffect } from 'expo-router' import { useQueryClient } from '@tanstack/react-query' -import type { RpcClient } from '../transport/rpc/rpc-client-contract' -import type { ConnectionState } from '../transport/connection/connection-state' -import { RpcIncompatibleReplyError } from '../transport/rpc/rpc-incompatible-reply-error' -import { startHostWorktreeRefresh } from '../worktree/host-worktree-refresh' -import type { HostSidebarFeedTriggers } from './host-sidebar-feed-connection' -import { areWorktreeListsEqual } from '../worktree/worktree-list-snapshot' +import type { RpcClient } from '../../transport/rpc/rpc-client-contract' +import type { ConnectionState } from '../../transport/connection/connection-state' +import { RpcIncompatibleReplyError } from '../../transport/rpc/rpc-incompatible-reply-error' +import { startHostWorktreeRefresh } from '../../worktree/host-worktree-refresh' +import type { HostSidebarFeedTriggers } from '../sidebar-feed/host-sidebar-feed-connection' +import { areWorktreeListsEqual } from '../../worktree/worktree-list-snapshot' import { clearConfirmedActiveWorktreeIdentity, retainLiveSleptWorktreeIdentities -} from '../worktree/worktree-host-row-identity' -import { savePinnedIds } from '../storage/preferences' -import type { HostScreenState } from './use-host-screen-state' -import { relaysToServers } from '../worktree/execution-host-scoped-rpc-client' -import { NO_SERVER_WORKSPACES } from '../worktree/server-workspaces' -import { useRelayHosts } from '../worktree/relay-hosts-feed' -import { WorktreeCatalogReadError } from '../worktree/worktree-catalog-snapshot-client' -import { useHostQuery } from '../query/host-query' -import { hostReadClient } from '../query/host-read-client' -import { hostScopeKey } from '../query/host-query-key' -import { isHostNotConnectedError } from '../query/host-query-errors' -import { refetchHostQueryAfterInFlight } from '../query/host-event-router' +} from '../../worktree/worktree-host-row-identity' +import { savePinnedIds } from '../../storage/preferences' +import type { HostScreenState } from '../use-host-screen-state' +import { relaysToServers } from '../../worktree/execution-host-scoped-rpc-client' +import { NO_SERVER_WORKSPACES } from '../../worktree/server-workspaces' +import { useRelayHosts } from '../../worktree/relay-hosts-feed' +import { WorktreeCatalogReadError } from '../../worktree/worktree-catalog-snapshot-client' +import { useHostQuery } from '../../query/host-query' +import { hostReadClient } from '../../query/host-read-client' +import { hostScopeKey } from '../../query/host-query-key' +import { isHostNotConnectedError } from '../../query/host-query-errors' +import { refetchHostQueryAfterInFlight } from '../../query/host-event-router' import { isHostListQuery, HOST_LIST_BACKSTOP_POLL_MS, diff --git a/mobile/src/host-screen/host-edit-navigation.ts b/mobile/src/host-screen/host-edit-navigation.ts deleted file mode 100644 index c1e667c5ca2..00000000000 --- a/mobile/src/host-screen/host-edit-navigation.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { - hostStackHostRoute, - navigateToHostStackRoute, - type HostStackHostRoute, - type HostStackNavigationController, - type HostStackNavigationState, - type HostStackRootNavigation, - type HostStackRouteTarget, - type HostStackRouter -} from '../navigation/host-stack-navigation' - -export type MobileHostEditHostRoute = HostStackHostRoute -export type MobileHostEditNavigationState = HostStackNavigationState -export type MobileHostEditRootNavigation = HostStackRootNavigation -export type MobileHostEditRouter = HostStackRouter -export type MobileHostEditNavigationController = HostStackNavigationController - -export function mobileHostEditHostRoute(hostId: string): MobileHostEditHostRoute { - return hostStackHostRoute(hostId) -} - -export function mobileHostEditRouteTarget(hostId: string): HostStackRouteTarget { - return { - name: '[hostId]/edit', - params: { hostId } - } -} - -export function navigateToMobileHostEdit( - navigation: MobileHostEditRootNavigation, - router: MobileHostEditRouter, - hostId: string -): MobileHostEditNavigationController { - return navigateToHostStackRoute(navigation, router, hostId, mobileHostEditRouteTarget(hostId)) -} diff --git a/mobile/src/host-screen/host-screen-overlays.tsx b/mobile/src/host-screen/host-screen-overlays.tsx index ba7f8977bc7..0526700d08f 100644 --- a/mobile/src/host-screen/host-screen-overlays.tsx +++ b/mobile/src/host-screen/host-screen-overlays.tsx @@ -12,12 +12,13 @@ import { ConfirmModal } from '../components/ConfirmModal' import { OrcaHookTrustDrawer } from '../components/OrcaHookTrustDrawer' import { NewWorktreeModalController } from '../components/NewWorktreeModalController' import { hostNewWorktreeSessionRoute } from '../host-new-worktree-session-route' -import { isWorktreePinned, type Worktree } from '../worktree/workspace-list-sections' +import { isWorktreePinned } from '../worktree/workspace-list-sections' +import type { Worktree } from '../worktree/workspace-list-types' import { useMobileColorScheme } from '../theme/use-mobile-color-scheme' import { hostScreenStyles as styles } from './host-screen-styles' -import { WorkspaceOptionsSheet } from './workspace-options-sheet' -import { WorkspaceDetailsSheet } from './workspace-details-sheet' -import { WorkspaceRenameSheet } from './workspace-rename-sheet' +import { WorkspaceOptionsSheet } from './workspace-options/workspace-options-sheet' +import { WorkspaceDetailsSheet } from './workspace-actions/workspace-details-sheet' +import { WorkspaceRenameSheet } from './workspace-actions/workspace-rename-sheet' import type { HostScreenController } from './use-host-screen-controller' import { useMobileTranslation } from '../i18n/use-mobile-translation' diff --git a/mobile/src/host-screen/host-screen-reply-schema.test.ts b/mobile/src/host-screen/host-screen-reply-schema.test.ts deleted file mode 100644 index e6aba400135..00000000000 --- a/mobile/src/host-screen/host-screen-reply-schema.test.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - hostPlatformSchema, - hostRepoCatalogSchema, - hostSshTargetSummariesSchema, - hostViewSettingsSchema -} from './host-screen-reply-schema' - -describe('host screen reply schemas', () => { - it('requires the repo list and drops a row no map could key', () => { - expect(hostRepoCatalogSchema.safeParse({}).success).toBe(false) - const parsed = hostRepoCatalogSchema.parse({ - repos: [{ id: 'r1', displayName: 'orca' }, { id: 'r2' }, { displayName: 'ghost' }] - }) - expect(parsed.map((repo) => repo.id)).toEqual(['r1']) - }) - - it('salvages badgeColor onto the generated swatch main fell back to', () => { - expect( - hostRepoCatalogSchema.parse({ repos: [{ id: 'r', displayName: 'o', badgeColor: 3 }] })[0] - ?.badgeColor - ).toBeUndefined() - }) - - it('keeps a known repo icon arm and degrades an unknown one to absent', () => { - const known = hostRepoCatalogSchema.parse({ - repos: [{ id: 'r', displayName: 'o', repoIcon: { type: 'emoji', emoji: 'x' } }] - }) - expect(known[0]?.repoIcon).toEqual({ type: 'emoji', emoji: 'x' }) - const unknown = hostRepoCatalogSchema.parse({ - repos: [{ id: 'r', displayName: 'o', repoIcon: { type: 'svg', markup: '' } }] - }) - // Unknown icon arms retain the row and use MobileRepoIcon’s Folder default. - expect(unknown[0]?.id).toBe('r') - expect(unknown[0]?.repoIcon).toBeUndefined() - }) - - it('keeps an image icon whose source this build has never heard of', () => { - const [repo] = hostRepoCatalogSchema.parse({ - repos: [ - { - id: 'r', - displayName: 'o', - repoIcon: { - type: 'image', - src: 'https://example.invalid/a.png', - source: 'gitlab', - label: 'acme/orca' - } - } - ] - }) - // MobileRepoIcon ignores source; constraining it would drop images from newer hosts for a member the renderer never reads. - expect(repo?.repoIcon).toEqual({ - type: 'image', - src: 'https://example.invalid/a.png', - source: 'gitlab', - label: 'acme/orca' - }) - }) - - it('passes a host-id spelling through for getRepoExecutionHostId to judge', () => { - const [repo] = hostRepoCatalogSchema.parse({ - repos: [{ id: 'r', displayName: 'o', executionHostId: 'cloud:zone-a', connectionId: null }] - }) - expect(repo?.executionHostId).toBe('cloud:zone-a') - expect(repo?.connectionId).toBeNull() - }) - - it('degrades an unreadable ssh target list to the empty one that falls back to host ids', () => { - expect(hostSshTargetSummariesSchema.parse({})).toEqual([]) - expect(hostSshTargetSummariesSchema.parse({ targets: 'none' })).toEqual([]) - expect( - hostSshTargetSummariesSchema.parse({ - targets: [{ id: 't', label: 'T' }, { id: 'u' }, { label: 'V' }] - }) - ).toEqual([{ id: 't', label: 'T' }]) - }) - - it('keeps SSH lifecycle fields raw and drops a malformed one without losing the target', () => { - expect( - hostSshTargetSummariesSchema.parse({ - targets: [ - { id: 't', label: 'T', connected: false, connectionStatus: 'hibernating' }, - { id: 'u', label: 'U', connected: 'no', connectionStatus: 7 } - ] - }) - ).toEqual([ - { id: 't', label: 'T', connected: false, connectionStatus: 'hibernating' }, - { id: 'u', label: 'U' } - ]) - }) - - it('answers the empty target list for a reply that is no object at all', () => { - // Label writes precede platform writes; malformed targets must degrade to [] rather than skip both. - for (const payload of [null, undefined, 'nope', 42, []]) { - expect(hostSshTargetSummariesSchema.parse(payload)).toEqual([]) - } - }) - - it('reads only a platform Node could have reported', () => { - expect(hostPlatformSchema.parse({ platform: 'win32' })).toBe('win32') - expect(hostPlatformSchema.parse({ platform: 'plan9' })).toBeNull() - expect(hostPlatformSchema.parse({ platform: '' })).toBeNull() - expect(hostPlatformSchema.parse({})).toBeNull() - for (const payload of [null, undefined, 'nope', 42]) { - expect(hostPlatformSchema.parse(payload)).toBeNull() - } - }) - - it('requires the ui member main read with a bare property access', () => { - expect(hostViewSettingsSchema.safeParse({}).success).toBe(false) - expect(hostViewSettingsSchema.safeParse(null).success).toBe(false) - expect(hostViewSettingsSchema.parse({ ui: {} })).toEqual({}) - }) - - it('degrades an unknown grouping or sort arm to absent so the local mode is kept', () => { - const parsed = hostViewSettingsSchema.parse({ - ui: { groupBy: 'agent', sortBy: 'stars', hideSleepingWorkspaces: 'yes' } - }) - expect(parsed.groupBy).toBeUndefined() - expect(parsed.sortBy).toBeUndefined() - expect(parsed.hideSleepingWorkspaces).toBeUndefined() - }) - - it('keeps the known view arms and the lists the screen adopts', () => { - const parsed = hostViewSettingsSchema.parse({ - ui: { - groupBy: 'workspace-status', - sortBy: 'recent', - filterRepoIds: ['r1'], - collapsedGroups: [], - workspaceStatuses: [{ id: 'active', label: 'Active' }] - } - }) - expect(parsed.groupBy).toBe('workspace-status') - expect(parsed.sortBy).toBe('recent') - expect(parsed.filterRepoIds).toEqual(['r1']) - expect(parsed.workspaceStatuses).toEqual([{ id: 'active', label: 'Active' }]) - }) - - it('reads every Workspace options value the desktop mirrors to its phone', () => { - const ui = { - hideAutomationGeneratedWorkspaces: true, - hideCliCreatedWorkspaces: false, - hideDetachedHeadWorkspaces: true, - hideWorkspacesFromOtherDevices: true, - projectOrderBy: 'recent', - worktreeCardProperties: ['status', 'a-property-from-a-newer-desktop'], - agentActivityDisplayMode: 'full' - } - expect(hostViewSettingsSchema.parse({ ui })).toEqual(ui) - }) - - it('reads a desktop that predates them as absent, which keeps the local value', () => { - const parsed = hostViewSettingsSchema.parse({ - ui: { groupBy: 'repo', projectOrderBy: 'alphabetical', agentActivityDisplayMode: 7 } - }) - expect(parsed).toEqual({ groupBy: 'repo' }) - }) - - it('salvages a non-array status catalog rather than handing a string to the group lookups', () => { - expect( - hostViewSettingsSchema.parse({ ui: { workspaceStatuses: 'active' } }).workspaceStatuses - ).toBeUndefined() - }) -}) - -describe('repo icon payloads', () => { - it('preserves the lucide icon name the row renders', () => { - const [row] = hostRepoCatalogSchema.parse({ - repos: [{ id: 'r', displayName: 'r', repoIcon: { type: 'lucide', name: 'Terminal' } }] - }) - expect(row?.repoIcon).toEqual({ type: 'lucide', name: 'Terminal' }) - }) -}) diff --git a/mobile/src/host-screen/host-screen-view.tsx b/mobile/src/host-screen/host-screen-view.tsx index 0aa860410de..c566c1e3d9b 100644 --- a/mobile/src/host-screen/host-screen-view.tsx +++ b/mobile/src/host-screen/host-screen-view.tsx @@ -7,7 +7,7 @@ import { hostScreenStyles as styles } from './host-screen-styles' import { HostBottomBar } from './host-bottom-bar' import { HostConnectionStatus, HostScreenHeader } from './host-screen-header' import { HostScreenOverlays } from './host-screen-overlays' -import { HostWorkspaceList } from './host-workspace-list' +import { HostWorkspaceList } from './workspace-list/host-workspace-list' import type { HostScreenController } from './use-host-screen-controller' export function HostScreenView({ controller }: { controller: HostScreenController }) { diff --git a/mobile/src/host-screen/host-sidebar-feed-sections.test.ts b/mobile/src/host-screen/host-sidebar-feed-sections.test.ts deleted file mode 100644 index 5d0cf70ce29..00000000000 --- a/mobile/src/host-screen/host-sidebar-feed-sections.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - buildSidebarFeedRows, - type SidebarFeedView -} from '../../../src/shared/sidebar-list/sidebar-feed-rows' - -import { buildSidebarFeedCatalog } from '../../../src/shared/sidebar-list/sidebar-feed-catalog' -import { buildHostFeedView } from './host-sidebar-feed-inputs' -import { buildHostFeedSections } from './host-sidebar-feed-sections' -import { - SERVED_SECTIONS, - feedDomains, - feedKey, - feedRow, - phoneView -} from './host-sidebar-feed.test-fixture' -import type { MobileViewState } from '../worktree/workspace-view-settings' -import type { ProjectGroup } from '../../../src/shared/project-group-types' -import { readServedProjectSections } from '../worktree/project-sections-operations' -import { NO_WORKTREE_HOST_CONTEXT } from '../worktree/worktree-host-context-labels' - -function phoneFeedView( - view: MobileViewState, - showPinnedInGroups = false, - hostPlatform: NodeJS.Platform | null = 'darwin' -): SidebarFeedView { - return buildHostFeedView({ - view, - projectOrderBy: 'manual', - desktop: { ...feedDomains().desktop!, showPinnedInGroups }, - hostPlatform - }) -} - -describe('the phone list from the sidebar feed', () => { - it('files a grouped project under its group header, nested and in the group’s order', () => { - const group = (id: string, name: string, extra: Partial = {}): ProjectGroup => ({ - id, - name, - parentPath: null, - parentGroupId: null, - createdFrom: 'manual', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 0, - updatedAt: 0, - ...extra - }) - const served = readServedProjectSections({ - id: 'sections', - ok: true, - result: { - catalog: { - ...SERVED_SECTIONS, - repos: [ - // Use reversed fixture order so only projectGroupOrder can produce the expected order. - { ...SERVED_SECTIONS.repos[0]!, projectGroupId: 'pg-inner', projectGroupOrder: 1 }, - { ...SERVED_SECTIONS.repos[1]!, projectGroupId: 'pg-inner', projectGroupOrder: 0 } - ], - projectGroups: [ - group('pg-school', 'school'), - group('pg-inner', 'inner', { parentGroupId: 'pg-school', createdFrom: 'folder-scan' }) - ] - } - } - }) - if (served.kind !== 'answered' || !served.value) { - throw new Error('expected a served catalog') - } - const grouped = buildSidebarFeedCatalog(served.value, [], []) - const base = feedDomains() - const domains = feedDomains({ - workspaces: new Map([ - ...base.workspaces, - [feedKey('|orca::/w/solo'), feedRow({ id: 'orca::/w/solo', manualOrder: 4 })] - ]) - }) - const built = buildSidebarFeedRows(domains, grouped, phoneFeedView(phoneView())) - const sections = buildHostFeedSections({ - rows: built.sectionRows, - lastVisited: null, - liveRows: [], - hostContextOf: NO_WORKTREE_HOST_CONTEXT, - repoHostIdByRepoId: new Map([['lab', 'ssh:gpu']]), - search: '' - }) - const headers = sections.map( - (section) => - `${section.title}${section.isProjectGroup ? ' group' : ''} depth=${String(section.projectGroupDepth ?? 0)}` - ) - expect(headers).toEqual([ - 'Pinned depth=0', - 'school group depth=0', - 'inner group depth=1', - 'lab depth=2', - 'orca depth=2' - ]) - expect( - sections - .slice(-2) - .map((section) => - section.data.map((row) => [ - row.view.projectGroupDepth, - row.view.inFolderScanGroup === true - ]) - ) - ).toEqual([[[2, true]], [[2, true]]]) - expect(sections.at(-2)?.data.map((row) => row.row.worktreeId)).toEqual(['lab::/w/gpu']) - }) -}) diff --git a/mobile/src/host-screen/host-sidebar-feed.test-fixture.ts b/mobile/src/host-screen/host-sidebar-feed.test-fixture.ts deleted file mode 100644 index 85afd5b4998..00000000000 --- a/mobile/src/host-screen/host-sidebar-feed.test-fixture.ts +++ /dev/null @@ -1,139 +0,0 @@ -import type { SidebarFeedDomains } from '../../../src/shared/sidebar-list/sidebar-feed-domains' -import type { WorkspaceRow } from '../../../src/shared/sidebar-list/workspace-rows' -import { - parseWorktreeHostIdentity, - type HostScopedWorktreeKey -} from '../../../src/shared/worktree/host-qualified-identity' -import type { ServedProjectSections } from '../worktree/project-sections-operations' -import { INITIAL_MOBILE_VIEW, type MobileViewState } from '../worktree/workspace-view-settings' -import type { Worktree } from '../worktree/workspace-list-types' - -export function feedRow(overrides: Partial & Pick): WorkspaceRow { - return { - hostId: 'local', - kind: 'git', - repoId: 'orca', - path: `/w/${overrides.id}`, - branch: overrides.id, - displayName: overrides.id, - isMainWorktree: false, - lineage: null, - isPinned: false, - isArchived: false, - isUnread: false, - workspaceStatus: 'in-progress', - comment: '', - linkedIssue: null, - linkedPR: null, - linkedLinearIssue: null, - linkedGitLabMR: null, - linkedGitLabIssue: null, - linkedItems: [], - defaultBranch: false, - ...overrides - } -} - -export const FEED_ROWS = { - parent: feedRow({ - id: 'orca::/w/parent', - isPinned: true, - instanceId: 'i-parent', - manualOrder: 1 - }), - child: feedRow({ - id: 'orca::/w/child', - instanceId: 'i-child', - manualOrder: 2, - lineage: { - parentWorktreeId: 'orca::/w/parent', - worktreeInstanceId: 'i-child', - parentWorktreeInstanceId: 'i-parent' - } - }), - remote: feedRow({ id: 'lab::/w/gpu', hostId: 'ssh:gpu', repoId: 'lab', manualOrder: 3 }) -} - -export function feedKey(identity: string): HostScopedWorktreeKey { - const key = parseWorktreeHostIdentity(identity) - if (!key) { - throw new Error(`not a row identity: ${identity}`) - } - return key -} - -export function feedDomains(overrides: Partial = {}): SidebarFeedDomains { - return { - workspaces: new Map([ - [feedKey('|orca::/w/parent'), FEED_ROWS.parent], - [feedKey('|orca::/w/child'), FEED_ROWS.child], - [feedKey('ssh:gpu|lab::/w/gpu'), FEED_ROWS.remote] - ]), - activity: new Map(), - review: new Map(), - creations: new Map(), - pairedServerHosts: new Map(), - pairedServerRows: new Map(), - repoIcons: new Map(), - desktop: { - desktopActive: { hostId: null, worktreeId: 'orca::/w/parent' }, - pinnedWorkspaceIds: ['orca::/w/parent'], - repoOrder: [ - { hostId: 'local', repoId: 'orca' }, - { hostId: 'ssh:gpu', repoId: 'lab' } - ], - workspaceHostOrder: [], - sshHosts: { 'ssh:gpu': { label: 'GPU box', status: 'connected' } }, - wslHosts: {} - }, - projectCatalog: null, - retention: null, - ...overrides - } -} - -type ServedRepo = ServedProjectSections['repos'][number] - -function servedRepo(fields: Pick & Partial): ServedRepo { - return { - displayName: fields.id, - badgeColor: undefined, - addedAt: undefined, - connectionId: undefined, - executionHostId: undefined, - projectGroupId: undefined, - projectGroupOrder: undefined, - ...fields - } -} - -export const SERVED_SECTIONS: ServedProjectSections = { - repos: [ - servedRepo({ id: 'orca', path: '/r/orca', badgeColor: '#f00' }), - servedRepo({ id: 'lab', path: '/r/lab', connectionId: 'gpu' }) - ], - projects: [], - setups: [], - projectOrderBy: 'manual', - projectGroups: undefined -} - -export function phoneView(overrides: Partial = {}): MobileViewState { - return { ...INITIAL_MOBILE_VIEW, groupBy: 'repo', sortBy: 'manual', ...overrides } -} - -export function liveRow(overrides: Partial & Pick): Worktree { - return { - repoId: 'orca', - repo: 'orca', - branch: overrides.worktreeId, - displayName: overrides.worktreeId, - path: `/w/${overrides.worktreeId}`, - liveTerminalCount: 0, - hasAttachedPty: false, - unread: false, - isPinned: false, - linkedPR: null, - ...overrides - } -} diff --git a/mobile/src/host-screen/identity/host-edit-navigation.ts b/mobile/src/host-screen/identity/host-edit-navigation.ts new file mode 100644 index 00000000000..7360a528e1a --- /dev/null +++ b/mobile/src/host-screen/identity/host-edit-navigation.ts @@ -0,0 +1,22 @@ +import { + navigateToHostStackRoute, + type HostStackNavigationController, + type HostStackRootNavigation, + type HostStackRouteTarget, + type HostStackRouter +} from '../../navigation/host-stack-navigation' + +export function mobileHostEditRouteTarget(hostId: string): HostStackRouteTarget { + return { + name: '[hostId]/edit', + params: { hostId } + } +} + +export function navigateToMobileHostEdit( + navigation: HostStackRootNavigation, + router: HostStackRouter, + hostId: string +): HostStackNavigationController { + return navigateToHostStackRoute(navigation, router, hostId, mobileHostEditRouteTarget(hostId)) +} diff --git a/mobile/src/host-screen/host-stored-identity.ts b/mobile/src/host-screen/identity/host-stored-identity.ts similarity index 83% rename from mobile/src/host-screen/host-stored-identity.ts rename to mobile/src/host-screen/identity/host-stored-identity.ts index dd02444eb75..45cef67e4fb 100644 --- a/mobile/src/host-screen/host-stored-identity.ts +++ b/mobile/src/host-screen/identity/host-stored-identity.ts @@ -1,5 +1,5 @@ -import type { HostProfile } from '../host-catalog/host-profile' -import type { HostMachine } from '../../../src/shared/host-machine-line' +import type { HostProfile } from '../../host-catalog/host-profile' +import type { HostMachine } from '../../../../src/shared/host-machine-line' export type HostStoredDescriptor = { personalName?: string diff --git a/mobile/src/host-screen/use-host-screen-identity.ts b/mobile/src/host-screen/identity/use-host-screen-identity.ts similarity index 80% rename from mobile/src/host-screen/use-host-screen-identity.ts rename to mobile/src/host-screen/identity/use-host-screen-identity.ts index 057a23b8718..59ad8a8229e 100644 --- a/mobile/src/host-screen/use-host-screen-identity.ts +++ b/mobile/src/host-screen/identity/use-host-screen-identity.ts @@ -1,17 +1,17 @@ import { useEffect } from 'react' import { useQueryClient } from '@tanstack/react-query' -import { translate } from '../i18n/mobile-i18n' -import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../../src/shared/sidebar-inputs-switch' -import { loadPinnedIds } from '../storage/preferences' -import { loadHosts, peekHosts } from '../host-catalog/host-store' -import { updateLastConnected } from '../host-catalog/host-connection-history' -import type { HostProfile } from '../host-catalog/host-profile' -import type { RpcClient } from '../transport/rpc/rpc-client-contract' -import type { HostScreenState } from './use-host-screen-state' +import { translate } from '../../i18n/mobile-i18n' +import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../../../src/shared/sidebar-inputs-switch' +import { loadPinnedIds } from '../../storage/preferences' +import { loadHosts, peekHosts } from '../../host-catalog/host-store' +import { updateLastConnected } from '../../host-catalog/host-connection-history' +import type { HostProfile } from '../../host-catalog/host-profile' +import type { RpcClient } from '../../transport/rpc/rpc-client-contract' +import type { HostScreenState } from '../use-host-screen-state' import { storedHostDescriptor } from './host-stored-identity' -import { INITIAL_MOBILE_VIEW } from '../worktree/workspace-view-settings' -import { cachedHostView } from './host-view-memory' -import { cachedHostWorktrees } from './host-list-queries' +import { INITIAL_MOBILE_VIEW } from '../../worktree/workspace-view-settings' +import { cachedHostView } from '../view-settings/host-view-memory' +import { cachedHostWorktrees } from '../catalog/host-list-queries' // The host as last read this launch, so the title's first frame shows its name, not "Host". export function peekStoredHost(hostId: string | undefined): HostProfile | null { diff --git a/mobile/src/host-screen/use-open-mobile-host-edit.ts b/mobile/src/host-screen/identity/use-open-mobile-host-edit.ts similarity index 81% rename from mobile/src/host-screen/use-open-mobile-host-edit.ts rename to mobile/src/host-screen/identity/use-open-mobile-host-edit.ts index a6517e3a3d9..0bf5e726969 100644 --- a/mobile/src/host-screen/use-open-mobile-host-edit.ts +++ b/mobile/src/host-screen/identity/use-open-mobile-host-edit.ts @@ -1,5 +1,5 @@ import { useCallback } from 'react' -import { useOpenHostStackRoute } from '../navigation/use-open-host-stack-route' +import { useOpenHostStackRoute } from '../../navigation/use-open-host-stack-route' import { mobileHostEditRouteTarget } from './host-edit-navigation' export function useOpenMobileHostEdit(): (hostId: string) => void { diff --git a/mobile/src/host-screen/host-sidebar-feed-connection.ts b/mobile/src/host-screen/sidebar-feed/host-sidebar-feed-connection.ts similarity index 82% rename from mobile/src/host-screen/host-sidebar-feed-connection.ts rename to mobile/src/host-screen/sidebar-feed/host-sidebar-feed-connection.ts index d48e56d65a2..bf1ed86d98a 100644 --- a/mobile/src/host-screen/host-sidebar-feed-connection.ts +++ b/mobile/src/host-screen/sidebar-feed/host-sidebar-feed-connection.ts @@ -2,8 +2,8 @@ import { SidebarFeedReader, type SidebarFeedNotice, type SidebarFeedReaderRead -} from '../../../src/shared/sidebar-feed-client/sidebar-feed-reader' -import type { UnvalidatedRpcRequestPort } from '../transport/rpc/unvalidated-rpc-request-port' +} from '../../../../src/shared/sidebar-feed-client/sidebar-feed-reader' +import type { UnvalidatedRpcRequestPort } from '../../transport/rpc/unvalidated-rpc-request-port' import { hostSidebarFeedTransport } from './host-sidebar-feed-operations' export type HostSidebarFeedTriggers = { diff --git a/mobile/src/host-screen/host-sidebar-feed-inputs.ts b/mobile/src/host-screen/sidebar-feed/host-sidebar-feed-inputs.ts similarity index 75% rename from mobile/src/host-screen/host-sidebar-feed-inputs.ts rename to mobile/src/host-screen/sidebar-feed/host-sidebar-feed-inputs.ts index bba09257ec2..936a32aa55b 100644 --- a/mobile/src/host-screen/host-sidebar-feed-inputs.ts +++ b/mobile/src/host-screen/sidebar-feed/host-sidebar-feed-inputs.ts @@ -3,12 +3,12 @@ import { LOCAL_EXECUTION_HOST_ID, normalizeExecutionHostId, type ExecutionHostId -} from '../../../src/shared/execution-host' -import { getHostContextLabel } from '../../../src/shared/worktree/host-context-labels' -import type { DesktopSidebarFacts } from '../../../src/shared/sidebar-list/desktop-sidebar-facts' -import type { SidebarFeedView } from '../../../src/shared/sidebar-list/sidebar-feed-rows' -import type { ProjectOrderBy } from '../../../src/shared/ui-chrome-types' -import type { MobileViewState } from '../worktree/workspace-view-settings' +} from '../../../../src/shared/execution-host' +import { getHostContextLabel } from '../../../../src/shared/worktree/host-context-labels' +import type { DesktopSidebarFacts } from '../../../../src/shared/sidebar-list/desktop-sidebar-facts' +import type { SidebarFeedView } from '../../../../src/shared/sidebar-list/sidebar-feed-rows' +import type { ProjectOrderBy } from '../../../../src/shared/ui-chrome-types' +import type { MobileViewState } from '../../worktree/workspace-view-settings' // Mirror desktop UI/settings into the shared sidebar builder; resolved all-host scope matches desktop legacy scope. export function buildHostFeedView(args: { diff --git a/mobile/src/host-screen/host-sidebar-feed-operations.ts b/mobile/src/host-screen/sidebar-feed/host-sidebar-feed-operations.ts similarity index 79% rename from mobile/src/host-screen/host-sidebar-feed-operations.ts rename to mobile/src/host-screen/sidebar-feed/host-sidebar-feed-operations.ts index 3f69e0ce52e..0aa0bf7e949 100644 --- a/mobile/src/host-screen/host-sidebar-feed-operations.ts +++ b/mobile/src/host-screen/sidebar-feed/host-sidebar-feed-operations.ts @@ -1,11 +1,11 @@ import * as z from 'zod' -import { SidebarInputsReadResultSchema } from '../../../src/shared/sidebar-list/sidebar-inputs-feed-contract' -import type { SidebarFeedTransport } from '../../../src/shared/sidebar-feed-client/sidebar-feed-reader' -import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc/rpc-operation' -import { rpcResultVariant } from '../transport/rpc/rpc-operation-result-reader' -import { isMobileMethodUnavailableError } from '../transport/rpc/mobile-method-unavailable' -import type { RpcResponse } from '../transport/rpc/rpc-wire-types' -import type { UnvalidatedRpcRequestPort } from '../transport/rpc/unvalidated-rpc-request-port' +import { SidebarInputsReadResultSchema } from '../../../../src/shared/sidebar-list/sidebar-inputs-feed-contract' +import type { SidebarFeedTransport } from '../../../../src/shared/sidebar-feed-client/sidebar-feed-reader' +import { bindDeferredRpcOperation, defineRpcOperation } from '../../transport/rpc/rpc-operation' +import { rpcResultVariant } from '../../transport/rpc/rpc-operation-result-reader' +import { isMobileMethodUnavailableError } from '../../transport/rpc/mobile-method-unavailable' +import type { RpcResponse } from '../../transport/rpc/rpc-wire-types' +import type { UnvalidatedRpcRequestPort } from '../../transport/rpc/unvalidated-rpc-request-port' // Only ORCA_SIDEBAR_INPUTS=main pages read this feed; method_not_found or an older allowlist’s forbidden means the page is newer than the host. diff --git a/mobile/src/host-screen/host-sidebar-feed-sections.ts b/mobile/src/host-screen/sidebar-feed/host-sidebar-feed-sections.ts similarity index 89% rename from mobile/src/host-screen/host-sidebar-feed-sections.ts rename to mobile/src/host-screen/sidebar-feed/host-sidebar-feed-sections.ts index fcdf2861ab7..f26ea3c39f3 100644 --- a/mobile/src/host-screen/host-sidebar-feed-sections.ts +++ b/mobile/src/host-screen/sidebar-feed/host-sidebar-feed-sections.ts @@ -2,26 +2,31 @@ import { LOCAL_EXECUTION_HOST_ID, getWorktreeExecutionHostId, type ExecutionHostId -} from '../../../src/shared/execution-host' -import { folderWorkspaceToWorktree } from '../../../src/shared/folder-workspaces/folder-workspace-worktree' -import { getFolderWorkspaceHostId } from '../../../src/shared/sidebar-list/folder-workspace-host-id' -import type { HostSectionRow } from '../../../src/shared/sidebar-list/host-section-rows' -import type { GroupHeaderRow } from '../../../src/shared/sidebar-list/row-types' -import { PR_GROUP_LABELS } from '../../../src/shared/workspace-view/workspace-pr-groups' -import type { Worktree as HostWorktree } from '../../../src/shared/worktree/types' -import { composeWorktreeHostIdentity } from '../../../src/shared/worktree/host-qualified-identity' -import type { Section, Worktree, WorktreeHostContextView } from '../worktree/workspace-list-types' +} from '../../../../src/shared/execution-host' +import { folderWorkspaceToWorktree } from '../../../../src/shared/folder-workspaces/folder-workspace-worktree' +import { getFolderWorkspaceHostId } from '../../../../src/shared/sidebar-list/folder-workspace-host-id' +import type { HostSectionRow } from '../../../../src/shared/sidebar-list/host-section-rows' +import type { GroupHeaderRow } from '../../../../src/shared/sidebar-list/row-types' +import { PR_GROUP_LABELS } from '../../../../src/shared/workspace-view/workspace-pr-groups' +import type { Worktree as HostWorktree } from '../../../../src/shared/worktree/types' +import { composeWorktreeHostIdentity } from '../../../../src/shared/worktree/host-qualified-identity' +import type { + Section, + Worktree, + WorktreeHostContextView +} from '../../worktree/workspace-list-types' import { isLastVisitedWorktreeRow, type LastVisitedWorktreeOnHost -} from '../worktree/host-list-highlight' +} from '../../worktree/host-list-highlight' import { resolveWorktreeHostId, type WorktreeHostContextOf -} from '../worktree/worktree-host-context-labels' -import { filterWorktrees } from '../worktree/workspace-list-sections' -import { rowFiltersForView, type MobileViewState } from '../worktree/workspace-view-settings' -import type { HiddenProjects } from '../../../src/shared/hidden-projects' +} from '../../worktree/worktree-host-context-labels' +import '../../worktree/workspace-list-sections' +import { filterWorktrees } from '../../worktree/workspace-row-filtering' +import { rowFiltersForView, type MobileViewState } from '../../worktree/workspace-view-settings' +import type { HiddenProjects } from '../../../../src/shared/hidden-projects' export type HostFeedListSources = { rows: readonly HostSectionRow[] diff --git a/mobile/src/host-screen/use-host-sidebar-feed.ts b/mobile/src/host-screen/sidebar-feed/use-host-sidebar-feed.ts similarity index 87% rename from mobile/src/host-screen/use-host-sidebar-feed.ts rename to mobile/src/host-screen/sidebar-feed/use-host-sidebar-feed.ts index 380b4e1cc7a..e66829b05a6 100644 --- a/mobile/src/host-screen/use-host-sidebar-feed.ts +++ b/mobile/src/host-screen/sidebar-feed/use-host-sidebar-feed.ts @@ -1,16 +1,16 @@ import { useEffect, useMemo, useRef, useState } from 'react' -import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../../src/shared/sidebar-inputs-switch' -import type { SidebarFeedDomains } from '../../../src/shared/sidebar-list/sidebar-feed-domains' +import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../../../src/shared/sidebar-inputs-switch' +import type { SidebarFeedDomains } from '../../../../src/shared/sidebar-list/sidebar-feed-domains' import type { SidebarFeedNotice, SidebarFeedReader -} from '../../../src/shared/sidebar-feed-client/sidebar-feed-reader' +} from '../../../../src/shared/sidebar-feed-client/sidebar-feed-reader' import { isReadableSidebarFeed, type SidebarFeedSnapshot -} from '../../../src/shared/sidebar-feed-client/sidebar-feed-snapshot' -import { RememberedSidebarFeeds } from '../../../src/shared/sidebar-feed-client/remembered-sidebar-feeds' -import type { RpcClient } from '../transport/rpc/rpc-client-contract' +} from '../../../../src/shared/sidebar-feed-client/sidebar-feed-snapshot' +import { RememberedSidebarFeeds } from '../../../../src/shared/sidebar-feed-client/remembered-sidebar-feeds' +import type { RpcClient } from '../../transport/rpc/rpc-client-contract' import { createHostSidebarFeedReader, type HostSidebarFeedTriggers diff --git a/mobile/src/host-screen/use-host-screen-controller.ts b/mobile/src/host-screen/use-host-screen-controller.ts index e5ea6277d43..0bc3da7bf96 100644 --- a/mobile/src/host-screen/use-host-screen-controller.ts +++ b/mobile/src/host-screen/use-host-screen-controller.ts @@ -17,17 +17,20 @@ import { useReconnectAttempt, useRelayRecoveryStatus } from '../transport/connection/client-context-connection-metrics' -import { useHostDisplayWorktrees, useResolvedHostPlatform } from './use-host-display-worktrees' -import { useHostListSections } from './use-host-list-sections' -import { useSwitchedHostSidebarFeed } from './use-host-sidebar-feed' -import { useHostProjectSections } from './use-host-project-sections' -import { useHostRepoMetadata } from './use-host-repo-metadata' -import { peekStoredHost, useHostScreenIdentity } from './use-host-screen-identity' +import { + useHostDisplayWorktrees, + useResolvedHostPlatform +} from './workspace-list/use-host-display-worktrees' +import { useHostListSections } from './workspace-list/use-host-list-sections' +import { useSwitchedHostSidebarFeed } from './sidebar-feed/use-host-sidebar-feed' +import { useHostProjectSections } from './catalog/use-host-project-sections' +import { useHostRepoMetadata } from './catalog/use-host-repo-metadata' +import { peekStoredHost, useHostScreenIdentity } from './identity/use-host-screen-identity' import { useHostScreenState } from './use-host-screen-state' -import { useHostViewSettings } from './use-host-view-settings' +import { useHostViewSettings } from './view-settings/use-host-view-settings' import { countActiveWorkspaceFilters } from '../../../src/shared/workspace-view/workspace-row-filters' -import { useHostWorktreeActions } from './use-host-worktree-actions' -import { useHostWorktreeCatalog } from './use-host-worktree-catalog' +import { useHostWorktreeActions } from './workspace-actions/use-host-worktree-actions' +import { useHostWorktreeCatalog } from './catalog/use-host-worktree-catalog' import { useHostDisplay } from '../host-descriptors/use-host-display' import { classifyConnection } from '../transport/connection/connection-health' diff --git a/mobile/src/host-screen/use-host-screen-state.ts b/mobile/src/host-screen/use-host-screen-state.ts index 4f40a1a95bb..3b965964dfb 100644 --- a/mobile/src/host-screen/use-host-screen-state.ts +++ b/mobile/src/host-screen/use-host-screen-state.ts @@ -2,17 +2,17 @@ import { useCallback, useRef, useState } from 'react' import { useQueryClient } from '@tanstack/react-query' import type { ExecutionHostId } from '../../../src/shared/execution-host' import type { ExecutionHostHealth } from '../../../src/shared/execution-host/execution-host-health' -import { storedHostDescriptor, type HostStoredDescriptor } from './host-stored-identity' +import { storedHostDescriptor, type HostStoredDescriptor } from './identity/host-stored-identity' import type { RpcClient } from '../transport/rpc/rpc-client-contract' import type { HostProfile } from '../host-catalog/host-profile' import { INITIAL_MOBILE_VIEW, type MobileViewState } from '../worktree/workspace-view-settings' -import type { Worktree } from '../worktree/workspace-list-sections' -import type { ServedProjectSections } from '../worktree/project-sections-operations' -import type { SectionRepo } from '../worktree/workspace-list-types' + +import type { ServedProjectSections } from '../../../src/shared/project-section-catalog-schema' +import type { SectionRepo, Worktree } from '../worktree/workspace-list-types' import { NO_SERVER_WORKSPACES, type ServerWorkspaces } from '../worktree/server-workspaces' import type { ProjectGroup } from '../../../src/shared/project-group-types' -import { cachedHostWorktrees } from './host-list-queries' -import { cachedHostView, useHostViewMemory } from './host-view-memory' +import { cachedHostWorktrees } from './catalog/host-list-queries' +import { cachedHostView, useHostViewMemory } from './view-settings/host-view-memory' export function useHostScreenState( hostId: string | undefined, diff --git a/mobile/src/host-screen/host-list-settings-operations.ts b/mobile/src/host-screen/view-settings/host-list-settings-operations.ts similarity index 80% rename from mobile/src/host-screen/host-list-settings-operations.ts rename to mobile/src/host-screen/view-settings/host-list-settings-operations.ts index 4af11d70e45..ed977bb0d1c 100644 --- a/mobile/src/host-screen/host-list-settings-operations.ts +++ b/mobile/src/host-screen/view-settings/host-list-settings-operations.ts @@ -1,7 +1,7 @@ -import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc/rpc-operation' -import type { RpcCompatibleReader } from '../transport/rpc/rpc-operation-contract' -import '../../../src/shared/source-control/listing/source-control-group-order' -import { settingsMember, settingsField } from '../settings/mobile-settings-projection' +import { bindDeferredRpcOperation, defineRpcOperation } from '../../transport/rpc/rpc-operation' +import type { RpcCompatibleReader } from '../../transport/rpc/rpc-operation-contract' +import '../../../../src/shared/source-control/listing/source-control-group-order' +import { settingsMember, settingsField } from '../../settings/mobile-settings-projection' type HostListSettings = { showPinnedWorktreesInGroups: boolean diff --git a/mobile/src/host-screen/host-view-memory.ts b/mobile/src/host-screen/view-settings/host-view-memory.ts similarity index 86% rename from mobile/src/host-screen/host-view-memory.ts rename to mobile/src/host-screen/view-settings/host-view-memory.ts index c04f436c9f7..0701f08b7e8 100644 --- a/mobile/src/host-screen/host-view-memory.ts +++ b/mobile/src/host-screen/view-settings/host-view-memory.ts @@ -1,10 +1,10 @@ import { useCallback } from 'react' import { skipToken, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query' import * as z from 'zod' -import { hostQueryKey } from '../query/host-query-key' -import type { PersistedHostFamily } from '../query/host-query-persistence' -import type { MobileViewState } from '../worktree/workspace-view-settings' -import { HOST_LIST_TARGET, NO_HOST_LIST_INPUT } from './host-list-queries' +import { hostQueryKey } from '../../query/host-query-key' +import type { PersistedHostFamily } from '../../query/host-query-persistence' +import type { MobileViewState } from '../../worktree/workspace-view-settings' +import { HOST_LIST_TARGET, NO_HOST_LIST_INPUT } from '../catalog/host-list-queries' const storedViewSchema = z.looseObject({ groupBy: z.string(), diff --git a/mobile/src/host-screen/host-view-pending-changes.ts b/mobile/src/host-screen/view-settings/host-view-pending-changes.ts similarity index 98% rename from mobile/src/host-screen/host-view-pending-changes.ts rename to mobile/src/host-screen/view-settings/host-view-pending-changes.ts index 76f2d48924b..6f932beb9f1 100644 --- a/mobile/src/host-screen/host-view-pending-changes.ts +++ b/mobile/src/host-screen/view-settings/host-view-pending-changes.ts @@ -2,7 +2,7 @@ import type { MobileViewPatch, MobileViewState, WorkspaceViewSettings -} from '../worktree/workspace-view-settings' +} from '../../worktree/workspace-view-settings' // Show the desired value until the desktop answers; a slow response is not a refusal. export type PendingViewChange = { opId: number; sentAt: number } & ( diff --git a/mobile/src/host-screen/use-host-pending-view-changes.test.ts b/mobile/src/host-screen/view-settings/use-host-pending-view-changes.test.ts similarity index 94% rename from mobile/src/host-screen/use-host-pending-view-changes.test.ts rename to mobile/src/host-screen/view-settings/use-host-pending-view-changes.test.ts index 29a393415cb..2812a4012e3 100644 --- a/mobile/src/host-screen/use-host-pending-view-changes.test.ts +++ b/mobile/src/host-screen/view-settings/use-host-pending-view-changes.test.ts @@ -3,11 +3,13 @@ import { act, create as createRenderer } from 'react-test-renderer' import { afterEach, describe, expect, it, vi } from 'vitest' -vi.mock('../../../src/shared/sidebar-inputs-switch', () => ({ ORCA_SIDEBAR_INPUTS_IS_MAIN: true })) +vi.mock('../../../../src/shared/sidebar-inputs-switch', () => ({ + ORCA_SIDEBAR_INPUTS_IS_MAIN: true +})) -import '../../../src/shared/rpc-contract/workspace-view-collapse-params' -import { FakeSession } from '../transport/connection/mobile-endpoint-supervisor-test-fakes' -import type { RpcResponse } from '../transport/rpc/rpc-wire-types' +import '../../../../src/shared/rpc-contract/workspace-view-collapse-params' +import { FakeSession } from '../../transport/connection/mobile-endpoint-supervisor-test-fakes' +import type { RpcResponse } from '../../transport/rpc/rpc-wire-types' import { addPendingViewChange, reconcilePendingViewChanges, diff --git a/mobile/src/host-screen/use-host-pending-view-changes.ts b/mobile/src/host-screen/view-settings/use-host-pending-view-changes.ts similarity index 93% rename from mobile/src/host-screen/use-host-pending-view-changes.ts rename to mobile/src/host-screen/view-settings/use-host-pending-view-changes.ts index fd5aeca638b..6df67901a6b 100644 --- a/mobile/src/host-screen/use-host-pending-view-changes.ts +++ b/mobile/src/host-screen/view-settings/use-host-pending-view-changes.ts @@ -1,9 +1,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../../src/shared/sidebar-inputs-switch' -import type { RpcClient } from '../transport/rpc/rpc-client-contract' -import type { MobileViewPatch, WorkspaceViewSettings } from '../worktree/workspace-view-settings' -import { hostViewSettingsWrite } from './host-screen-operations' -import { readHostMethodReply, setCollapsedWrite } from './host-sidebar-feed-operations' +import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../../../src/shared/sidebar-inputs-switch' +import type { RpcClient } from '../../transport/rpc/rpc-client-contract' +import type { MobileViewPatch, WorkspaceViewSettings } from '../../worktree/workspace-view-settings' +import { hostViewSettingsWrite } from '../host-screen-operations' +import { + readHostMethodReply, + setCollapsedWrite +} from '../sidebar-feed/host-sidebar-feed-operations' import { PENDING_VIEW_CHANGE_STALE_MS, addPendingViewChange, diff --git a/mobile/src/host-screen/use-host-view-settings.ts b/mobile/src/host-screen/view-settings/use-host-view-settings.ts similarity index 93% rename from mobile/src/host-screen/use-host-view-settings.ts rename to mobile/src/host-screen/view-settings/use-host-view-settings.ts index 208d262439f..a2c83d1a58a 100644 --- a/mobile/src/host-screen/use-host-view-settings.ts +++ b/mobile/src/host-screen/view-settings/use-host-view-settings.ts @@ -1,37 +1,37 @@ import { useCallback, useRef } from 'react' -import type { RpcClient } from '../transport/rpc/rpc-client-contract' -import type { ConnectionState } from '../transport/connection/connection-state' -import { getMobileWorkspaceLineageGroupKey } from '../worktree/mobile-workspace-lineage' +import type { RpcClient } from '../../transport/rpc/rpc-client-contract' +import type { ConnectionState } from '../../transport/connection/connection-state' +import { getMobileWorkspaceLineageGroupKey } from '../../worktree/mobile-workspace-lineage' import { applyDesktopViewSettings, buildWorkspaceViewSettingsUpdate, type MobileViewPatch, type MobileViewState, type WorkspaceViewSettings -} from '../worktree/workspace-view-settings' +} from '../../worktree/workspace-view-settings' import { changedWorkspaceRowFilters, NO_WORKSPACE_ROW_FILTERS -} from '../../../src/shared/workspace-view/workspace-row-filters' +} from '../../../../src/shared/workspace-view/workspace-row-filters' import { hostCardLayoutWrite, hostViewSettingsRead, hostViewSettingsWrite -} from './host-screen-operations' +} from '../host-screen-operations' import { hostListSettingsRead } from './host-list-settings-operations' import type { AgentActivityDisplayMode, ProjectOrderBy, WorktreeCardProperty -} from '../../../src/shared/ui-chrome-types' -import { getWorktreeCardModeUpdates } from '../../../src/shared/worktree/card-properties' -import type { HostScreenState } from './use-host-screen-state' +} from '../../../../src/shared/ui-chrome-types' +import { getWorktreeCardModeUpdates } from '../../../../src/shared/worktree/card-properties' +import type { HostScreenState } from '../use-host-screen-state' import { applyCollapse, withPendingViewChanges } from './host-view-pending-changes' import { useSwitchedHostPendingViewChanges, type PendingViewStatus } from './use-host-pending-view-changes' -import type { FilterState, WorkspaceListItem } from '../worktree/workspace-list-types' +import type { FilterState, WorkspaceListItem } from '../../worktree/workspace-list-types' const REFUSED_NOTE = 'The desktop did not take that change.' const NOTHING_PENDING: PendingViewStatus = { pending: [], stale: false } diff --git a/mobile/src/host-screen/use-host-worktree-actions.ts b/mobile/src/host-screen/workspace-actions/use-host-worktree-actions.ts similarity index 83% rename from mobile/src/host-screen/use-host-worktree-actions.ts rename to mobile/src/host-screen/workspace-actions/use-host-worktree-actions.ts index d96d2e8c9ab..a64de9fe88b 100644 --- a/mobile/src/host-screen/use-host-worktree-actions.ts +++ b/mobile/src/host-screen/workspace-actions/use-host-worktree-actions.ts @@ -1,52 +1,33 @@ import { useCallback } from 'react' import { useQueryClient } from '@tanstack/react-query' -import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../../src/shared/sidebar-inputs-switch' +import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../../../src/shared/sidebar-inputs-switch' import type { useRouter } from 'expo-router' -import { floatingWorkspaceSessionPath } from '../session/floating-workspace' -import { holdSessionLeave } from '../session/session-leave-guard' -import { savePinnedIds } from '../storage/preferences' -import type { useForgetHostClient } from '../transport/connection/host-client-hooks' -import { removeHostAndCloseClient } from '../host-catalog/host-removal-lifecycle' -import { isPageHostRemovalUnavailable } from '../host-catalog/page-host-removal-refusal' -import type { RpcClient } from '../transport/rpc/rpc-client-contract' +import { floatingWorkspaceSessionPath } from '../../session/floating-workspace' +import { holdSessionLeave } from '../../session/session-leave-guard' +import { savePinnedIds } from '../../storage/preferences' +import type { useForgetHostClient } from '../../transport/connection/host-client-hooks' +import { removeHostAndCloseClient } from '../../host-catalog/host-removal-lifecycle' +import { isPageHostRemovalUnavailable } from '../../host-catalog/page-host-removal-refusal' +import type { RpcClient } from '../../transport/rpc/rpc-client-contract' import { rpcClientForExecutionHost, workspaceRouteExecutionHost -} from '../worktree/execution-host-scoped-rpc-client' -import { serverUpdateNeededNotice } from '../host-route-notice' -import type { ConnectionState } from '../transport/connection/connection-state' -import { leaveHostRoute } from '../host-route-exit' -import { getWorktreeRowIdentity, isSameWorktreeRow } from '../worktree/worktree-host-row-identity' -import { noteLastVisitedWorktree } from '../worktree/last-visited-worktree-by-host' -import { isWorktreePinned, type Worktree } from '../worktree/workspace-list-sections' +} from '../../worktree/execution-host-scoped-rpc-client' +import { serverUpdateNeededNotice } from '../../host-route-notice' +import type { ConnectionState } from '../../transport/connection/connection-state' +import { leaveHostRoute } from '../../host-route-exit' import { - worktreeActivate, - worktreeDisplayNameWrite, - worktreePinWrite, - worktreeSleep -} from './host-screen-operations' -import type { HostScreenState } from './use-host-screen-state' + getWorktreeRowIdentity, + isSameWorktreeRow +} from '../../worktree/worktree-host-row-identity' +import { noteLastVisitedWorktree } from '../../worktree/last-visited-worktree-by-host' +import { isWorktreePinned } from '../../worktree/workspace-list-sections' +import type { Worktree } from '../../worktree/workspace-list-types' +import { worktreeActivate, worktreePinWrite, worktreeSleep } from '../host-screen-operations' +import type { HostScreenState } from '../use-host-screen-state' import { useHostWorktreeDelete } from './use-host-worktree-delete' -import { defineHostMutation, useHostMutation, type HostMutationPhase } from '../query/host-mutation' - -async function sendWorkspaceRename( - client: RpcClient, - vars: { worktreeId: string; displayName: string } -): Promise { - worktreeDisplayNameWrite.interpret( - await worktreeDisplayNameWrite.request(client, { - worktree: `id:${vars.worktreeId}`, - displayName: vars.displayName - }) - ) -} - -const workspaceRename = defineHostMutation({ - offline: 'draft', - send: sendWorkspaceRename, - // Renaming is idempotent, so a lost reply is settled by sending the same name again. - confirm: sendWorkspaceRename -}) +import { useHostMutation, type HostMutationPhase } from '../../query/host-mutation' +import { workspaceRename } from './workspace-rename-mutation' export function useHostWorktreeActions(args: { client: RpcClient | null diff --git a/mobile/src/host-screen/use-host-worktree-delete.ts b/mobile/src/host-screen/workspace-actions/use-host-worktree-delete.ts similarity index 91% rename from mobile/src/host-screen/use-host-worktree-delete.ts rename to mobile/src/host-screen/workspace-actions/use-host-worktree-delete.ts index eb4b90885c1..33dae59848e 100644 --- a/mobile/src/host-screen/use-host-worktree-delete.ts +++ b/mobile/src/host-screen/workspace-actions/use-host-worktree-delete.ts @@ -1,20 +1,20 @@ import { useCallback, useRef, useState } from 'react' -import type { PersistedTrustedOrcaHooks } from '../../../src/shared/orca-yaml-hook-types' -import { ARCHIVE_HOOK_FAILED_REMOVAL_CODE } from '../../../src/shared/worktree/archive-hook-removal-gate' -import type { OrcaHookTrustPrompt } from '../components/OrcaHookTrustDrawer' -import { newWorkspaceUiStateRead } from '../components/new-workspace-operations' -import { canUseRepoWideTrust } from '../../../src/shared/orca-hook-trust-entries' +import type { PersistedTrustedOrcaHooks } from '../../../../src/shared/orca-yaml-hook-types' +import { ARCHIVE_HOOK_FAILED_REMOVAL_CODE } from '../../../../src/shared/worktree/archive-hook-removal-gate' +import type { OrcaHookTrustPrompt } from '../../components/OrcaHookTrustDrawer' +import { newWorkspaceUiStateRead } from '../../components/new-workspace-operations' +import { canUseRepoWideTrust } from '../../../../src/shared/orca-hook-trust-entries' import { persistOrcaHookTrustApproval, wasOrcaHookPreviouslyApproved -} from '../new-workspace/creation/setup-hook-trust' -import { triggerError, triggerSuccess } from '../platform/haptics' -import type { RpcClient } from '../transport/rpc/rpc-client-contract' +} from '../../new-workspace/creation/setup-hook-trust' +import { triggerError, triggerSuccess } from '../../platform/haptics' +import type { RpcClient } from '../../transport/rpc/rpc-client-contract' import { buildWorktreeRemoveRequest } from './worktree-remove-request' -import { removeWorktreeRow } from '../worktree/worktree-host-row-identity' -import type { Worktree } from '../worktree/workspace-list-sections' -import { worktreeArchiveHookTrustRead, worktreeRemove } from './host-screen-operations' -import type { HostScreenState } from './use-host-screen-state' +import { removeWorktreeRow } from '../../worktree/worktree-host-row-identity' +import type { Worktree } from '../../worktree/workspace-list-types' +import { worktreeArchiveHookTrustRead, worktreeRemove } from '../host-screen-operations' +import type { HostScreenState } from '../use-host-screen-state' type ArchiveHookDecision = | { kind: 'run' | 'skip' } diff --git a/mobile/src/host-screen/workspace-details-sheet.tsx b/mobile/src/host-screen/workspace-actions/workspace-details-sheet.tsx similarity index 90% rename from mobile/src/host-screen/workspace-details-sheet.tsx rename to mobile/src/host-screen/workspace-actions/workspace-details-sheet.tsx index e285c5b6480..1bf6ac07cee 100644 --- a/mobile/src/host-screen/workspace-details-sheet.tsx +++ b/mobile/src/host-screen/workspace-actions/workspace-details-sheet.tsx @@ -8,30 +8,30 @@ import { SquareTerminal, StickyNote } from 'lucide-react-native' -import { colors, spacing, typography } from '../theme/mobile-theme' -import { themedStyleSheet } from '../theme/themed-styles' -import { useMobileColorScheme } from '../theme/use-mobile-color-scheme' -import { prStateColor } from '../components/WorktreeMetaGlyphs' -import { PrStateIcon } from '../components/PrStateIcon' +import { colors, spacing, typography } from '../../theme/mobile-theme' +import { themedStyleSheet } from '../../theme/themed-styles' +import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme' +import { prStateColor } from '../../components/WorktreeMetaGlyphs' +import { PrStateIcon } from '../../components/PrStateIcon' import { LIST_LEADING_INSET, LIST_LEADING_SLOT, LIST_ROW_PADDING, ListGroup, ListRow -} from '../components/list/ListGroup' -import { TaskProviderLogo } from '../components/TaskProviderLogo' -import { getWorktreeGitIdentityDisplay } from '../../../src/shared/worktree/git-identity-display' +} from '../../components/list/ListGroup' +import { TaskProviderLogo } from '../../components/TaskProviderLogo' +import { getWorktreeGitIdentityDisplay } from '../../../../src/shared/worktree/git-identity-display' import { buildWorkspaceDetailLinks, buildWorkspaceOrigin, workspaceNotes, type WorkspaceDetailLink, type WorkspaceOrigin -} from '../worktree/workspace-details-model' -import type { Worktree } from '../worktree/workspace-list-types' -import type { Translate } from '../i18n/mobile-i18n' -import { useMobileTranslation } from '../i18n/use-mobile-translation' +} from '../../worktree/workspace-details-model' +import type { Worktree } from '../../worktree/workspace-list-types' +import type { Translate } from '../../i18n/mobile-i18n' +import { useMobileTranslation } from '../../i18n/use-mobile-translation' const ICON_SIZE = 16 diff --git a/mobile/src/host-screen/workspace-actions/workspace-rename-mutation.ts b/mobile/src/host-screen/workspace-actions/workspace-rename-mutation.ts new file mode 100644 index 00000000000..88fa50851dc --- /dev/null +++ b/mobile/src/host-screen/workspace-actions/workspace-rename-mutation.ts @@ -0,0 +1,22 @@ +import { worktreeDisplayNameWrite } from '../host-screen-operations' +import type { RpcClient } from '../../transport/rpc/rpc-client-contract' +import { defineHostMutation } from '../../query/host-mutation' + +async function sendWorkspaceRename( + client: RpcClient, + vars: { worktreeId: string; displayName: string } +): Promise { + worktreeDisplayNameWrite.interpret( + await worktreeDisplayNameWrite.request(client, { + worktree: `id:${vars.worktreeId}`, + displayName: vars.displayName + }) + ) +} + +export const workspaceRename = defineHostMutation({ + offline: 'draft', + send: sendWorkspaceRename, + // Renaming is idempotent, so a lost reply is settled by sending the same name again. + confirm: sendWorkspaceRename +}) diff --git a/mobile/src/host-screen/workspace-rename-sheet.tsx b/mobile/src/host-screen/workspace-actions/workspace-rename-sheet.tsx similarity index 78% rename from mobile/src/host-screen/workspace-rename-sheet.tsx rename to mobile/src/host-screen/workspace-actions/workspace-rename-sheet.tsx index 52f525515db..a9bc55d11bc 100644 --- a/mobile/src/host-screen/workspace-rename-sheet.tsx +++ b/mobile/src/host-screen/workspace-actions/workspace-rename-sheet.tsx @@ -1,10 +1,10 @@ -import { automaticWorktreeDisplayName } from '../../../src/shared/worktree/display-name-provenance' -import { TextInputSheetContent } from '../components/TextInputModal' -import { useDraftRenameSheet } from '../components/use-draft-rename-sheet' -import { useMobileTranslation } from '../i18n/use-mobile-translation' -import type { HostMutationPhase } from '../query/host-mutation' -import type { Worktree } from '../worktree/workspace-list-types' -import { getWorktreeRowIdentity } from '../worktree/worktree-host-row-identity' +import { automaticWorktreeDisplayName } from '../../../../src/shared/worktree/display-name-provenance' +import { TextInputSheetContent } from '../../components/TextInputModal' +import { useDraftRenameSheet } from '../../components/use-draft-rename-sheet' +import { useMobileTranslation } from '../../i18n/use-mobile-translation' +import type { HostMutationPhase } from '../../query/host-mutation' +import type { Worktree } from '../../worktree/workspace-list-types' +import { getWorktreeRowIdentity } from '../../worktree/worktree-host-row-identity' export function workspaceRenameDraftKey(hostId: string, row: Worktree): string { return `workspace-rename:${hostId}:${getWorktreeRowIdentity(row)}` diff --git a/mobile/src/host-screen/worktree-remove-request.ts b/mobile/src/host-screen/workspace-actions/worktree-remove-request.ts similarity index 78% rename from mobile/src/host-screen/worktree-remove-request.ts rename to mobile/src/host-screen/workspace-actions/worktree-remove-request.ts index 1d8fc8e8a4d..fda5a919efc 100644 --- a/mobile/src/host-screen/worktree-remove-request.ts +++ b/mobile/src/host-screen/workspace-actions/worktree-remove-request.ts @@ -1,6 +1,6 @@ -import { worktreeRemovalReplyTimeoutMs } from '../../../src/shared/worktree/archive-hook-removal-gate' -import { RECIPE_REMOVAL_REPLY_TIMEOUT_MS } from '../../../src/shared/recipe-host-removal' -import type { Worktree } from '../worktree/workspace-list-sections' +import { worktreeRemovalReplyTimeoutMs } from '../../../../src/shared/worktree/archive-hook-removal-gate' +import { RECIPE_REMOVAL_REPLY_TIMEOUT_MS } from '../../../../src/shared/recipe-host-removal' +import type { Worktree } from '../../worktree/workspace-list-types' export type WorktreeRemoveHooks = { runHooks: boolean diff --git a/mobile/src/host-screen/host-pending-creation-row.tsx b/mobile/src/host-screen/workspace-list/host-pending-creation-row.tsx similarity index 80% rename from mobile/src/host-screen/host-pending-creation-row.tsx rename to mobile/src/host-screen/workspace-list/host-pending-creation-row.tsx index 8be4f9e8ece..185643a13a0 100644 --- a/mobile/src/host-screen/host-pending-creation-row.tsx +++ b/mobile/src/host-screen/workspace-list/host-pending-creation-row.tsx @@ -1,9 +1,9 @@ import { ActivityIndicator, Text, View } from 'react-native' -import { colors, spacing, typography } from '../theme/mobile-theme' -import { themedStyleSheet } from '../theme/themed-styles' -import { useMobileColorScheme } from '../theme/use-mobile-color-scheme' -import type { SectionPendingCreation } from '../worktree/workspace-list-types' -import { useMobileTranslation } from '../i18n/use-mobile-translation' +import { colors, spacing, typography } from '../../theme/mobile-theme' +import { themedStyleSheet } from '../../theme/themed-styles' +import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme' +import type { SectionPendingCreation } from '../../worktree/workspace-list-types' +import { useMobileTranslation } from '../../i18n/use-mobile-translation' export function HostPendingCreationRow({ creation }: { creation: SectionPendingCreation }) { const t = useMobileTranslation() diff --git a/mobile/src/host-screen/host-workspace-list.tsx b/mobile/src/host-screen/workspace-list/host-workspace-list.tsx similarity index 88% rename from mobile/src/host-screen/host-workspace-list.tsx rename to mobile/src/host-screen/workspace-list/host-workspace-list.tsx index 745d53c1736..61d38dfb911 100644 --- a/mobile/src/host-screen/host-workspace-list.tsx +++ b/mobile/src/host-screen/workspace-list/host-workspace-list.tsx @@ -9,36 +9,36 @@ import { View } from 'react-native' import { ChevronRight, FolderTree, Pin } from 'lucide-react-native' -import { AuthFailedBanner } from '../components/AuthFailedBanner' -import { Presence } from '../components/AutoHeight' -import { DisclosureChevron } from '../components/DisclosureChevron' -import { HostDiagnosticsLink } from '../components/HostDiagnosticsLink' -import { HostOfflineBanner, useHostOfflineBanner } from '../components/HostOfflineBanner' -import { isConnectionOutage } from '../transport/connection/connection-health' -import { HostRouteNoticeBanner } from '../components/HostRouteNoticeBanner' -import { MobileRepoIcon } from '../components/MobileRepoIcon' -import type { CollapsingTitle } from '../components/nav-bar/PageTitle' -import { WorktreeListRow } from '../components/WorktreeListRow' -import { WORKTREE_ROW_LAYOUT } from '../components/worktree-list-row-layout' -import { colors, spacing } from '../theme/mobile-theme' -import { useMobileColorScheme } from '../theme/use-mobile-color-scheme' -import { getWorktreeRowIdentity } from '../worktree/worktree-host-row-identity' +import { AuthFailedBanner } from '../../components/AuthFailedBanner' +import { Presence } from '../../components/AutoHeight' +import { DisclosureChevron } from '../../components/DisclosureChevron' +import { HostDiagnosticsLink } from '../../components/HostDiagnosticsLink' +import { HostOfflineBanner, useHostOfflineBanner } from '../../components/HostOfflineBanner' +import { isConnectionOutage } from '../../transport/connection/connection-health' +import { HostRouteNoticeBanner } from '../../components/HostRouteNoticeBanner' +import { MobileRepoIcon } from '../../components/MobileRepoIcon' +import type { CollapsingTitle } from '../../components/nav-bar/PageTitle' +import { WorktreeListRow } from '../../components/WorktreeListRow' +import { WORKTREE_ROW_LAYOUT } from '../../components/worktree-list-row-layout' +import { colors, spacing } from '../../theme/mobile-theme' +import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme' +import { getWorktreeRowIdentity } from '../../worktree/worktree-host-row-identity' import { HostWorkspaceListReopen, HostWorkspaceListStates -} from '../worktree/host-workspace-list-states' -import { getWorktreeDotStatus, hasWorktreeAgentState } from '../worktree/workspace-list-ordering' -import type { Section, WorkspaceListItem, Worktree } from '../worktree/workspace-list-types' -import { DEFAULT_REPO_BADGE_COLOR } from '../../../src/shared/repo-badge-color' -import { getWorktreeCardDetails } from '../../../src/shared/worktree/card-properties' -import { hostScreenStyles as styles } from './host-screen-styles' -import { useRepoLookById, type RepoLook } from './host-repo-looks' +} from '../../worktree/host-workspace-list-states' +import { getWorktreeDotStatus, hasWorktreeAgentState } from '../../worktree/workspace-list-ordering' +import type { Section, WorkspaceListItem, Worktree } from '../../worktree/workspace-list-types' +import { DEFAULT_REPO_BADGE_COLOR } from '../../../../src/shared/repo-badge-color' +import { getWorktreeCardDetails } from '../../../../src/shared/worktree/card-properties' +import { hostScreenStyles as styles } from '../host-screen-styles' +import { useRepoLookById, type RepoLook } from '../catalog/host-repo-looks' import { HostPendingCreationRow } from './host-pending-creation-row' import { useSlowPendingKeys } from './use-slow-pending-keys' -import { useHostMutationDrafts } from '../query/host-mutation-drafts' -import { workspaceRenameDraftKey } from './workspace-rename-sheet' -import { projectGroupHeaderIndent } from '../worktree/project-group-indent' -import type { HostScreenController } from './use-host-screen-controller' +import { useHostMutationDrafts } from '../../query/host-mutation-drafts' +import { workspaceRenameDraftKey } from '../workspace-actions/workspace-rename-sheet' +import { projectGroupHeaderIndent } from '../../worktree/project-group-indent' +import type { HostScreenController } from '../use-host-screen-controller' export function HostWorkspaceList({ controller, diff --git a/mobile/src/host-screen/use-host-display-worktrees.ts b/mobile/src/host-screen/workspace-list/use-host-display-worktrees.ts similarity index 82% rename from mobile/src/host-screen/use-host-display-worktrees.ts rename to mobile/src/host-screen/workspace-list/use-host-display-worktrees.ts index 517c0924fa2..aac98051673 100644 --- a/mobile/src/host-screen/use-host-display-worktrees.ts +++ b/mobile/src/host-screen/workspace-list/use-host-display-worktrees.ts @@ -1,14 +1,14 @@ import { useMemo } from 'react' -import { useHostDescriptor } from '../host-descriptors/host-descriptor-store' -import type { ConnectionState } from '../transport/connection/connection-state' +import { useHostDescriptor } from '../../host-descriptors/host-descriptor-store' +import type { ConnectionState } from '../../transport/connection/connection-state' import { serverHostHealth, serverHostHealthLabels, withServerHosts -} from '../worktree/server-workspaces' -import { buildWorktreeHostContextOf } from '../worktree/worktree-host-context-labels' -import { applyWorktreeRowDisplayState } from '../worktree/worktree-host-row-identity' -import type { HostScreenState } from './use-host-screen-state' +} from '../../worktree/server-workspaces' +import { buildWorktreeHostContextOf } from '../../worktree/worktree-host-context-labels' +import { applyWorktreeRowDisplayState } from '../../worktree/worktree-host-row-identity' +import type { HostScreenState } from '../use-host-screen-state' /** The desktop's platform: its own report, or the paired descriptor's before that lands. */ export function useResolvedHostPlatform( diff --git a/mobile/src/host-screen/use-host-list-sections.ts b/mobile/src/host-screen/workspace-list/use-host-list-sections.ts similarity index 78% rename from mobile/src/host-screen/use-host-list-sections.ts rename to mobile/src/host-screen/workspace-list/use-host-list-sections.ts index 96ca76f8333..e1ac7799ab2 100644 --- a/mobile/src/host-screen/use-host-list-sections.ts +++ b/mobile/src/host-screen/workspace-list/use-host-list-sections.ts @@ -1,25 +1,28 @@ import { useMemo } from 'react' -import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../../src/shared/sidebar-inputs-switch' -import { buildSidebarFeedRows } from '../../../src/shared/sidebar-list/sidebar-feed-rows' -import { repoIconFeedKey } from '../../../src/shared/sidebar-list/repo-icon-data-urls-contract' -import type { ProjectOrderBy } from '../../../src/shared/ui-chrome-types' -import type { RpcClient } from '../transport/rpc/rpc-client-contract' -import { buildProjectSectionCatalog } from '../worktree/project-section-catalog' -import { useWorkspaceSections } from '../worktree/use-workspace-sections' -import { buildFilterRepos, type WorkspaceSectionRepo } from '../worktree/workspace-filter-repos' -import type { Section, SectionRepo, Worktree } from '../worktree/workspace-list-types' -import type { WorktreeHostContextOf } from '../worktree/worktree-host-context-labels' -import type { MobileLineageStores } from '../worktree/mobile-workspace-lineage' -import { useLastVisitedWorktreeOnHost } from '../worktree/use-last-visited-worktree-on-host' -import { rowFiltersForView } from '../worktree/workspace-view-settings' -import { buildSidebarFeedCatalog } from '../../../src/shared/sidebar-list/sidebar-feed-catalog' -import { buildHostFeedView } from './host-sidebar-feed-inputs' -import { buildHostFeedSections, buildPreFeedSections } from './host-sidebar-feed-sections' -import { useHostRepoIconDataUrls } from './use-host-repo-icon-data-urls' -import type { HostSidebarFeed } from './use-host-sidebar-feed' -import type { HostScreenState } from './use-host-screen-state' -import { indexHiddenProjects } from '../../../src/shared/hidden-projects' -import { LOCAL_EXECUTION_HOST_ID } from '../../../src/shared/execution-host' +import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../../../src/shared/sidebar-inputs-switch' +import { buildSidebarFeedRows } from '../../../../src/shared/sidebar-list/sidebar-feed-rows' +import { repoIconFeedKey } from '../../../../src/shared/sidebar-list/repo-icon-data-urls-contract' +import type { ProjectOrderBy } from '../../../../src/shared/ui-chrome-types' +import type { RpcClient } from '../../transport/rpc/rpc-client-contract' +import { buildProjectSectionCatalog } from '../../worktree/project-section-catalog' +import { useWorkspaceSections } from '../../worktree/use-workspace-sections' +import { buildFilterRepos, type WorkspaceSectionRepo } from '../../worktree/workspace-filter-repos' +import type { Section, SectionRepo, Worktree } from '../../worktree/workspace-list-types' +import type { WorktreeHostContextOf } from '../../worktree/worktree-host-context-labels' +import type { MobileLineageStores } from '../../worktree/mobile-workspace-lineage' +import { useLastVisitedWorktreeOnHost } from '../../worktree/use-last-visited-worktree-on-host' +import { rowFiltersForView } from '../../worktree/workspace-view-settings' +import { buildSidebarFeedCatalog } from '../../../../src/shared/sidebar-list/sidebar-feed-catalog' +import { buildHostFeedView } from '../sidebar-feed/host-sidebar-feed-inputs' +import { + buildHostFeedSections, + buildPreFeedSections +} from '../sidebar-feed/host-sidebar-feed-sections' +import { useHostRepoIconDataUrls } from '../catalog/use-host-repo-icon-data-urls' +import type { HostSidebarFeed } from '../sidebar-feed/use-host-sidebar-feed' +import type { HostScreenState } from '../use-host-screen-state' +import { indexHiddenProjects } from '../../../../src/shared/hidden-projects' +import { LOCAL_EXECUTION_HOST_ID } from '../../../../src/shared/execution-host' export type HostListSectionsInputs = { client: RpcClient | null diff --git a/mobile/src/host-screen/use-slow-pending-keys.test.tsx b/mobile/src/host-screen/workspace-list/use-slow-pending-keys.test.tsx similarity index 100% rename from mobile/src/host-screen/use-slow-pending-keys.test.tsx rename to mobile/src/host-screen/workspace-list/use-slow-pending-keys.test.tsx diff --git a/mobile/src/host-screen/use-slow-pending-keys.ts b/mobile/src/host-screen/workspace-list/use-slow-pending-keys.ts similarity index 100% rename from mobile/src/host-screen/use-slow-pending-keys.ts rename to mobile/src/host-screen/workspace-list/use-slow-pending-keys.ts diff --git a/mobile/src/host-screen/phone-card-property-choices.ts b/mobile/src/host-screen/workspace-options/phone-card-property-choices.ts similarity index 75% rename from mobile/src/host-screen/phone-card-property-choices.ts rename to mobile/src/host-screen/workspace-options/phone-card-property-choices.ts index 66f4736120c..d68a4fa5e6e 100644 --- a/mobile/src/host-screen/phone-card-property-choices.ts +++ b/mobile/src/host-screen/workspace-options/phone-card-property-choices.ts @@ -1,5 +1,5 @@ -import type { WorkspaceViewChoice } from '../../../src/shared/workspace-view/workspace-view-options' -import type { WorktreeCardProperty } from '../../../src/shared/ui-chrome-types' +import type { WorkspaceViewChoice } from '../../../../src/shared/workspace-view/workspace-view-options' +import type { WorktreeCardProperty } from '../../../../src/shared/ui-chrome-types' // Omit Ports: the phone has no port scan; desktop retains it. const PHONE_HIDDEN_CARD_PROPERTIES: ReadonlySet = new Set(['ports']) diff --git a/mobile/src/host-screen/workspace-option-controls-copy.ts b/mobile/src/host-screen/workspace-options/workspace-option-controls-copy.ts similarity index 92% rename from mobile/src/host-screen/workspace-option-controls-copy.ts rename to mobile/src/host-screen/workspace-options/workspace-option-controls-copy.ts index 89d8213811b..188a6a2507c 100644 --- a/mobile/src/host-screen/workspace-option-controls-copy.ts +++ b/mobile/src/host-screen/workspace-options/workspace-option-controls-copy.ts @@ -1,9 +1,9 @@ -import * as viewOptions from '../../../src/shared/workspace-view/workspace-view-options' -import type { WorkspaceViewChoice } from '../../../src/shared/workspace-view/workspace-view-options' -import type { WorktreeCardProperty } from '../../../src/shared/ui-chrome-types' -import type { Translate } from '../i18n/mobile-i18n' +import * as viewOptions from '../../../../src/shared/workspace-view/workspace-view-options' +import type { WorkspaceViewChoice } from '../../../../src/shared/workspace-view/workspace-view-options' +import type { WorktreeCardProperty } from '../../../../src/shared/ui-chrome-types' +import type { Translate } from '../../i18n/mobile-i18n' import { phoneCardPropertyChoices } from './phone-card-property-choices' -import type { HostScreenController } from './use-host-screen-controller' +import type { HostScreenController } from '../use-host-screen-controller' function workspaceOptionChoices( choices: readonly WorkspaceViewChoice[], diff --git a/mobile/src/host-screen/workspace-option-controls.tsx b/mobile/src/host-screen/workspace-options/workspace-option-controls.tsx similarity index 91% rename from mobile/src/host-screen/workspace-option-controls.tsx rename to mobile/src/host-screen/workspace-options/workspace-option-controls.tsx index 95556c3c644..9863b9fe464 100644 --- a/mobile/src/host-screen/workspace-option-controls.tsx +++ b/mobile/src/host-screen/workspace-options/workspace-option-controls.tsx @@ -1,19 +1,19 @@ import { useState, type ComponentType } from 'react' import { View } from 'react-native' -import { MobileSearchField } from '../components/MobileSearchField' -import { MobileSegmentedControl } from '../components/MobileSegmentedControl' -import * as viewOptions from '../../../src/shared/workspace-view/workspace-view-options' +import { MobileSearchField } from '../../components/MobileSearchField' +import { MobileSegmentedControl } from '../../components/MobileSegmentedControl' +import * as viewOptions from '../../../../src/shared/workspace-view/workspace-view-options' import type { WorkspaceViewChoice, WorkspaceViewOptionKey -} from '../../../src/shared/workspace-view/workspace-view-options' -import * as hostVisibility from '../../../src/shared/workspace-view/workspace-host-visibility' -import type { ExecutionHostId } from '../../../src/shared/execution-host' -import type { WorktreeCardProperty } from '../../../src/shared/ui-chrome-types' -import { useMobileColorScheme } from '../theme/use-mobile-color-scheme' -import { hostScreenStyles as styles } from './host-screen-styles' +} from '../../../../src/shared/workspace-view/workspace-view-options' +import * as hostVisibility from '../../../../src/shared/workspace-view/workspace-host-visibility' +import type { ExecutionHostId } from '../../../../src/shared/execution-host' +import type { WorktreeCardProperty } from '../../../../src/shared/ui-chrome-types' +import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme' +import { hostScreenStyles as styles } from '../host-screen-styles' import { withoutPhoneHiddenProperties } from './phone-card-property-choices' -import type { HostScreenController } from './use-host-screen-controller' +import type { HostScreenController } from '../use-host-screen-controller' import { groupByChoices, sortByChoices, @@ -23,11 +23,11 @@ import { cardPropertyChoices, cardDisplayChoices } from './workspace-option-controls-copy' -import { ListGroup, SectionHeader } from '../components/list/ListGroup' +import { ListGroup, SectionHeader } from '../../components/list/ListGroup' import { CheckRow, ChoiceRows, SwitchRow } from './workspace-option-rows' import { choiceLabel, summarizeChecklist } from './workspace-option-values' -import type { Translate } from '../i18n/mobile-i18n' -import { useMobileTranslation } from '../i18n/use-mobile-translation' +import type { Translate } from '../../i18n/mobile-i18n' +import { useMobileTranslation } from '../../i18n/use-mobile-translation' export type WorkspaceOptionHost = { id: ExecutionHostId; label: string } diff --git a/mobile/src/host-screen/workspace-option-rows.tsx b/mobile/src/host-screen/workspace-options/workspace-option-rows.tsx similarity index 88% rename from mobile/src/host-screen/workspace-option-rows.tsx rename to mobile/src/host-screen/workspace-options/workspace-option-rows.tsx index a90265472df..caa67a9ced1 100644 --- a/mobile/src/host-screen/workspace-option-rows.tsx +++ b/mobile/src/host-screen/workspace-options/workspace-option-rows.tsx @@ -1,8 +1,8 @@ import type { ReactNode } from 'react' import { View } from 'react-native' -import { MobileSwitch } from '../components/MobileSwitch' -import { ListGroup, ListRow } from '../components/list/ListGroup' -import type { WorkspaceViewChoice } from '../../../src/shared/workspace-view/workspace-view-options' +import { MobileSwitch } from '../../components/MobileSwitch' +import { ListGroup, ListRow } from '../../components/list/ListGroup' +import type { WorkspaceViewChoice } from '../../../../src/shared/workspace-view/workspace-view-options' export function CheckRow(props: { label: string diff --git a/mobile/src/host-screen/workspace-option-values.ts b/mobile/src/host-screen/workspace-options/workspace-option-values.ts similarity index 100% rename from mobile/src/host-screen/workspace-option-values.ts rename to mobile/src/host-screen/workspace-options/workspace-option-values.ts diff --git a/mobile/src/host-screen/workspace-options-sheet.tsx b/mobile/src/host-screen/workspace-options/workspace-options-sheet.tsx similarity index 90% rename from mobile/src/host-screen/workspace-options-sheet.tsx rename to mobile/src/host-screen/workspace-options/workspace-options-sheet.tsx index 284c9d08a9d..d3757a8be0b 100644 --- a/mobile/src/host-screen/workspace-options-sheet.tsx +++ b/mobile/src/host-screen/workspace-options/workspace-options-sheet.tsx @@ -1,35 +1,35 @@ import { useMemo, type ReactNode } from 'react' import { ActivityIndicator, Text, View } from 'react-native' import { ChevronLeft } from 'lucide-react-native' -import { colors } from '../theme/mobile-theme' -import { useMobileColorScheme } from '../theme/use-mobile-color-scheme' -import { useBackClaim } from '../navigation/use-back-claim' +import { colors } from '../../theme/mobile-theme' +import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme' +import { useBackClaim } from '../../navigation/use-back-claim' import { BottomDrawer, OptionPager, useOptionPager, type OptionPagerControls -} from '../components/bottom-drawer/BottomDrawer' -import { NavBarButton } from '../components/nav-bar/NavBarButton' -import { SheetHeader } from '../components/nav-bar/SheetHeader' +} from '../../components/bottom-drawer/BottomDrawer' +import { NavBarButton } from '../../components/nav-bar/NavBarButton' +import { SheetHeader } from '../../components/nav-bar/SheetHeader' import { LOCAL_EXECUTION_HOST_ID, parseExecutionHostId, type ExecutionHostId -} from '../../../src/shared/execution-host' +} from '../../../../src/shared/execution-host' import { getShownWorkspaceViewSections, type WorkspaceViewOptionKey, type WorkspaceViewSection -} from '../../../src/shared/workspace-view/workspace-view-options' -import { getHostContextLabel } from '../../../src/shared/worktree/host-context-labels' -import type { Translate } from '../i18n/mobile-i18n' -import { useMobileTranslation } from '../i18n/use-mobile-translation' -import { resolveWorktreeHostId } from '../worktree/worktree-host-context-labels' -import { withServerHosts } from '../worktree/server-workspaces' -import { hostScreenStyles as styles } from './host-screen-styles' -import type { HostScreenController } from './use-host-screen-controller' -import { ListGroup, SectionHeader } from '../components/list/ListGroup' +} from '../../../../src/shared/workspace-view/workspace-view-options' +import { getHostContextLabel } from '../../../../src/shared/worktree/host-context-labels' +import type { Translate } from '../../i18n/mobile-i18n' +import { useMobileTranslation } from '../../i18n/use-mobile-translation' +import { resolveWorktreeHostId } from '../../worktree/worktree-host-context-labels' +import { withServerHosts } from '../../worktree/server-workspaces' +import { hostScreenStyles as styles } from '../host-screen-styles' +import type { HostScreenController } from '../use-host-screen-controller' +import { ListGroup, SectionHeader } from '../../components/list/ListGroup' import { DrillRow } from './workspace-option-rows' import { WORKSPACE_OPTION_CONTROLS, diff --git a/mobile/src/host-screen/worktree-remove-request.test.ts b/mobile/src/host-screen/worktree-remove-request.test.ts deleted file mode 100644 index a787e7ca421..00000000000 --- a/mobile/src/host-screen/worktree-remove-request.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { worktreeRemovalReplyTimeoutMs } from '../../../src/shared/worktree/archive-hook-removal-gate' -import { RECIPE_REMOVAL_REPLY_TIMEOUT_MS } from '../../../src/shared/recipe-host-removal' -import { buildWorktreeRemoveRequest } from './worktree-remove-request' - -describe('phone worktree delete request', () => { - it('keeps the ordinary delete for workspaces the desktop did not tag', () => { - expect(buildWorktreeRemoveRequest({ worktreeId: 'r::/w' }, { runHooks: false })).toEqual({ - params: { worktree: 'id:r::/w', force: true, runHooks: false }, - timeoutMs: worktreeRemovalReplyTimeoutMs(false) - }) - }) - - it('asks for the named recipe environment to be destroyed, and waits for its destroy step', () => { - const request = buildWorktreeRemoveRequest( - { worktreeId: 'r::/w', recipeEnvironment: { name: 'task' } }, - { runHooks: true } - ) - expect(request.params).toEqual({ - worktree: 'id:r::/w', - force: true, - runHooks: true, - destroyHost: true - }) - expect(request.timeoutMs).toBe(RECIPE_REMOVAL_REPLY_TIMEOUT_MS) - }) -}) diff --git a/mobile/src/mobile-web-shell/bridge/bridge-native-verbs.ts b/mobile/src/mobile-web-shell/bridge/bridge-native-verbs.ts index 7dfff72f157..25af8db2513 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-native-verbs.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-native-verbs.ts @@ -21,7 +21,7 @@ import { imageParamsSchema, imageResultSchema } from './bridge-image-verbs' import { BRIDGE_SPEECH_VERB, speechParamsSchema, speechResultSchema } from './bridge-speech-verbs' import { BRIDGE_STORE_VERB, storeParamsSchema, storeResultSchema } from './bridge-store-verb' import { PAGE_STORAGE_MAX_KEY_CHARS } from '../page-storage-keys' -import { mobileFileMediaMime, PDF_MIME_TYPE } from '../../files/mobile-file-media' +import { mobileFileMediaMime, PDF_MIME_TYPE } from '../../files/media/mobile-file-media' // Fence native-prefixed requests on-device; desktop allowlist refusal alone would hide leaked forwarding and depend on desktop version. // Native replies use existing frames/in-flight caps without runtime _meta; adding a verb needs a table row and handler, never a new frame kind. diff --git a/mobile/src/mobile-web-shell/shell-screen-route.test.ts b/mobile/src/mobile-web-shell/shell-screen-route.test.ts deleted file mode 100644 index 62da0363a8b..00000000000 --- a/mobile/src/mobile-web-shell/shell-screen-route.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { BRIDGE_MAX_ROUTE_PARAM_CHARS } from './bridge/bridge-caps' -import { BridgeInitRouteSchema, type BridgeInitRoute } from './bridge/bridge-envelope' -import { shellRouteHref } from './bridge/page-bootstrap' -import { shellScreenRoute, shellScreenRouteKey } from './shell-screen-route' -import { - mobileFilePreviewShellParams, - normalizeMobileFilePreviewRouteParams -} from '../files/mobile-file-preview-route' - -const PREVIEW_PATH = '/h/host-1/files/preview/wt-1' - -function previewRoute(absolutePath: string) { - const route = normalizeMobileFilePreviewRouteParams({ - hostId: 'host-1', - worktreeId: 'wt-1', - source: 'terminalArtifact', - absolutePath, - grantId: 'grant-1' - }) - if (!route.ok) { - throw new Error(route.message) - } - return { pathname: PREVIEW_PATH, params: mobileFilePreviewShellParams(route.params) } -} - -describe('the route a switch hands the shell', () => { - it('is one the page could actually be given', () => { - const route = previewRoute('/logs/run.txt') - expect(BridgeInitRouteSchema.safeParse(route).success).toBe(true) - expect(shellScreenRoute(route)).toEqual(route) - }) - - it('is nothing when a file path is longer than a param may be', () => { - // Use a real Windows long path: without a length guard the bridge drops init.route to null and shows an update refusal over a working native screen. - const route = previewRoute(`/logs/${'a'.repeat(BRIDGE_MAX_ROUTE_PARAM_CHARS)}.txt`) - expect(BridgeInitRouteSchema.safeParse(route).success).toBe(false) - expect(shellScreenRoute(route)).toBeNull() - }) - - it('is nothing when a worktree id is not a segment the page will route', () => { - // Dot segments survive encodeURIComponent and escape the intended /h/ prefix when resolved. - const route = { pathname: '/h/host-1/files/..', params: { name: 'Files' } } - // Prove schema refusal too so an always-null guard cannot pass. - expect(BridgeInitRouteSchema.safeParse(route).success).toBe(false) - expect(shellScreenRoute(route)).toBeNull() - }) - - it('keeps a path with a slash, a space and a dot segment, which are params and not segments', () => { - const route = { - pathname: '/h/host-1/files/preview/wt-1', - params: { relativePath: 'docs/../my notes/readme.md', source: 'worktree' } - } - expect(shellScreenRoute(route)).toEqual(route) - }) -}) - -describe('the key a shell screen remounts on', () => { - // Pin native remount keys to the page serializer’s URL; importing its RPC-adjacent module into native routes would cross the platform boundary. - it.each([ - { pathname: '/h/host-1/files/wt-1' }, - { pathname: '/h/host-1/files/wt-1', params: { name: 'my worktree' } }, - { - pathname: '/h/host-1/files/preview/wt-1', - params: { relativePath: 'docs/my notes/readme.md', source: 'worktree', line: '12' } - } - ])('is the href the page would write into its history: %o', (route) => { - expect(shellScreenRouteKey(route)).toBe(shellRouteHref(route)) - }) -}) diff --git a/mobile/src/mobile-web-shell/shell-screen-route.ts b/mobile/src/mobile-web-shell/shell-screen-route.ts index 79dfadc1924..4cbafc53914 100644 --- a/mobile/src/mobile-web-shell/shell-screen-route.ts +++ b/mobile/src/mobile-web-shell/shell-screen-route.ts @@ -7,7 +7,7 @@ export function shellScreenRoute(route: BridgeInitRoute): BridgeInitRoute | null } // Include params in remount identity so files/labels update the document; pane taps deliberately use a separate in-place init path. -// Match the page bootstrap serializer, pinned by tests instead of importing its RPC/document dependencies into native routes. +// Match the page bootstrap serializer without importing its RPC/document dependencies into native routes. export function shellScreenRouteKey(route: BridgeInitRoute): string { const search = new URLSearchParams(route.params ?? {}).toString() return search === '' ? route.pathname : `${route.pathname}?${search}` diff --git a/mobile/src/new-workspace/use-host-label-by-id.ts b/mobile/src/new-workspace/use-host-label-by-id.ts index 6ea165fc630..8bc1410d4c1 100644 --- a/mobile/src/new-workspace/use-host-label-by-id.ts +++ b/mobile/src/new-workspace/use-host-label-by-id.ts @@ -4,7 +4,7 @@ import { HOST_LIST_TARGET, hostRepoLabelsQuery, NO_HOST_LIST_INPUT -} from '../host-screen/host-list-queries' +} from '../host-screen/catalog/host-list-queries' import { useHostQuery } from '../query/host-query' import type { RpcClient } from '../transport/rpc/rpc-client-contract' diff --git a/mobile/src/new-workspace/use-host-worktree-rows.ts b/mobile/src/new-workspace/use-host-worktree-rows.ts index 8b3a5693abe..d78cf11e4ed 100644 --- a/mobile/src/new-workspace/use-host-worktree-rows.ts +++ b/mobile/src/new-workspace/use-host-worktree-rows.ts @@ -5,7 +5,7 @@ import { HOST_LIST_TARGET, hostWorktreeCatalogQuery, NO_HOST_LIST_INPUT -} from '../host-screen/host-list-queries' +} from '../host-screen/catalog/host-list-queries' type WorktreeRow = Pick diff --git a/mobile/src/platform/native-pdf-view.ts b/mobile/src/platform/native-pdf-view.ts index 4f86f7c2771..c1627e2573a 100644 --- a/mobile/src/platform/native-pdf-view.ts +++ b/mobile/src/platform/native-pdf-view.ts @@ -4,12 +4,12 @@ import { File as FsFile } from 'expo-file-system' import { useFocusEffect, useNavigation } from 'expo-router' import { presentPdf } from '../../modules/orca-mobile-web-shell/src' import { getRuntimePathBasename } from '../../../src/shared/cross-platform-path' -import { PDF_MIME_TYPE } from '../files/mobile-file-media' +import { PDF_MIME_TYPE } from '../files/media/mobile-file-media' import { downloadMobileFileMedia, MobileMediaHostOutdatedError, type MobileMediaSink -} from '../files/mobile-file-media-download' +} from '../files/media/mobile-file-media-download' import { pdfViewParamsSchema, type BridgePdfOpenParams diff --git a/mobile/src/query/first-paint-query-keys.ts b/mobile/src/query/first-paint-query-keys.ts index c36ff57d591..38a7e5db0b2 100644 --- a/mobile/src/query/first-paint-query-keys.ts +++ b/mobile/src/query/first-paint-query-keys.ts @@ -10,9 +10,9 @@ import { hostWorktreeCatalogKey, hostWorktreeCatalogQuery, NO_HOST_LIST_INPUT -} from '../host-screen/host-list-queries' -import { hostViewFamily, hostViewKey } from '../host-screen/host-view-memory' -import { hostProjectSectionsQuery } from '../host-screen/use-host-project-sections' +} from '../host-screen/catalog/host-list-queries' +import { hostViewFamily, hostViewKey } from '../host-screen/view-settings/host-view-memory' +import { hostProjectSectionsQuery } from '../host-screen/catalog/use-host-project-sections' import { sessionTabsFamily, sessionTabsKey } from '../session/tabs/mobile-session-last-tabs' import type { MobileSessionTab } from '../session/tabs/mobile-session-tabs' import { activeSessionTabOf } from '../session/tabs/active-session-tab' diff --git a/mobile/src/query/host-query-persistence.test.ts b/mobile/src/query/host-query-persistence.test.ts index 4439806d94c..153cf64c71d 100644 --- a/mobile/src/query/host-query-persistence.test.ts +++ b/mobile/src/query/host-query-persistence.test.ts @@ -1,12 +1,11 @@ -import { hashKey, QueryClient } from '@tanstack/react-query' +import { QueryClient } from '@tanstack/react-query' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { OrcaQueryStoreBackend } from '../device-store/query-storage' -import { hostWorktreeCatalogKey, hostWorktreeCatalogQuery } from '../host-screen/host-list-queries' import { - createHostQueryPersistence, - holdsRestoredHostQueryData, - persistedHostQueryKey -} from './host-query-persistence' + hostWorktreeCatalogKey, + hostWorktreeCatalogQuery +} from '../host-screen/catalog/host-list-queries' +import { createHostQueryPersistence, persistedHostQueryKey } from './host-query-persistence' vi.mock('react-native', () => ({ AppState: {} })) @@ -123,44 +122,4 @@ describe('createHostQueryPersistence', () => { expect(relaunched.getQueryData(key)).toBe(live) }) - - it.each([ - { replaceOnHearing: false, expected: [true, false] }, - { replaceOnHearing: true, expected: [true, false, false] } - ])( - "marks restored data as an earlier launch's before anyone hears of it, until this launch's own answer (a listener's replacement: $replaceOnHearing)", - async ({ replaceOnHearing, expected }) => { - const store = memoryStore('host-a') - await storeFromEarlierLaunch(store, [liveRow]) - - const relaunched = new QueryClient() - const persistence = createHostQueryPersistence(relaunched, store.backend) - relaunched.getQueryCache().build(relaunched, { - queryKey: key, - meta: { persist: hostWorktreeCatalogQuery.persist } - }) - const heard: boolean[] = [] - const replacement = { snapshotId: 'snap-8', worktrees: [liveRow] } - relaunched.getQueryCache().subscribe((event) => { - if (event.type === 'updated' && event.action.type === 'success') { - heard.push(holdsRestoredHostQueryData(relaunched, hashKey(key))) - // A listener replacing the restored rows as it hears of them. - if (replaceOnHearing && heard.length === 1) { - relaunched.setQueryData(key, replacement, { updatedAt: SAVED_AT - 2 }) - } - } - }) - const restoring = persistence.restore(firstPaint) - await vi.runAllTimersAsync() - await restoring - // A live answer timed before the stored one, as after a clock correction, is still this launch's. - relaunched.setQueryData( - key, - { snapshotId: 'snap-8', worktrees: [liveRow] }, - { updatedAt: SAVED_AT - 1 } - ) - - expect(heard).toEqual(expected) - } - ) }) diff --git a/mobile/src/session/MobileSessionActiveContent.tsx b/mobile/src/session/MobileSessionActiveContent.tsx index b23e9941286..b4f4232506a 100644 --- a/mobile/src/session/MobileSessionActiveContent.tsx +++ b/mobile/src/session/MobileSessionActiveContent.tsx @@ -32,7 +32,7 @@ import { MobileSessionContentState } from './MobileSessionContentState' import { MarkdownReader } from '../files/session-documents/MobileSessionMarkdownReader' import { markdownTabDraftKey } from '../drafts/document-edit-draft-rows' import { workspaceDraftRowKey } from '../drafts/durable-draft-rows' -import { loadMarkdownPreviewImage } from '../files/mobile-markdown-image-source' +import { loadMarkdownPreviewImage } from '../files/markdown/mobile-markdown-image-source' export function MobileSessionActiveContent({ controller diff --git a/mobile/src/session/MobileSourceControlButton.tsx b/mobile/src/session/MobileSourceControlButton.tsx index 132e05fea14..b28abc798d0 100644 --- a/mobile/src/session/MobileSourceControlButton.tsx +++ b/mobile/src/session/MobileSourceControlButton.tsx @@ -6,7 +6,7 @@ import { HOST_LIST_TARGET, hostWorktreeCatalogQuery, NO_HOST_LIST_INPUT -} from '../host-screen/host-list-queries' +} from '../host-screen/catalog/host-list-queries' import { useMobileTranslation } from '../i18n/use-mobile-translation' import { useHostQuery } from '../query/host-query' import { useMobileColorScheme } from '../theme/use-mobile-color-scheme' diff --git a/mobile/src/session/SessionDockPanelContent.tsx b/mobile/src/session/SessionDockPanelContent.tsx index e4bcfd06bf6..0b776e20e40 100644 --- a/mobile/src/session/SessionDockPanelContent.tsx +++ b/mobile/src/session/SessionDockPanelContent.tsx @@ -1,6 +1,6 @@ import { memo } from 'react' import { MobileSourceControlRoutePanel } from '../source-control/MobileSourceControlRoutePanel' -import { MobileFileExplorerPanel } from '../files/MobileFileExplorerPanel' +import { MobileFileExplorerPanel } from '../files/explorer/MobileFileExplorerPanel' import type { ActivePanel } from './session-panel-host' export type SessionDockPanelContentProps = { diff --git a/mobile/src/session/native-chat/agent-session/mobile-structured-create-operations.ts b/mobile/src/session/native-chat/agent-session/mobile-structured-create-operations.ts index a6a263cd543..10274054a00 100644 --- a/mobile/src/session/native-chat/agent-session/mobile-structured-create-operations.ts +++ b/mobile/src/session/native-chat/agent-session/mobile-structured-create-operations.ts @@ -1,6 +1,6 @@ import { bindDeferredRpcOperation, defineRpcOperation } from '../../../transport/rpc/rpc-operation' import { rpcResultVariant } from '../../../transport/rpc/rpc-operation-result-reader' -import '../../../files/mobile-file-preview-operations' +import '../../../files/preview/mobile-file-preview-operations' import { sessionLaunchUnreadReplySchema } from '../../../transport/rpc/rpc-uninterpreted-reply-schemas' import '../../../agent-history/mobile-ai-vault-resume-reply-schema' import '../../../browser/tab-navigation/mobile-session-browser-reply-schema' diff --git a/mobile/src/session/native-chat/composer/MobileNativeChatComposerAttachments.tsx b/mobile/src/session/native-chat/composer/MobileNativeChatComposerAttachments.tsx index f55e60f05ce..1fea9f8088f 100644 --- a/mobile/src/session/native-chat/composer/MobileNativeChatComposerAttachments.tsx +++ b/mobile/src/session/native-chat/composer/MobileNativeChatComposerAttachments.tsx @@ -14,7 +14,7 @@ import { FileText, Image as ImageIcon, X } from 'lucide-react-native' import { colors, concentricRadius, radii, spacing, pressedStyle } from '../../../theme/mobile-theme' import { themedStyleSheet } from '../../../theme/themed-styles' import { useMobileColorScheme } from '../../../theme/use-mobile-color-scheme' -import { formatPreviewByteLength } from '../../../files/mobile-file-preview-response' +import { formatPreviewByteLength } from '../../../files/preview/mobile-file-preview-response' import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment' import type { PendingNativeChatFile } from './mobile-native-chat-file-attachment' import { useMobileNativeChatOpenFile } from '../mobile-native-chat-open-file-context' diff --git a/mobile/src/session/native-chat/composer/use-mobile-native-chat-file-search.ts b/mobile/src/session/native-chat/composer/use-mobile-native-chat-file-search.ts index 642420bd6ed..1de6d1ac320 100644 --- a/mobile/src/session/native-chat/composer/use-mobile-native-chat-file-search.ts +++ b/mobile/src/session/native-chat/composer/use-mobile-native-chat-file-search.ts @@ -7,7 +7,7 @@ import { import { nativeChatFileInventoryRead, nativeChatFileSearchRead -} from '../../../files/native-chat-file-search-operations' +} from '../../../files/explorer/native-chat-file-search-operations' import { getPreparedQuickOpenFiles, rankQuickOpenFiles diff --git a/mobile/src/session/native-chat/input/mobile-native-chat-options-operations.ts b/mobile/src/session/native-chat/input/mobile-native-chat-options-operations.ts index 51337993088..fea9c439707 100644 --- a/mobile/src/session/native-chat/input/mobile-native-chat-options-operations.ts +++ b/mobile/src/session/native-chat/input/mobile-native-chat-options-operations.ts @@ -1,6 +1,6 @@ import { bindDeferredRpcOperation, defineRpcOperation } from '../../../transport/rpc/rpc-operation' import { rpcResultVariant } from '../../../transport/rpc/rpc-operation-result-reader' -import '../../../files/mobile-file-preview-operations' +import '../../../files/preview/mobile-file-preview-operations' import { sessionLaunchUnreadReplySchema } from '../../../transport/rpc/rpc-uninterpreted-reply-schemas' import '../../../agent-history/mobile-ai-vault-resume-reply-schema' import '../../../browser/tab-navigation/mobile-session-browser-reply-schema' diff --git a/mobile/src/session/native-chat/visual/mobile-native-chat-image-preview.ts b/mobile/src/session/native-chat/visual/mobile-native-chat-image-preview.ts index 715293a1ed7..f07eafa7db5 100644 --- a/mobile/src/session/native-chat/visual/mobile-native-chat-image-preview.ts +++ b/mobile/src/session/native-chat/visual/mobile-native-chat-image-preview.ts @@ -1,7 +1,7 @@ import { buildImageDataUri } from '../../../../../src/shared/file-preview/image-data-uri' import { isAgentSessionAttachmentStorePath } from '../../../../../src/shared/agent-session/agent-session-attachments' import { PrioritySemaphore } from '../../../../../src/shared/priority-semaphore' -import { filePreviewImageSchema } from '../../../files/file-preview-reply-schema' +import { filePreviewImageSchema } from '../../../files/preview/file-preview-reply-schema' import { bindDeferredRpcOperation, defineRpcOperation } from '../../../transport/rpc/rpc-operation' import { rpcResultVariant } from '../../../transport/rpc/rpc-operation-result-reader' import type { RpcClient } from '../../../transport/rpc/rpc-client-contract' diff --git a/mobile/src/session/use-mobile-session-content-create-actions.ts b/mobile/src/session/use-mobile-session-content-create-actions.ts index cec20da1636..b4dc401f892 100644 --- a/mobile/src/session/use-mobile-session-content-create-actions.ts +++ b/mobile/src/session/use-mobile-session-content-create-actions.ts @@ -1,6 +1,6 @@ import { normalizeBrowserUrl } from '../browser/pane-view/browser-url' import { translate } from '../i18n/mobile-i18n' -import { captureMobileFileMutationOwnership } from '../files/mobile-file-mutation-ownership' +import { captureMobileFileMutationOwnership } from '../files/mutation-ownership/mobile-file-mutation-ownership' import { browserGoBack, browserGoForward, @@ -11,7 +11,7 @@ import { sessionBrowserTabCreate } from '../browser/tab-navigation/mobile-sessio import { sessionMarkdownNoteCreate } from '../files/session-documents/mobile-markdown-tab-operations' import { interpretOrThrowRefusalMessage } from '../transport/rpc/rpc-refusal-message' import type { MobileBrowserNavigationMethod } from './tabs/MobileBrowserTabActionSheet' -import { isFileExistsErrorMessage } from '../files/file-exists-error' +import { isFileExistsErrorMessage } from '../files/mutation-ownership/file-exists-error' import type { MobileSessionTab } from './tabs/mobile-session-tabs' import type { MobileSessionTerminalCreateActionsModel } from '../terminal/session/use-mobile-session-terminal-create-actions' diff --git a/mobile/src/source-control/folder/use-mobile-folder-attached-worktrees.ts b/mobile/src/source-control/folder/use-mobile-folder-attached-worktrees.ts index 027e2836d1a..0f5d3fce4ce 100644 --- a/mobile/src/source-control/folder/use-mobile-folder-attached-worktrees.ts +++ b/mobile/src/source-control/folder/use-mobile-folder-attached-worktrees.ts @@ -8,7 +8,7 @@ import { hostServerWorkspacesQuery, hostWorktreeCatalogQuery, NO_HOST_LIST_INPUT -} from '../../host-screen/host-list-queries' +} from '../../host-screen/catalog/host-list-queries' import { useWorkspaceExecutionHost } from '../../navigation/workspace-execution-host' import { useHostQuery } from '../../query/host-query' import { useHostClient } from '../../transport/connection/host-client-hooks' diff --git a/mobile/src/stats/home-stats-summary-read.ts b/mobile/src/stats/home-stats-summary-read.ts index e1ee4807167..07fc222ce15 100644 --- a/mobile/src/stats/home-stats-summary-read.ts +++ b/mobile/src/stats/home-stats-summary-read.ts @@ -1,6 +1,6 @@ import { z } from 'zod' import { salvagedOptional } from '../../../src/shared/zod-salvage' -import { HOST_LIST_TARGET, NO_HOST_LIST_INPUT } from '../host-screen/host-list-queries' +import { HOST_LIST_TARGET, NO_HOST_LIST_INPUT } from '../host-screen/catalog/host-list-queries' import { defineHostQuery } from '../query/host-query' import { hostQueryKey } from '../query/host-query-key' import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc/rpc-operation' diff --git a/mobile/src/test-support/rpc-recording/adapters/client-event-stream-mount-adapters.ts b/mobile/src/test-support/rpc-recording/adapters/client-event-stream-mount-adapters.ts index 3fa2de1e3e5..86f6e5925e0 100644 --- a/mobile/src/test-support/rpc-recording/adapters/client-event-stream-mount-adapters.ts +++ b/mobile/src/test-support/rpc-recording/adapters/client-event-stream-mount-adapters.ts @@ -1,6 +1,6 @@ import type * as UseLiveWorktreeNameModule from '../../../worktree/use-live-worktree-name' import type * as HostWorktreeRefreshModule from '../../../worktree/host-worktree-refresh' -import type * as HostListQueries from '../../../host-screen/host-list-queries' +import type * as HostListQueries from '../../../host-screen/catalog/host-list-queries' import type * as HostQueryModule from '../../../query/host-query' import { hookMount } from '../hook-mount' import { hookScreenMount } from '../mounted-screen-tree' @@ -53,7 +53,7 @@ export function clientEventStreamMountAdapters( 'mobile/src/query/host-query.ts' ) const queries = modules.load( - 'mobile/src/host-screen/host-list-queries.ts' + 'mobile/src/host-screen/catalog/host-list-queries.ts' ) let status: string | null = null const hook = hookMount(() => { diff --git a/mobile/src/test-support/rpc-recording/adapters/file-explorer-screen-mount-adapters.ts b/mobile/src/test-support/rpc-recording/adapters/file-explorer-screen-mount-adapters.ts index 41b41d8facd..0f1b86b63a7 100644 --- a/mobile/src/test-support/rpc-recording/adapters/file-explorer-screen-mount-adapters.ts +++ b/mobile/src/test-support/rpc-recording/adapters/file-explorer-screen-mount-adapters.ts @@ -1,3 +1,4 @@ +import type * as FileExplorerScreenModule from '../../../files/explorer/MobileFileExplorerPanel' import { createElement } from 'react' import { projectMountedScreen, renderedElementProps, screenMount } from '../mounted-screen-tree' import { mountFixture } from '../recorder-fixture-shape' @@ -19,8 +20,8 @@ export function fileExplorerScreenMountAdapters( ): Record { return { 'files.explorer-screen': ({ client, effect }) => { - const Panel = modules.load( - 'mobile/src/files/MobileFileExplorerPanel.tsx' + const Panel = modules.load( + 'mobile/src/files/explorer/MobileFileExplorerPanel.tsx' ).MobileFileExplorerPanel const hostClientContext = loadHostClientContext(modules) const context = mountFixture({ diff --git a/mobile/src/test-support/rpc-recording/adapters/file-request-mount-adapters.ts b/mobile/src/test-support/rpc-recording/adapters/file-request-mount-adapters.ts index 69aee295011..a838c88cd6a 100644 --- a/mobile/src/test-support/rpc-recording/adapters/file-request-mount-adapters.ts +++ b/mobile/src/test-support/rpc-recording/adapters/file-request-mount-adapters.ts @@ -1,3 +1,6 @@ +import type * as FileMutationOwnershipModule from '../../../files/mutation-ownership/mobile-file-mutation-ownership' +import type * as FilePreviewRequestModule from '../../../files/preview/mobile-file-preview-request' +import type * as FileTabDocModule from '../../../files/session-documents/mobile-file-tab-doc' import type { MountAdapter } from '../recording-scenario' import type { operationModuleLoader } from '../operation-module-loader' @@ -21,8 +24,8 @@ export function fileRequestMountAdapters( ): Record { return { 'files.mutation-ownership': ({ client }) => { - const capture = modules.load( - 'mobile/src/files/mobile-file-mutation-ownership.ts' + const capture = modules.load( + 'mobile/src/files/mutation-ownership/mobile-file-mutation-ownership.ts' ).captureMobileFileMutationOwnership let ownership: unknown = 'uncaptured' return { @@ -36,8 +39,8 @@ export function fileRequestMountAdapters( } }, 'files.preview-load': ({ client, effect }) => { - const load = modules.load( - 'mobile/src/files/mobile-file-preview-request.ts' + const load = modules.load( + 'mobile/src/files/preview/mobile-file-preview-request.ts' ).loadMobileFilePreview let preview: unknown = 'unloaded' return { @@ -59,8 +62,8 @@ export function fileRequestMountAdapters( } }, 'files.preview-save': ({ client, effect }) => { - const save = modules.load( - 'mobile/src/files/mobile-file-preview-request.ts' + const save = modules.load( + 'mobile/src/files/preview/mobile-file-preview-request.ts' ).saveMobileTerminalArtifactPreview let saved: unknown = 'unsaved' return { @@ -78,8 +81,8 @@ export function fileRequestMountAdapters( } }, 'files.tab-doc': ({ client }) => { - const resolve = modules.load( - 'mobile/src/files/mobile-file-tab-doc.ts' + const resolve = modules.load( + 'mobile/src/files/session-documents/mobile-file-tab-doc.ts' ).resolveMobileFileTabDoc const docs: Record = {} return { diff --git a/mobile/src/test-support/rpc-recording/adapters/host-screen-mount-adapters.ts b/mobile/src/test-support/rpc-recording/adapters/host-screen-mount-adapters.ts index 269cec1e798..c502d7a387d 100644 --- a/mobile/src/test-support/rpc-recording/adapters/host-screen-mount-adapters.ts +++ b/mobile/src/test-support/rpc-recording/adapters/host-screen-mount-adapters.ts @@ -1,3 +1,4 @@ +import type * as HostViewSettingsModule from '../../../host-screen/view-settings/use-host-view-settings' import type { MountAdapter } from '../recording-scenario' import { INITIAL_MOBILE_VIEW } from '../../../worktree/workspace-view-settings' import { hookMount, performHookAction } from '../hook-mount' @@ -9,9 +10,9 @@ export function hostScreenMountAdapters( ): Record { return { 'host.view-settings': (context) => { - const useViewSettings = modules.load< - typeof import('../../../host-screen/use-host-view-settings') - >('mobile/src/host-screen/use-host-view-settings.ts').useHostViewSettings + const useViewSettings = modules.load( + 'mobile/src/host-screen/view-settings/use-host-view-settings.ts' + ).useHostViewSettings const state = observableModel(context, { clientRef: { current: context.client }, view: INITIAL_MOBILE_VIEW, diff --git a/mobile/src/test-support/rpc-recording/adapters/host-worktree-action-mount-adapters.ts b/mobile/src/test-support/rpc-recording/adapters/host-worktree-action-mount-adapters.ts index d927a211549..54cc47e1bdc 100644 --- a/mobile/src/test-support/rpc-recording/adapters/host-worktree-action-mount-adapters.ts +++ b/mobile/src/test-support/rpc-recording/adapters/host-worktree-action-mount-adapters.ts @@ -1,3 +1,4 @@ +import type * as HostWorktreeActionsModule from '../../../host-screen/workspace-actions/use-host-worktree-actions' import type { MountAdapter } from '../recording-scenario' import { hookMount, performHookAction } from '../hook-mount' import { observableModel, projectObservable } from '../observable-model' @@ -24,9 +25,9 @@ export function hostWorktreeActionMountAdapters( ): Record { return { 'host.worktree-actions': (context) => { - const useActions = modules.load< - typeof import('../../../host-screen/use-host-worktree-actions') - >('mobile/src/host-screen/use-host-worktree-actions.ts').useHostWorktreeActions + const useActions = modules.load( + 'mobile/src/host-screen/workspace-actions/use-host-worktree-actions.ts' + ).useHostWorktreeActions const state = observableModel(context, { pinnedIds: new Set(), worktrees: [ROW], diff --git a/mobile/src/test-support/rpc-recording/adapters/settings-mount-adapters.ts b/mobile/src/test-support/rpc-recording/adapters/settings-mount-adapters.ts index 734ad92cd5d..120f69c359c 100644 --- a/mobile/src/test-support/rpc-recording/adapters/settings-mount-adapters.ts +++ b/mobile/src/test-support/rpc-recording/adapters/settings-mount-adapters.ts @@ -1,9 +1,9 @@ import type * as UsePrBotAuthorOverridesModule from '../../../source-control/review/use-pr-bot-author-overrides' import type * as UseNewWorkspaceRuntimeContextModule from '../../../components/use-new-workspace-runtime-context' -import type * as UseHostRepoMetadataModule from '../../../host-screen/use-host-repo-metadata' +import type * as UseHostRepoMetadataModule from '../../../host-screen/catalog/use-host-repo-metadata' import type * as UseMobileTasksRuntimeHydrationModule from '../../../tasks/screen-state/use-mobile-tasks-runtime-hydration' import { notifyManager, useQueryClient, type QueryClient } from '@tanstack/react-query' -import type * as HostListQueries from '../../../host-screen/host-list-queries' +import type * as HostListQueries from '../../../host-screen/catalog/host-list-queries' import type * as ResumeMetadata from '../../../agent-history/mobile-agent-history-resume-metadata' import { hostQueryKey, hostScopeKey } from '../../../query/host-query-key' import type { OperationExposure } from '../operation-module-loader' @@ -94,10 +94,10 @@ export function settingsMountAdapters( }, 'settings.repo-metadata': (context) => { const useMetadata = modules.load( - 'mobile/src/host-screen/use-host-repo-metadata.ts' + 'mobile/src/host-screen/catalog/use-host-repo-metadata.ts' ).useHostRepoMetadata const queries = modules.load( - 'mobile/src/host-screen/host-list-queries.ts' + 'mobile/src/host-screen/catalog/host-list-queries.ts' ) const state = observableModel(context, { clientRef: { current: context.client } }) // A fault schedule ends with the read settling on a timeout, after the recorder's last zero-time drain; deliver diff --git a/mobile/src/worktree/home-host-worktree-fetch.ts b/mobile/src/worktree/home-host-worktree-fetch.ts index 53e48cd9f70..1abb6cf37c9 100644 --- a/mobile/src/worktree/home-host-worktree-fetch.ts +++ b/mobile/src/worktree/home-host-worktree-fetch.ts @@ -2,7 +2,7 @@ import * as z from 'zod' import { HOME_WORKTREE_COUNTS_FAMILY, type NO_HOST_LIST_INPUT -} from '../host-screen/host-list-queries' +} from '../host-screen/catalog/host-list-queries' import { defineHostQuery } from '../query/host-query' import { homeAgentEntries, diff --git a/mobile/src/worktree/host-worktree-refresh.ts b/mobile/src/worktree/host-worktree-refresh.ts index 0231ff7e6fd..f933f3f496a 100644 --- a/mobile/src/worktree/host-worktree-refresh.ts +++ b/mobile/src/worktree/host-worktree-refresh.ts @@ -2,7 +2,7 @@ import { subscribeSharedClientEvents, type SharedClientEventsClient } from '../transport/connection/shared-client-events' -import type { HostSidebarFeedTriggers } from '../host-screen/host-sidebar-feed-connection' +import type { HostSidebarFeedTriggers } from '../host-screen/sidebar-feed/host-sidebar-feed-connection' // Keep lifecycle callers independent of RN types. type HostAppState = { diff --git a/mobile/tsconfig.test.mobile-declarations.json b/mobile/tsconfig.test.mobile-declarations.json index 623991763da..2db5ec60c48 100644 --- a/mobile/tsconfig.test.mobile-declarations.json +++ b/mobile/tsconfig.test.mobile-declarations.json @@ -31,7 +31,7 @@ "src/test-support/rpc-recording/derived-goldens.ts", "src/test-support/rpc-recording/golden-recording.ts", "src/test-support/rpc-recording/scenario-input.ts", - "src/host-screen/host-sidebar-feed-sections.ts", + "src/host-screen/sidebar-feed/host-sidebar-feed-sections.ts", "src/worktree/workspace-list-sections.ts", "src/worktree/worktree-catalog-snapshot-client.ts", "src/worktree/worktree-host-context-labels.ts", diff --git a/tests/e2e/helpers/phone-feed-list.ts b/tests/e2e/helpers/phone-feed-list.ts index db1a1b49fa4..0e3ab4537d2 100644 --- a/tests/e2e/helpers/phone-feed-list.ts +++ b/tests/e2e/helpers/phone-feed-list.ts @@ -2,15 +2,15 @@ import { expect } from '@stablyai/playwright-test' import type { Page } from '@stablyai/playwright-test' import { buildSidebarFeedRows } from '../../../src/shared/sidebar-list/sidebar-feed-rows' import { buildSidebarFeedCatalog } from '../../../src/shared/sidebar-list/sidebar-feed-catalog' -import { buildHostFeedView } from '../../../mobile/src/host-screen/host-sidebar-feed-inputs' -import { buildHostFeedSections } from '../../../mobile/src/host-screen/host-sidebar-feed-sections' +import { buildHostFeedView } from '../../../mobile/src/host-screen/sidebar-feed/host-sidebar-feed-inputs' +import { buildHostFeedSections } from '../../../mobile/src/host-screen/sidebar-feed/host-sidebar-feed-sections' import { NO_WORKTREE_HOST_CONTEXT } from '../../../mobile/src/worktree/worktree-host-context-labels' import { readHostSidebarFeed, type HostSidebarFeedRead -} from '../../../mobile/src/host-screen/use-host-sidebar-feed' -import { createHostSidebarFeedReader } from '../../../mobile/src/host-screen/host-sidebar-feed-connection' -import { readHostMethodReply } from '../../../mobile/src/host-screen/host-sidebar-feed-operations' +} from '../../../mobile/src/host-screen/sidebar-feed/use-host-sidebar-feed' +import { createHostSidebarFeedReader } from '../../../mobile/src/host-screen/sidebar-feed/host-sidebar-feed-connection' +import { readHostMethodReply } from '../../../mobile/src/host-screen/sidebar-feed/host-sidebar-feed-operations' import type { SharedClientEventsClient } from '../../../mobile/src/transport/connection/shared-client-events' import { hostPlatformRead, From e9d5b35d64e2d5faa87f8c1d1971ee84a74f8178 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 13 Nov 2026 05:25:55 -0800 Subject: [PATCH 24/55] test: remove low-value mobile and CLI runtime tests (#42022) * test: remove low-value mobile suites and repeated vectors * test: trim low-value CLI runtime cases and obsolete allowance * test: satisfy changed-line template lint in click case --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- config/flaky-test-pattern-baseline.txt | 1 - mobile/src/mock-server-key-pair.test.ts | 24 -- .../onboarding/MobileOnboardingPage.test.ts | 122 ------ .../NotificationOnboardingPreview.test.ts | 116 ------ .../legacy-notification-opt-in-route.test.ts | 24 -- .../settings/settings-screen-state.test.tsx | 30 -- .../terminal-live-preedit-mirror.test.ts | 29 +- .../terminal-live-text-commit.test.ts | 22 - ...inal-text-field-submit-binding.web.test.ts | 27 +- .../terminal-webview-cell-box.test.ts | 16 - ...rminal-webview-interactive-output.test.tsx | 106 ----- ...rminal-webview-query-reply-routing.test.ts | 70 ---- .../terminal-webview-tap-routing.test.ts | 375 ------------------ .../terminal-webview-wheel-scroll.test.ts | 278 ------------- .../terminal/terminal-write-coalescer.test.ts | 64 +-- .../use-terminal-live-input-click.test.ts | 37 +- .../use-terminal-live-input-commit.test.ts | 49 +-- ...erminal-text-field-submit-binding.test.tsx | 55 +-- src/cli/runtime/envelope-schema.test.ts | 117 +----- .../profile-state-recovery-launch.test.ts | 71 +--- .../runtime/runtime-listener-probe.test.ts | 26 -- src/cli/runtime/runtime-source-fence.test.ts | 25 +- src/cli/runtime/serve-appimage-update.test.ts | 14 +- src/cli/runtime/serve-bundle-park.test.ts | 9 - .../runtime/serve-electron-preflight.test.ts | 6 - src/cli/runtime/serve-install-hold.test.ts | 62 --- .../runtime/serve-pid1-init-reaper.test.ts | 43 -- .../serve-signal-exit-diagnostic.test.ts | 80 +--- .../runtime/serve-supervised-service.test.ts | 39 +- .../serve-supervisor-relocation.test.ts | 27 -- src/cli/runtime/status.test.ts | 87 +--- 31 files changed, 74 insertions(+), 1977 deletions(-) delete mode 100644 mobile/src/onboarding/MobileOnboardingPage.test.ts delete mode 100644 mobile/src/onboarding/NotificationOnboardingPreview.test.ts delete mode 100644 mobile/src/onboarding/legacy-notification-opt-in-route.test.ts delete mode 100644 mobile/src/terminal/terminal-webview-interactive-output.test.tsx delete mode 100644 mobile/src/terminal/terminal-webview-query-reply-routing.test.ts delete mode 100644 mobile/src/terminal/terminal-webview-tap-routing.test.ts delete mode 100644 mobile/src/terminal/terminal-webview-wheel-scroll.test.ts delete mode 100644 src/cli/runtime/serve-install-hold.test.ts diff --git a/config/flaky-test-pattern-baseline.txt b/config/flaky-test-pattern-baseline.txt index c4db40f9a5b..85c8cc9f0c6 100644 --- a/config/flaky-test-pattern-baseline.txt +++ b/config/flaky-test-pattern-baseline.txt @@ -121,7 +121,6 @@ 1 test-no-polling-loop:tests/e2e/terminal-tui-wheel-reports.spec.ts 1 test-no-polling-loop:tests/e2e/workspace-sleep-in-main.spec.ts 2 test-no-polling-loop:tests/e2e/worktree-card-downward-drag.spec.ts -1 test-no-real-time-wait:mobile/src/terminal/terminal-webview-tap-routing.test.ts 1 test-no-real-time-wait:src/main/agent-hooks/installation/scripts/posix-hook-json-stdin-reader.test.ts 2 test-no-real-time-wait:src/main/agent-hooks/server/testing/http/server-transport-interference.test.ts 1 test-no-real-time-wait:src/main/agent-hooks/server/testing/main-agent/server-turn-ended-at.test.ts diff --git a/mobile/src/mock-server-key-pair.test.ts b/mobile/src/mock-server-key-pair.test.ts index 1db9757e1e2..b4dbcccd3dd 100644 --- a/mobile/src/mock-server-key-pair.test.ts +++ b/mobile/src/mock-server-key-pair.test.ts @@ -3,7 +3,6 @@ import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { pathToFileURL } from 'node:url' -import nacl from 'tweetnacl' import { afterEach, describe, expect, it, vi } from 'vitest' import { loadOrCreateMockServerKeyPair } from '../scripts/mock-server-key-pair' @@ -186,20 +185,6 @@ describe('mock server key persistence', () => { } }) - it('re-keys canonical-length content with malformed base64', () => { - const keyFile = keyFilePath() - const encoded = Buffer.from(nacl.box.keyPair().secretKey).toString('base64') - const malformed = `${encoded.slice(0, 4)}!${encoded.slice(4)}` - expect(Buffer.from(malformed, 'base64')).toHaveLength(nacl.box.secretKeyLength) - writeFileSync(keyFile, malformed) - const logger = { warn: vi.fn() } - - const loaded = loadOrCreateMockServerKeyPair(keyFile, logger) - - expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('invalid base64')) - expect(readFileSync(keyFile, 'utf-8')).toBe(Buffer.from(loaded.secretKey).toString('base64')) - }) - it('makes concurrent creators converge on the persisted winner', async () => { const keyFile = keyFilePath() const lockFile = `${keyFile}.lock` @@ -224,13 +209,4 @@ describe('mock server key persistence', () => { await cleanupConcurrentCreators(creators, lockFile, parentOwnsLock) } }) - - it('does not overwrite an invalid key owned by another creator', () => { - const keyFile = keyFilePath() - writeFileSync(keyFile, 'invalid') - writeFileSync(`${keyFile}.lock`, '', { flag: 'wx', mode: 0o600 }) - - expect(() => loadOrCreateMockServerKeyPair(keyFile)).toThrow('remained busy') - expect(readFileSync(keyFile, 'utf-8')).toBe('invalid') - }) }) diff --git a/mobile/src/onboarding/MobileOnboardingPage.test.ts b/mobile/src/onboarding/MobileOnboardingPage.test.ts deleted file mode 100644 index 48612248143..00000000000 --- a/mobile/src/onboarding/MobileOnboardingPage.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { createElement, type ReactNode } from 'react' -import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { MobileOnboardingPage } from './MobileOnboardingPage' -import { findByHostType } from '../test-support/react-test-renderer-host-nodes' - -vi.mock('react-native', async () => { - const React = await import('react') - class AnimatedValue { - interpolate() { - return 0 - } - setValue() {} - } - return { - AccessibilityInfo: { - addEventListener: vi.fn(() => ({ remove: vi.fn() })), - isReduceMotionEnabled: vi.fn(() => Promise.resolve(true)) - }, - ActivityIndicator: 'ActivityIndicator', - Animated: { - Value: AnimatedValue, - View: 'AnimatedView', - delay: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })), - loop: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })), - parallel: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })), - sequence: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })), - timing: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })) - }, - Easing: { cubic: (t: number) => t, in: (e: unknown) => e, out: (e: unknown) => e }, - Image: 'Image', - Pressable: 'Pressable', - ScrollView: ({ children, ...props }: { children?: ReactNode }) => - React.createElement('ScrollView', props, children), - StyleSheet: { create: (styles: unknown) => styles }, - Text: 'Text', - View: 'View' - } -}) - -vi.mock('lucide-react-native', () => ({ - MessageSquare: 'MessageSquare' -})) - -vi.mock('../components/OrcaLogo', () => ({ OrcaLogo: 'OrcaLogo' })) - -describe('MobileOnboardingPage', () => { - let renderer: ReactTestRenderer | null = null - - afterEach(() => { - act(() => renderer?.unmount()) - renderer = null - vi.restoreAllMocks() - }) - - async function renderPage( - step: 'session-view' | 'notifications', - options: { active?: boolean; busyChoice?: 'chat' | 'enable' | null } = {} - ) { - const onSessionChoice = vi.fn() - const onNotificationChoice = vi.fn() - const consoleError = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] !== 'string' || !args[0].includes('react-test-renderer is deprecated')) { - throw new Error(String(args[0])) - } - }) - await act(async () => { - renderer = create( - createElement(MobileOnboardingPage, { - step, - width: 390, - active: options.active ?? true, - busyChoice: options.busyChoice ?? null, - error: null, - onSessionChoice, - onNotificationChoice - }) - ) - }) - consoleError.mockRestore() - return { onSessionChoice, onNotificationChoice } - } - - function button(label: string) { - return renderer!.root.find( - (node) => String(node.type) === 'Pressable' && node.props.accessibilityLabel === label - ) - } - - it('renders the session choices and sends exactly one selected view', async () => { - const callbacks = await renderPage('session-view') - - act(() => button('Open sessions in Chat UI').props.onPress()) - expect(callbacks.onSessionChoice).toHaveBeenCalledExactlyOnceWith('chat') - expect(callbacks.onNotificationChoice).not.toHaveBeenCalled() - }) - - it('renders the notification choices and sends the selected option', async () => { - const callbacks = await renderPage('notifications') - act(() => button('Skip notifications for now').props.onPress()) - - expect(callbacks.onNotificationChoice).toHaveBeenCalledWith('skip') - expect(callbacks.onSessionChoice).not.toHaveBeenCalled() - }) - - it('disables both notification choices while permission is pending', async () => { - await renderPage('notifications', { busyChoice: 'enable' }) - const enable = button('Enable agent notifications') - const secondary = button('Skip notifications for now') - - expect(enable.props.disabled).toBe(true) - expect(secondary.props.disabled).toBe(true) - }) - - it('hides an off-screen page from assistive technology', async () => { - await renderPage('notifications', { active: false }) - const scrollView = findByHostType(renderer!.root, 'ScrollView') - - expect(scrollView.props.accessibilityElementsHidden).toBe(true) - expect(scrollView.props.importantForAccessibility).toBe('no-hide-descendants') - }) -}) diff --git a/mobile/src/onboarding/NotificationOnboardingPreview.test.ts b/mobile/src/onboarding/NotificationOnboardingPreview.test.ts deleted file mode 100644 index fa400485a9f..00000000000 --- a/mobile/src/onboarding/NotificationOnboardingPreview.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { createElement, type ReactNode } from 'react' -import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { NotificationOnboardingPreview } from './NotificationOnboardingPreview' -import { findAllByHostType } from '../test-support/react-test-renderer-host-nodes' - -const mocks = vi.hoisted(() => { - const anim = () => ({ start: vi.fn(), stop: vi.fn() }) - return { - reducedMotion: true, - reducedMotionResult: null as Promise | null, - timing: vi.fn(anim), - loop: vi.fn(anim) - } -}) - -vi.mock('react-native', async () => { - const React = await import('react') - class AnimatedValue { - interpolate() { - return 0 - } - setValue() {} - } - return { - AccessibilityInfo: { - addEventListener: vi.fn(() => ({ remove: vi.fn() })), - isReduceMotionEnabled: vi.fn( - () => mocks.reducedMotionResult ?? Promise.resolve(mocks.reducedMotion) - ) - }, - Animated: { - Value: AnimatedValue, - View: ({ children, ...props }: { children?: ReactNode }) => - React.createElement('AnimatedView', props, children), - delay: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })), - loop: mocks.loop, - parallel: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })), - sequence: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })), - timing: mocks.timing - }, - Easing: { cubic: (t: number) => t, in: (e: unknown) => e, out: (e: unknown) => e }, - StyleSheet: { create: (styles: unknown) => styles }, - Text: 'Text', - View: 'View' - } -}) - -vi.mock('../components/OrcaLogo', () => ({ OrcaLogo: 'OrcaLogo' })) - -describe('NotificationOnboardingPreview', () => { - let renderer: ReactTestRenderer | null = null - - afterEach(() => { - act(() => renderer?.unmount()) - renderer = null - mocks.reducedMotion = true - mocks.reducedMotionResult = null - mocks.timing.mockClear() - mocks.loop.mockClear() - vi.restoreAllMocks() - }) - - async function renderPreview(active = true) { - const consoleError = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] !== 'string' || !args[0].includes('react-test-renderer is deprecated')) { - throw new Error(String(args[0])) - } - }) - await act(async () => { - renderer = create(createElement(NotificationOnboardingPreview, { active })) - }) - consoleError.mockRestore() - } - - it('shows sample agent-done and needs-input banners as a decorative mock', async () => { - await renderPreview() - const preview = renderer!.root.findByProps({ testID: 'notification-onboarding-preview' }) - const copy = findAllByHostType(renderer!.root, 'Text') - .flatMap((node) => node.props.children) - .join(' ') - - expect(preview.props.accessibilityElementsHidden).toBe(true) - expect(preview.props.importantForAccessibility).toBe('no-hide-descendants') - expect(copy).toContain('Codex finished') - expect(copy).toContain('Claude needs input') - }) - - it('does not animate the banners when the page is off-screen', async () => { - mocks.reducedMotion = false - await renderPreview(false) - expect(mocks.loop).not.toHaveBeenCalled() - }) - - it('loops the arriving banners while the page is active', async () => { - mocks.reducedMotion = false - await renderPreview(true) - expect(mocks.loop).toHaveBeenCalledOnce() - expect(mocks.loop.mock.results[0]?.value.start).toHaveBeenCalledOnce() - }) - - it('does not start the loop until reduced-motion is known', async () => { - let resolvePreference: (enabled: boolean) => void = () => {} - mocks.reducedMotionResult = new Promise((resolve) => { - resolvePreference = resolve - }) - - await renderPreview(true) - expect(mocks.loop).not.toHaveBeenCalled() - - await act(async () => { - resolvePreference(false) - }) - expect(mocks.loop).toHaveBeenCalledOnce() - }) -}) diff --git a/mobile/src/onboarding/legacy-notification-opt-in-route.test.ts b/mobile/src/onboarding/legacy-notification-opt-in-route.test.ts deleted file mode 100644 index edbb44f8e72..00000000000 --- a/mobile/src/onboarding/legacy-notification-opt-in-route.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { legacyNotificationOptInDestination } from './legacy-notification-opt-in-destination' - -describe('legacy notification opt-in destination', () => { - it('selects only notifications and preserves a scalar hostId', () => { - expect(legacyNotificationOptInDestination({ hostId: 'paired-host' })).toEqual({ - pathname: '/mobile-onboarding', - params: { steps: 'notifications', hostId: 'paired-host' } - }) - }) - - it('ignores unrelated legacy parameters', () => { - const legacyParams = { - hostId: 'paired-host', - steps: 'session-view', - returnTo: '/settings' - } - - expect(legacyNotificationOptInDestination(legacyParams)).toEqual({ - pathname: '/mobile-onboarding', - params: { steps: 'notifications', hostId: 'paired-host' } - }) - }) -}) diff --git a/mobile/src/settings/settings-screen-state.test.tsx b/mobile/src/settings/settings-screen-state.test.tsx index 0bcef5862e3..6bd21b6b58c 100644 --- a/mobile/src/settings/settings-screen-state.test.tsx +++ b/mobile/src/settings/settings-screen-state.test.tsx @@ -55,36 +55,6 @@ afterEach(() => { act(() => renderer?.unmount()) }) describe('shared settings screen state', () => { - it('shows a desktop on Auto as Auto and switches it to plain Toggle', async () => { - const operations: VoiceSettingsOperations = { - load: vi.fn().mockResolvedValue({ - enabled: true, - dictationMode: 'toggle', - dictationHoldToRecord: true, - selectedModelId: '', - models: [] - }), - configure: vi.fn(() => new Promise(() => {})), - download: vi.fn(), - delete: vi.fn() - } - await act(async () => { - renderer = create( - createElement(VoiceSettingsScreen, { operations, focused: true, onBack: vi.fn() }) - ) - }) - const modeProps = () => renderer.root.findByProps({ testIDPrefix: 'voice-mode-' }).props - expect(modeProps().selected).toBe('auto') - - await act(async () => { - modeProps().onSelect('toggle') - }) - expect(operations.configure).toHaveBeenCalledWith({ - dictationMode: 'toggle', - dictationHoldToRecord: false - }) - expect(modeProps().selected).toBe('toggle') - }) it('flips the Voice switch before the desktop replies and surfaces a rejected save', async () => { const loaded = { enabled: true, diff --git a/mobile/src/terminal/terminal-live-preedit-mirror.test.ts b/mobile/src/terminal/terminal-live-preedit-mirror.test.ts index d6a966b2348..04cba45fb51 100644 --- a/mobile/src/terminal/terminal-live-preedit-mirror.test.ts +++ b/mobile/src/terminal/terminal-live-preedit-mirror.test.ts @@ -12,10 +12,7 @@ type MirrorRun = { type MirrorFrame = { readonly text: string; readonly composing?: boolean } -function runMirrorSequence( - fieldStates: readonly (string | MirrorFrame)[], - options: { readonly commitAtEnd: boolean } = { commitAtEnd: false } -): MirrorRun { +function runMirrorSequence(fieldStates: readonly (string | MirrorFrame)[]): MirrorRun { const payloads: string[] = [] let sentText = '' let heldText = '' @@ -29,16 +26,6 @@ function runMirrorSequence( sentText = step.nextSentText heldText = step.heldText } - if (options.commitAtEnd) { - const lastField = sentText + heldText - const step = computeTerminalLiveMirrorStep(sentText, lastField, { commitHeld: true }) - const payload = buildTerminalLiveMirrorPayload(step) - if (payload.length > 0) { - payloads.push(payload) - } - sentText = step.nextSentText - heldText = step.heldText - } return { payloads, sentText, heldText } } @@ -117,19 +104,7 @@ const twoSetFields = [ describe('terminal live mirror with iOS keyboard edits', () => { it.each([ { keyboard: 'Cheonjiin', fields: cheonjiinFields }, - { keyboard: 'two-set', fields: twoSetFields }, - { - keyboard: 'autocorrect around a held Korean tail', - fields: ['teh 하', 'teh 하ᄉᆞ', 'the 하ᄉᆞ', 'the 하세 '] - }, - { - keyboard: 'replacement of an already-sent Korean prefix', - fields: ['안녕하', '안녕하ᄉᆞ', '반가ᄉᆞ', '반가워 '] - }, - { - keyboard: 'delete-all with a held Korean tail', - fields: ['안녕하', '안녕하ᄉᆞ', '', '새 입력 '] - } + { keyboard: 'two-set', fields: twoSetFields } ])('replays $keyboard edits for both receiver deletion units', ({ fields }) => { const run = runMirrorSequence(fields.map((text) => ({ text, composing: false }))) const segmenter = new Intl.Segmenter('ko', { granularity: 'grapheme' }) diff --git a/mobile/src/terminal/terminal-live-text-commit.test.ts b/mobile/src/terminal/terminal-live-text-commit.test.ts index c363419cfa5..610b2679b6f 100644 --- a/mobile/src/terminal/terminal-live-text-commit.test.ts +++ b/mobile/src/terminal/terminal-live-text-commit.test.ts @@ -17,26 +17,4 @@ describe('terminal live accessory local edit text', () => { ) } ) - - it.each([ - ['', ''], - ['a', ''], - ['a🙂', 'a'], - ['🙂a', '🙂'], - ['🙂🙂', '🙂'], - ['a\ud800', 'a'], - ['a\udc00', 'a'], - ['\ud800\ud800', '\ud800'], - ['\udc00\ud800', '\udc00'], - ['e\u0301', 'e'], - ['👩‍💻', '👩‍'], - ['\r\n', '\r'] - ])('preserves code-point deletion for %j', (fieldText, expected) => { - expect(getTerminalLiveAccessoryLocalEditText({ localEdit: 'backspace', fieldText })).toBe( - expected - ) - expect(getTerminalLiveAccessoryLocalEditText({ localEdit: 'delete', fieldText })).toBe( - fieldText - ) - }) }) diff --git a/mobile/src/terminal/terminal-text-field-submit-binding.web.test.ts b/mobile/src/terminal/terminal-text-field-submit-binding.web.test.ts index a3a52b32a35..4be80b442b8 100644 --- a/mobile/src/terminal/terminal-text-field-submit-binding.web.test.ts +++ b/mobile/src/terminal/terminal-text-field-submit-binding.web.test.ts @@ -80,13 +80,8 @@ describe.each(['android', 'ios'] as const)('live terminal keyboard chords on %s' it.each([ ['c', { ctrlKey: true }, '\x03'], - ['Escape', {}, '\x1b'], - ['Tab', {}, '\t'], - ['Tab', { shiftKey: true }, '\x1b[Z'], - ['ArrowUp', {}, '\x1b[A'], ['ArrowLeft', { ctrlKey: true }, '\x1b[1;5D'], - ['Enter', { shiftKey: true }, '\x1b[13;2u'], - ['ArrowLeft', { altKey: true }, '\x1b[1;3D'] + ['Enter', { shiftKey: true }, '\x1b[13;2u'] ])('claims %s once before RNWeb can resend it', (name, options, bytes) => { const { field, onChord, key } = setup() const parentKey = vi.fn() @@ -96,28 +91,18 @@ describe.each(['android', 'ios'] as const)('live terminal keyboard chords on %s' expect(parentKey).not.toHaveBeenCalled() }) - it.each(['b', '@', '[', ']', '{', ' '])( - 'keeps Option-generated %j native on iOS and Alt chords on Android', - (name) => { - const { onChord, key } = setup() - expect(key(name, { altKey: true }).defaultPrevented).toBe(os === 'android') - expect(onChord.mock.calls).toEqual(os === 'android' ? [[{ bytes: `\x1b${name}` }]] : []) - } - ) + it.each(['b'])('keeps Option-generated %j native on iOS and Alt chords on Android', (name) => { + const { onChord, key } = setup() + expect(key(name, { altKey: true }).defaultPrevented).toBe(os === 'android') + expect(onChord.mock.calls).toEqual(os === 'android' ? [[{ bytes: `\x1b${name}` }]] : []) + }) it.each([ ['a', {}], ['Enter', {}], ['Enter', { ctrlKey: true, shiftKey: true }], - ['Enter', { altKey: true, shiftKey: true }], - ['Enter', { shiftKey: true, isComposing: true }], - ['Enter', { shiftKey: true, keyCode: 229 }], ['Backspace', {}], - ['Delete', {}], ['ArrowLeft', {}], - ['ArrowRight', {}], - ['Home', {}], - ['End', {}], ['c', { ctrlKey: true, isComposing: true }], ['c', { ctrlKey: true, keyCode: 229 }], ['c', { ctrlKey: true, altKey: true }], diff --git a/mobile/src/terminal/terminal-webview-cell-box.test.ts b/mobile/src/terminal/terminal-webview-cell-box.test.ts index 5dfcb5dc351..fdbe3b06d85 100644 --- a/mobile/src/terminal/terminal-webview-cell-box.test.ts +++ b/mobile/src/terminal/terminal-webview-cell-box.test.ts @@ -109,22 +109,6 @@ describe('the cell box xterm laid out', () => { expect(handle().fitDimensions(FRAME)).toEqual({ cols: 55, rows: 47 }) }) - it('fits in the app from the reported box, and sends the frame with every grid', () => { - const { handle, notify } = mount() - notify({ type: 'web-ready', cellBox: cellAt(scale) }) - expect(handle().fitDimensions({ width: 427.5, height: 0 })).toBeNull() - expect(handle().fitDimensions({ width: 427.5, height: 710 })).toEqual({ cols: 55, rows: 47 }) - const frame = { width: 427.5, height: 710 } - handle().init({ cols: 55, rows: 47, initialData: '', frame }) - handle().resize(55, 47, frame) - handle().reflow(55, 47, frame) - const grids = nativeWebViewMethods.postMessage.mock.calls - .map(([message]) => JSON.parse(message)) - .filter((message) => ['init', 'resize', 'reflow'].includes(message.type)) - expect(grids.map((message) => message.frame)).toEqual([frame, frame, frame]) - expect(postedTypes()).not.toContain('measure') - }) - it('sends nothing before a ready: a load start drops what was queued', () => { const { handle, notify, webView } = mount() handle().init({ cols: 55, rows: 47, initialData: 'snapshot', frame: null }) diff --git a/mobile/src/terminal/terminal-webview-interactive-output.test.tsx b/mobile/src/terminal/terminal-webview-interactive-output.test.tsx deleted file mode 100644 index a84f902af16..00000000000 --- a/mobile/src/terminal/terminal-webview-interactive-output.test.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { - useTerminalWebViewController, - type TerminalWebViewTransport -} from './use-terminal-webview-controller' - -vi.mock('react-native', () => ({ AppState: { currentState: 'active' } })) - -vi.mock('./terminal-webview-engine-error-state', () => ({ - useTerminalWebViewEngineErrorState: () => ({ - clearEngineError: vi.fn(), - engineError: null, - reportEngineError: vi.fn(), - reportNativeEngineError: vi.fn() - }) -})) - -let root: ReactTestRenderer | undefined - -function mountController(interactionActive = true) { - const post = vi.fn() - const onTerminalInput = vi.fn() - const exposed: { current: ReturnType | null } = { - current: null - } - const transport = { post, pingsOnForegroundRecovery: () => false } - function Probe() { - exposed.current = useTerminalWebViewController( - { onTerminalInput, interactionActive }, - transport - ) - return null - } - act(() => { - root = create() - }) - const controller = exposed.current - if (!controller) { - throw new Error('Controller did not mount') - } - act(() => controller.receive({ type: 'web-ready' })) - const writes = () => - post.mock.calls - .map(([command]) => command) - .filter((command) => command.type === 'write') - .map((command) => command.data) - return { controller, onTerminalInput, writes } -} - -describe('terminal scroll response batching', () => { - afterEach(() => { - act(() => root?.unmount()) - root = undefined - vi.useRealTimers() - }) - - it.each(['\x1b[<64;2;2M', '\x1b[A'])('shortens pending output for gesture %j', (bytes) => { - vi.useFakeTimers() - const { controller, onTerminalInput, writes } = mountController() - controller.handle.write('first') - vi.advanceTimersByTime(5) - controller.handle.write('response') - vi.advanceTimersByTime(3) - controller.receive({ type: 'terminal-input', bytes }) - vi.advanceTimersByTime(8) - expect(writes()).toEqual(['first', 'response']) - expect(onTerminalInput).toHaveBeenCalledWith(bytes) - }) - - it.each(['', 'ordinary text', 123])('keeps ordinary batching for non-gesture %j', (bytes) => { - vi.useFakeTimers() - const { controller, writes } = mountController() - controller.handle.write('first') - controller.handle.write('pending') - controller.receive({ type: 'terminal-input', bytes }) - vi.advanceTimersByTime(16) - expect(writes()).toEqual(['first']) - vi.advanceTimersByTime(32) - expect(writes()).toEqual(['first', 'pending']) - }) - - it('rejects inactive gesture notifications without changing output batching', () => { - vi.useFakeTimers() - const { controller, onTerminalInput, writes } = mountController(false) - controller.handle.write('first') - controller.handle.write('pending') - controller.receive({ type: 'terminal-input', bytes: '\x1b[A' }) - vi.advanceTimersByTime(16) - expect(onTerminalInput).not.toHaveBeenCalled() - expect(writes()).toEqual(['first']) - vi.advanceTimersByTime(32) - expect(writes()).toEqual(['first', 'pending']) - }) - - it('drops pending gesture output before a new document snapshot', () => { - vi.useFakeTimers() - const { controller, writes } = mountController() - controller.handle.write('first') - controller.handle.write('stale') - controller.receive({ type: 'terminal-input', bytes: '\x1b[A' }) - controller.handle.init({ cols: 80, rows: 24, initialData: 'snapshot', frame: null }) - vi.advanceTimersByTime(48) - expect(writes()).toEqual(['first']) - }) -}) diff --git a/mobile/src/terminal/terminal-webview-query-reply-routing.test.ts b/mobile/src/terminal/terminal-webview-query-reply-routing.test.ts deleted file mode 100644 index b5296d75b21..00000000000 --- a/mobile/src/terminal/terminal-webview-query-reply-routing.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { createElement } from 'react' -import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { TerminalWebView } from './TerminalWebView' -import { findByHostType } from '../test-support/react-test-renderer-host-nodes' - -vi.mock('react-native', () => ({ - Platform: { OS: 'ios' }, - StyleSheet: { - absoluteFill: { - bottom: 0, - left: 0, - position: 'absolute', - right: 0, - top: 0 - }, - create: (styles: unknown) => styles - }, - Text: 'Text', - View: 'View' -})) - -vi.mock('react-native-webview', async () => { - const React = await import('react') - const WebView = React.forwardRef((props: Record, _ref) => - React.createElement('WebView', props) - ) - return { WebView, default: WebView } -}) - -vi.mock('lucide-react-native', () => ({ - RefreshCw: 'RefreshCw' -})) - -let renderer: ReactTestRenderer | null = null - -function postWebViewMessage(payload: Record): void { - if (!renderer) { - throw new Error('TerminalWebView did not render') - } - const webView = findByHostType(renderer.root, 'WebView') - act(() => { - webView.props.onMessage({ nativeEvent: { data: JSON.stringify(payload) } }) - }) -} - -describe('TerminalWebView query reply routing', () => { - afterEach(() => { - if (renderer) { - act(() => renderer?.unmount()) - renderer = null - } - vi.restoreAllMocks() - }) - - it('routes only complete terminal query replies to native', () => { - const onTerminalQueryReply = vi.fn() - vi.spyOn(console, 'error').mockImplementation(() => {}) - act(() => { - renderer = create(createElement(TerminalWebView, { onTerminalQueryReply })) - }) - - postWebViewMessage({ type: 'terminal-data', bytes: '\x1b[3;4R' }) - postWebViewMessage({ type: 'terminal-data', bytes: 'a' }) - postWebViewMessage({ type: 'terminal-data', bytes: '\x1b[A' }) - - expect(onTerminalQueryReply).toHaveBeenCalledTimes(1) - expect(onTerminalQueryReply).toHaveBeenCalledWith('\x1b[3;4R') - }) -}) diff --git a/mobile/src/terminal/terminal-webview-tap-routing.test.ts b/mobile/src/terminal/terminal-webview-tap-routing.test.ts deleted file mode 100644 index d8b30559234..00000000000 --- a/mobile/src/terminal/terminal-webview-tap-routing.test.ts +++ /dev/null @@ -1,375 +0,0 @@ -// @vitest-environment happy-dom -// URL taps with slight jitter once hit the long-press slop gate and never reached the native browser bridge. -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { TERMINAL_DOCUMENT_SCRIPT } from './terminal-webview-document-script.generated' -import { TERMINAL_DOCUMENT_MARKUP } from './terminal-webview-html' - -function bodyMarkup(): string { - return TERMINAL_DOCUMENT_MARKUP -} - -function makeTerminal( - lineRef: { current: string }, - mouseTrackingMode = 'none', - scrollbackRows = 0 -) { - return { - cols: 80, - rows: 24, - options: { fontSize: 13 }, - modes: { mouseTrackingMode }, - element: { scrollWidth: 800, scrollHeight: 360 }, - _core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 15 } } } } }, - buffer: { - active: { - viewportY: 0, - baseY: scrollbackRows, - length: 1, - cursorY: 0, - type: 'normal' as const, - getLine(row: number) { - // Rows after the first are xterm soft-wrap continuations. - const text = lineRef.current.split('\n')[row] - if (text === undefined) { - return null - } - return { - length: 80, - isWrapped: row > 0, - // Honor xterm’s translateToString slice arguments so wide-cell offset conversion is realistic. - translateToString: (_trim?: boolean, start?: number, end?: number) => - text.slice(start ?? 0, end ?? text.length), - getCell: () => ({ extended: undefined }) - } - } - } - }, - write(_d: string, cb?: () => void) { - cb?.() - }, - open() {}, - resize() {}, - clear() {}, - reset() {}, - refresh() {}, - selectAll() {}, - clearSelection() {}, - select() {}, - scrollLines() {}, - scrollToBottom() {}, - getSelection: () => '', - onLineFeed: () => ({ dispose() {} }), - onScroll: () => ({ dispose() {} }), - onWriteParsed: () => ({ dispose() {} }), - dispose() {} - } -} - -type Posted = Array> - -type OscLinkRange = { row: number; startCol: number; endCol: number; uri: string } - -function boot( - line: string, - oscLinks?: OscLinkRange[], - mouseTrackingMode = 'none', - scrollbackRows = 0 -): { posted: Posted; setLine: (line: string) => void } { - const posted: Posted = [] - const lineRef = { current: line } - const w = window as unknown as { Terminal: unknown; ReactNativeWebView: unknown } - w.Terminal = function () { - return makeTerminal(lineRef, mouseTrackingMode, scrollbackRows) - } - w.ReactNativeWebView = { - postMessage(s: string) { - posted.push(JSON.parse(s)) - } - } - document.body.innerHTML = bodyMarkup() - // The bundle the WebView loads, run as the WebView runs it. - new Function(TERMINAL_DOCUMENT_SCRIPT)() - window.dispatchEvent( - new MessageEvent('message', { - data: JSON.stringify({ type: 'init', cols: 80, rows: 24, initialData: '', oscLinks }) - }) - ) - return { - posted, - setLine: (nextLine: string) => { - lineRef.current = nextLine - } - } -} - -function fireTouch(type: string, touches: Array<{ x: number; y: number }>): void { - const surface = document.getElementById('terminal-surface') as HTMLElement - const ev = new Event(type, { bubbles: true, cancelable: true }) - Object.defineProperty(ev, 'touches', { - value: touches.map((p, i) => ({ identifier: i, clientX: p.x, clientY: p.y, target: surface })) - }) - surface.dispatchEvent(ev) -} - -const settle = (): Promise => new Promise((r) => setTimeout(r, 50)) - -describe('terminal WebView tap routing', () => { - const URL_LINE = 'visit https://example.com/foo now' - const tapX = 12 * 8 * 0.3125 - const tapY = 2 - const screenXForCol = (col: number): number => col * 8 * 0.3125 - - beforeEach(() => { - vi.spyOn(window, 'addEventListener') - vi.spyOn(document, 'addEventListener') - Object.defineProperty(window, 'innerWidth', { value: 200, configurable: true }) - Object.defineProperty(window, 'innerHeight', { value: 400, configurable: true }) - }) - - afterEach(() => { - for (const [type, listener, options] of vi.mocked(window.addEventListener).mock.calls) { - window.removeEventListener(type, listener, options) - } - for (const [type, listener, options] of vi.mocked(document.addEventListener).mock.calls) { - document.removeEventListener(type, listener, options) - } - vi.restoreAllMocks() - }) - - it('still posts open-url when the tap jitters a few pixels', async () => { - // Small finger jitter must stay a tap and open the touched URL. - const { posted } = boot(URL_LINE) - await settle() - fireTouch('touchstart', [{ x: tapX, y: tapY }]) - fireTouch('touchmove', [{ x: tapX + 11, y: tapY + 4 }]) - fireTouch('touchend', []) - expect(posted.find((m) => m.type === 'open-url')?.url).toBe('https://example.com/foo') - }) - - it('focuses native input after reporting a touch tap to a mouse-tracking TUI', async () => { - const { posted } = boot('interactive prompt', undefined, 'drag') - await settle() - window.dispatchEvent( - new MessageEvent('message', { - data: JSON.stringify({ type: 'write', data: '\u001b[?1006l' }) - }) - ) - - fireTouch('touchstart', [{ x: 20, y: tapY }]) - fireTouch('touchend', []) - - expect( - posted - .filter((message) => message.type === 'terminal-input' || message.type === 'terminal-tap') - .map((message) => message.type) - ).toEqual(['terminal-input', 'terminal-tap']) - }) - - it('focuses native input without mouse bytes while the tracking encoding is unproven', async () => { - const { posted } = boot('interactive prompt', undefined, 'drag') - await settle() - - fireTouch('touchstart', [{ x: 20, y: tapY }]) - fireTouch('touchend', []) - - expect(posted.find((message) => message.type === 'terminal-input')).toBeUndefined() - expect(posted.filter((message) => message.type === 'terminal-tap')).toHaveLength(1) - }) - - it('opens first-load OSC links from snapshot metadata on the exact cell range', async () => { - const oscLinks = [{ row: 0, startCol: 6, endCol: 11, uri: 'https://example.com/issue/1234' }] - const { posted } = boot('issue #1234 done', oscLinks) - await settle() - - fireTouch('touchstart', [{ x: screenXForCol(7), y: tapY }]) - fireTouch('touchend', []) - - expect(posted.find((m) => m.type === 'open-url')?.url).toBe('https://example.com/issue/1234') - }) - - it('opens first-load file OSC links through terminal file taps', async () => { - const oscLinks = [{ row: 0, startCol: 5, endCol: 15, uri: 'file:///tmp/result.json#L12C3' }] - const { posted } = boot('open artifact here', oscLinks) - await settle() - - fireTouch('touchstart', [{ x: screenXForCol(7), y: tapY }]) - fireTouch('touchend', []) - - expect(posted.find((m) => m.type === 'open-url')).toBeUndefined() - expect(posted.find((m) => m.type === 'terminal-file-tap')).toEqual({ - type: 'terminal-file-tap', - candidates: [{ pathText: '/tmp/result.json', line: 12, column: 3 }] - }) - }) - - it('keeps the UNC host of an OSC file link for a Windows share workspace', async () => { - const oscLinks = [ - { row: 0, startCol: 5, endCol: 15, uri: 'file://server/share/repo/app.ts#L12' } - ] - const { posted } = boot('open artifact here', oscLinks) - await settle() - - fireTouch('touchstart', [{ x: screenXForCol(7), y: tapY }]) - fireTouch('touchend', []) - - expect(posted.find((m) => m.type === 'terminal-file-tap')).toEqual({ - type: 'terminal-file-tap', - candidates: [{ pathText: '//server/share/repo/app.ts', line: 12, column: null }] - }) - }) - - it('keeps a tap on prose beside a path an ordinary tap', async () => { - const line = 'me@host:~/proj$ git status' - const { posted } = boot(line) - await settle() - - fireTouch('touchstart', [{ x: screenXForCol(line.indexOf('git') + 1), y: tapY }]) - fireTouch('touchend', []) - - expect(posted.find((m) => m.type === 'terminal-file-tap')).toBeUndefined() - expect(posted.find((m) => m.type === 'terminal-tap')).toBeDefined() - }) - - it('opens a markdown link whose destination has spaces', async () => { - const line = 'see [PDF]()' - const { posted } = boot(line) - await settle() - - fireTouch('touchstart', [{ x: screenXForCol(line.indexOf('with') + 1), y: tapY }]) - fireTouch('touchend', []) - - expect(posted.find((m) => m.type === 'terminal-file-tap')).toEqual({ - type: 'terminal-file-tap', - candidates: [{ pathText: '/tmp/folder with spaces/report.pdf', line: null, column: null }] - }) - }) - - it('opens plain file URLs through terminal file taps', async () => { - const line = 'open file:///tmp/result.json#L12C3 now' - const { posted } = boot(line) - await settle() - - fireTouch('touchstart', [{ x: screenXForCol(line.indexOf('result')), y: tapY }]) - fireTouch('touchend', []) - - expect(posted.find((m) => m.type === 'terminal-file-tap')).toEqual({ - type: 'terminal-file-tap', - candidates: [{ pathText: '/tmp/result.json', line: 12, column: 3 }] - }) - }) - - it('opens the whole URL when the tap lands on its soft-wrapped second row', async () => { - const url = `https://example.com/${'a'.repeat(70)}/end` - const text = `see ${url} now` - const { posted } = boot(`${text.slice(0, 80)}\n${text.slice(80)}`) - await settle() - - fireTouch('touchstart', [{ x: screenXForCol(2), y: tapY + 15 * 0.3125 }]) - fireTouch('touchend', []) - - expect(posted.find((m) => m.type === 'open-url')?.url).toBe(url) - }) - - it('does not open snapshot OSC links from adjacent terminal cells', async () => { - const oscLinks = [{ row: 0, startCol: 6, endCol: 11, uri: 'https://example.com/issue/1234' }] - const { posted } = boot('issue #1234 done', oscLinks) - await settle() - - fireTouch('touchstart', [{ x: screenXForCol(12), y: tapY }]) - fireTouch('touchend', []) - - expect(posted.find((m) => m.type === 'open-url')).toBeUndefined() - }) - - it('does not open stale snapshot OSC links after the row text changes', async () => { - const oscLinks = [{ row: 0, startCol: 6, endCol: 11, uri: 'https://example.com/issue/1234' }] - const { posted, setLine } = boot('issue #1234 done', oscLinks) - await settle() - setLine('issue plain done') - - fireTouch('touchstart', [{ x: screenXForCol(7), y: tapY }]) - fireTouch('touchend', []) - - expect(posted.find((m) => m.type === 'open-url')).toBeUndefined() - }) - - it('does not post open-url for a scroll gesture past the tap slop', async () => { - const { posted } = boot(URL_LINE) - await settle() - fireTouch('touchstart', [{ x: tapX, y: tapY }]) - fireTouch('touchmove', [{ x: tapX, y: tapY + 120 }]) - fireTouch('touchend', []) - expect(posted.find((m) => m.type === 'open-url')).toBeUndefined() - }) - - // Scaled rows are smaller than tap slop; a short drag can still count as a real row scroll. - it('does not tap after a short drag that scrolled scrollback', async () => { - const { posted } = boot('plain output', undefined, 'none', 50) - await settle() - fireTouch('touchstart', [{ x: 20, y: 100 }]) - fireTouch('touchmove', [{ x: 20, y: 88 }]) - fireTouch('touchend', []) - expect(posted.find((m) => m.type === 'terminal-tap')).toBeUndefined() - }) - - it('does not tap or click after a short drag that scrolled a mouse-tracking TUI', async () => { - const { posted } = boot('interactive prompt', undefined, 'drag') - await settle() - window.dispatchEvent( - new MessageEvent('message', { - data: JSON.stringify({ type: 'write', data: '\u001b[?1006l' }) - }) - ) - - fireTouch('touchstart', [{ x: 20, y: 100 }]) - fireTouch('touchmove', [{ x: 20, y: 88 }]) - fireTouch('touchend', []) - - expect(posted.find((m) => m.type === 'terminal-tap')).toBeUndefined() - const input = posted - .filter((m) => m.type === 'terminal-input') - .map((m) => String(m.bytes)) - .join('') - const reports = input.split('\u001b[M').slice(1) - expect(reports.length).toBeGreaterThan(0) - expect(reports.every((report) => report[0] === '`' || report[0] === 'a')).toBe(true) - }) - - it('still taps when the touch moves less than a row', async () => { - const { posted } = boot('plain output', undefined, 'none', 50) - await settle() - fireTouch('touchstart', [{ x: 20, y: 100 }]) - fireTouch('touchmove', [{ x: 20, y: 97 }]) - fireTouch('touchend', []) - expect(posted.filter((m) => m.type === 'terminal-tap')).toHaveLength(1) - }) - - it('still taps when the touch moves a row and back before anything scrolls', async () => { - const { posted } = boot('plain output', undefined, 'none', 50) - await settle() - fireTouch('touchstart', [{ x: 20, y: 100 }]) - fireTouch('touchmove', [{ x: 20, y: 94 }]) - fireTouch('touchmove', [{ x: 20, y: 100 }]) - fireTouch('touchend', []) - expect(posted.filter((m) => m.type === 'terminal-tap')).toHaveLength(1) - }) - - it('does not tap when the touch stops a coast', async () => { - let now = 1_000 - vi.spyOn(Date, 'now').mockImplementation(() => now) - const { posted } = boot('plain output', undefined, 'none', 50) - await settle() - fireTouch('touchstart', [{ x: 20, y: 300 }]) - now += 16 - fireTouch('touchmove', [{ x: 20, y: 240 }]) - now += 16 - fireTouch('touchmove', [{ x: 20, y: 180 }]) - fireTouch('touchend', []) - const beforeStoppingTouch = posted.length - - fireTouch('touchstart', [{ x: 20, y: 180 }]) - fireTouch('touchend', []) - - expect(posted.slice(beforeStoppingTouch).find((m) => m.type === 'terminal-tap')).toBeUndefined() - }) -}) diff --git a/mobile/src/terminal/terminal-webview-wheel-scroll.test.ts b/mobile/src/terminal/terminal-webview-wheel-scroll.test.ts deleted file mode 100644 index 33f0c635258..00000000000 --- a/mobile/src/terminal/terminal-webview-wheel-scroll.test.ts +++ /dev/null @@ -1,278 +0,0 @@ -// @vitest-environment happy-dom -import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' -import { TERMINAL_DOCUMENT_SCRIPT } from './terminal-webview-document-script.generated' -import { TERMINAL_DOCUMENT_MARKUP } from './terminal-webview-html' - -function bodyMarkup(): string { - return TERMINAL_DOCUMENT_MARKUP -} - -type BufferState = { - baseY: number - type: 'alternate' | 'normal' - viewportY: number -} - -type TerminalStub = ReturnType -type RegisteredWindowListener = { - listener: EventListenerOrEventListenerObject - options?: boolean | AddEventListenerOptions - type: string -} - -const CELL_HEIGHT = 15 -const ESC = '\u001b' -const ESC_ARROW_DOWN = `${ESC}[B` -const ESC_APP_ARROW_UP = `${ESC}OA` - -function makeTerminal(buffer: BufferState, scrollLines: (lines: number) => void) { - const terminal = { - cols: 40, - rows: 24, - options: { fontSize: 13 }, - modes: { mouseTrackingMode: 'none' as string }, - element: null as HTMLElement | null, - _core: { - _renderService: { dimensions: { css: { cell: { width: 8, height: CELL_HEIGHT } } } } - }, - buffer: { - active: { - get baseY() { - return buffer.baseY - }, - get type() { - return buffer.type - }, - get viewportY() { - return buffer.viewportY - }, - cursorY: 0, - length: 1, - getLine: () => null - } - }, - write(_data: string, callback?: () => void) { - callback?.() - }, - open(surface: HTMLElement) { - terminal.element = surface - }, - loadAddon() {}, - resize(cols: number, rows: number) { - terminal.cols = cols - terminal.rows = rows - }, - clear() {}, - reset() {}, - refresh() {}, - selectAll() {}, - clearSelection() {}, - select() {}, - scrollLines, - scrollToBottom() {}, - scrollToLine() {}, - attachCustomKeyEventHandler() {}, - getSelection: () => '', - onData: () => ({ dispose() {} }), - onLineFeed: () => ({ dispose() {} }), - onScroll: () => ({ dispose() {} }), - onWriteParsed: () => ({ dispose() {} }), - dispose() {} - } - return terminal -} - -function dispatchWheel(deltaY: number, init: WheelEventInit = {}): WheelEvent { - const surface = document.getElementById('terminal-surface') - if (!surface) { - throw new Error('terminal surface missing') - } - const event = new WheelEvent('wheel', { - bubbles: true, - cancelable: true, - clientX: 40, - clientY: 60, - deltaMode: 0, - deltaY, - ...init - }) - if (init.ctrlKey) { - // Happy DOM drops wheel modifier initialization; set flags explicitly. - Object.defineProperty(event, 'ctrlKey', { value: true }) - } - surface.dispatchEvent(event) - return event -} - -function terminalInputBytes(postMessage: ReturnType): string { - return postMessage.mock.calls - .map(([raw]) => JSON.parse(String(raw)) as { bytes?: string; type: string }) - .filter((msg) => msg.type === 'terminal-input') - .map((msg) => msg.bytes ?? '') - .join('') -} - -describe('terminal WebView external pointer wheel scrolling', () => { - let animationFrames: Array<() => void> - let buffer: BufferState - let postMessage: Mock<(data: string) => void> - let registeredWindowListeners: RegisteredWindowListener[] - let scrollLines: Mock<(lines: number) => void> - let terminals: TerminalStub[] - - function boot(): void { - document.body.innerHTML = bodyMarkup() - new Function(TERMINAL_DOCUMENT_SCRIPT)() - window.dispatchEvent( - new MessageEvent('message', { - data: JSON.stringify({ type: 'init', cols: 40, rows: 24, initialData: '' }) - }) - ) - // Wait for init’s next-frame surface commit. - while (animationFrames.length > 0) { - animationFrames.shift()?.() - } - } - - function flushFrames(): void { - for (let i = 0; i < 4 && animationFrames.length > 0; i++) { - animationFrames.shift()?.() - } - } - - beforeEach(() => { - animationFrames = [] - buffer = { baseY: 0, type: 'normal', viewportY: 0 } - registeredWindowListeners = [] - scrollLines = vi.fn<(lines: number) => void>() - terminals = [] - const addWindowEventListener = window.addEventListener.bind(window) - vi.spyOn(window, 'addEventListener').mockImplementation((( - type: string, - listener: EventListenerOrEventListenerObject, - options?: boolean | AddEventListenerOptions - ) => { - registeredWindowListeners.push({ type, listener, options }) - addWindowEventListener(type, listener, options) - }) as typeof window.addEventListener) - vi.stubGlobal('requestAnimationFrame', (callback: () => void) => { - animationFrames.push(callback) - return animationFrames.length - }) - vi.stubGlobal('cancelAnimationFrame', () => {}) - Object.defineProperty(window, 'innerWidth', { value: 381, configurable: true }) - Object.defineProperty(window, 'innerHeight', { value: 612, configurable: true }) - postMessage = vi.fn<(data: string) => void>() - const webWindow = window as unknown as { - Terminal: new () => TerminalStub - ReactNativeWebView: { postMessage: (data: string) => void } - } - webWindow.Terminal = function () { - const terminal = makeTerminal(buffer, scrollLines) - terminals.push(terminal) - return terminal - } as unknown as new () => TerminalStub - webWindow.ReactNativeWebView = { postMessage } - }) - - afterEach(() => { - for (const { type, listener, options } of registeredWindowListeners) { - window.removeEventListener(type, listener as EventListener, options) - } - vi.restoreAllMocks() - vi.unstubAllGlobals() - }) - - it('turns an alternate-screen wheel scroll into cursor keys (#6863, #8818)', () => { - buffer = { baseY: 0, type: 'alternate', viewportY: 0 } - boot() - expect(terminals).toHaveLength(1) - - dispatchWheel(3 * CELL_HEIGHT) - - // Alternate-screen TUIs have no scrollback; wheels must reach the PTY as input, matching touch. - expect(terminalInputBytes(postMessage)).toBe(ESC_ARROW_DOWN.repeat(3)) - }) - - it('sends application cursor keys when the TUI enabled DECCKM', () => { - buffer = { baseY: 0, type: 'alternate', viewportY: 0 } - boot() - const terminal = terminals[0] - if (!terminal) { - throw new Error('terminal missing') - } - ;(terminal.modes as Record).applicationCursorKeysMode = true - - dispatchWheel(-2 * CELL_HEIGHT) - - expect(terminalInputBytes(postMessage)).toBe(ESC_APP_ARROW_UP.repeat(2)) - }) - - it('reports the wheel as mouse tracking bytes when the TUI asked for them', () => { - buffer = { baseY: 0, type: 'alternate', viewportY: 0 } - boot() - const terminal = terminals[0] - if (!terminal) { - throw new Error('terminal missing') - } - terminal.modes.mouseTrackingMode = 'any' - window.dispatchEvent( - new MessageEvent('message', { - data: JSON.stringify({ type: 'write', data: `${ESC}[?1006l` }) - }) - ) - - dispatchWheel(CELL_HEIGHT) - - const bytes = terminalInputBytes(postMessage) - expect(bytes.slice(0, 4)).toBe(`${ESC}[Ma`) - expect(bytes).toHaveLength(6) - expect(bytes.charCodeAt(4)).toBeGreaterThanOrEqual(33) - expect(bytes.charCodeAt(5)).toBeGreaterThanOrEqual(33) - }) - - it('scrolls normal-buffer scrollback from a wheel instead of leaving it frozen', () => { - buffer = { baseY: 20, type: 'normal', viewportY: 20 } - boot() - - dispatchWheel(-3 * CELL_HEIGHT) - flushFrames() - - expect(scrollLines).toHaveBeenCalledWith(-3) - }) - - it('converts line-mode deltas that Android reports for external mouse wheels', () => { - buffer = { baseY: 0, type: 'alternate', viewportY: 0 } - boot() - - dispatchWheel(2, { deltaMode: 1 }) - - expect(terminalInputBytes(postMessage)).toBe(ESC_ARROW_DOWN.repeat(2)) - }) - - it('swallows a trackpad pinch instead of firing cursor keys at the TUI', () => { - buffer = { baseY: 0, type: 'alternate', viewportY: 0 } - boot() - - const event = dispatchWheel(3 * CELL_HEIGHT, { ctrlKey: true }) - - expect(terminalInputBytes(postMessage)).toBe('') - expect(event.defaultPrevented).toBe(true) - }) - - it('clears fractional wheel state when the terminal is recreated', () => { - buffer = { baseY: 0, type: 'alternate', viewportY: 0 } - boot() - dispatchWheel(CELL_HEIGHT - 1) - - window.dispatchEvent( - new MessageEvent('message', { - data: JSON.stringify({ type: 'init', cols: 40, rows: 24, initialData: '' }) - }) - ) - flushFrames() - dispatchWheel(1) - - expect(terminalInputBytes(postMessage)).toBe('') - }) -}) diff --git a/mobile/src/terminal/terminal-write-coalescer.test.ts b/mobile/src/terminal/terminal-write-coalescer.test.ts index 5cc20d8fef1..41e833f8990 100644 --- a/mobile/src/terminal/terminal-write-coalescer.test.ts +++ b/mobile/src/terminal/terminal-write-coalescer.test.ts @@ -1,11 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { - createTerminalWriteCoalescer, - TERMINAL_WRITE_FLUSH_WINDOW_MS, - TERMINAL_WRITE_MAX_PENDING_UNITS, - TERMINAL_INTERACTIVE_WRITE_WINDOW_MS, - TERMINAL_INTERACTIVE_WRITE_DURATION_MS -} from './terminal-write-coalescer' +import { createTerminalWriteCoalescer } from './terminal-write-coalescer' function createDeliverySink() { const delivered: string[] = [] @@ -32,7 +26,7 @@ describe('terminal write coalescer', () => { coalescer.write('c') expect(sink.delivered).toEqual(['a']) - vi.advanceTimersByTime(TERMINAL_WRITE_FLUSH_WINDOW_MS) + vi.advanceTimersByTime(48) expect(sink.delivered).toEqual(['a', 'bc']) }) @@ -47,25 +41,10 @@ describe('terminal write coalescer', () => { vi.setSystemTime(100_000 - 60 * 60 * 1000) coalescer.write('b') - vi.advanceTimersByTime(TERMINAL_WRITE_FLUSH_WINDOW_MS) + vi.advanceTimersByTime(48) expect(sink.delivered).toEqual(['a', 'b']) }) - it('flushes immediately when the pending buffer exceeds the size cap', () => { - vi.useFakeTimers() - const sink = createDeliverySink() - const coalescer = createTerminalWriteCoalescer(sink.deliver) - - coalescer.write('a') - coalescer.write('x'.repeat(TERMINAL_WRITE_MAX_PENDING_UNITS + 1)) - - expect(sink.delivered).toHaveLength(2) - expect(sink.delivered[1]).toHaveLength(TERMINAL_WRITE_MAX_PENDING_UNITS + 1) - expect(vi.getTimerCount()).toBe(0) - vi.runOnlyPendingTimers() - expect(sink.delivered).toHaveLength(2) - }) - it('flushNow() delivers the joined pending buffer synchronously and cancels the timer', () => { vi.useFakeTimers() const sink = createDeliverySink() @@ -77,25 +56,10 @@ describe('terminal write coalescer', () => { coalescer.flushNow() expect(sink.delivered).toEqual(['a', 'bc']) - expect(vi.getTimerCount()).toBe(0) vi.runOnlyPendingTimers() expect(sink.delivered).toEqual(['a', 'bc']) }) - it('shortens an existing output wait when a scroll gesture arrives', () => { - vi.useFakeTimers() - const sink = createDeliverySink() - const coalescer = createTerminalWriteCoalescer(sink.deliver) - coalescer.write('first') - vi.advanceTimersByTime(5) - coalescer.write('response') - vi.advanceTimersByTime(3) - coalescer.markInteractive() - vi.advanceTimersByTime(TERMINAL_INTERACTIVE_WRITE_WINDOW_MS - 8) - expect(sink.delivered).toEqual(['first', 'response']) - expect(vi.getTimerCount()).toBe(0) - }) - it('does not postpone output when gestures repeat faster than one frame', () => { vi.useFakeTimers() const sink = createDeliverySink() @@ -105,11 +69,9 @@ describe('terminal write coalescer', () => { for (let i = 0; i < 8; i += 1) { coalescer.write(`${i}`) coalescer.markInteractive() - expect(vi.getTimerCount()).toBe(1) vi.advanceTimersByTime(2) } expect(sink.delivered).toEqual(['first', '01234567']) - expect(vi.getTimerCount()).toBe(0) }) it('clears interactive state and pending output when a document is replaced', () => { @@ -122,25 +84,9 @@ describe('terminal write coalescer', () => { coalescer.clear() coalescer.write('new') coalescer.write('new-trailing') - vi.advanceTimersByTime(TERMINAL_INTERACTIVE_WRITE_WINDOW_MS) + vi.advanceTimersByTime(16) expect(sink.delivered).toEqual(['old', 'new']) - vi.advanceTimersByTime(TERMINAL_WRITE_FLUSH_WINDOW_MS - TERMINAL_INTERACTIVE_WRITE_WINDOW_MS) + vi.advanceTimersByTime(32) expect(sink.delivered).toEqual(['old', 'new', 'new-trailing']) }) - - it('expires interactive batching on a monotonic clock after a wall-clock rollback', () => { - vi.useFakeTimers() - vi.setSystemTime(100_000) - const sink = createDeliverySink() - const coalescer = createTerminalWriteCoalescer(sink.deliver) - coalescer.markInteractive() - vi.setSystemTime(0) - vi.advanceTimersByTime(TERMINAL_INTERACTIVE_WRITE_DURATION_MS) - coalescer.write('first') - coalescer.write('second') - vi.advanceTimersByTime(TERMINAL_INTERACTIVE_WRITE_WINDOW_MS) - expect(sink.delivered).toEqual(['first']) - vi.advanceTimersByTime(TERMINAL_WRITE_FLUSH_WINDOW_MS - TERMINAL_INTERACTIVE_WRITE_WINDOW_MS) - expect(sink.delivered).toEqual(['first', 'second']) - }) }) diff --git a/mobile/src/terminal/use-terminal-live-input-click.test.ts b/mobile/src/terminal/use-terminal-live-input-click.test.ts index c855decfb7d..059167df485 100644 --- a/mobile/src/terminal/use-terminal-live-input-click.test.ts +++ b/mobile/src/terminal/use-terminal-live-input-click.test.ts @@ -17,12 +17,11 @@ type ClickHarness = { readonly captures: readonly string[] readonly handlers: Handlers readonly sent: readonly string[] - readonly changeHandle: (handle: string) => void readonly unmount: () => void } function createClickHarness(sender?: TerminalLiveInputSender): ClickHarness { - let activeHandle = 'terminal-a' + const activeHandle = 'terminal-a' const activeHandleRef: RefObject = { current: activeHandle } const activeSessionTabTypeRef: RefObject = { current: 'terminal' } const captures: string[] = [] @@ -69,11 +68,6 @@ function createClickHarness(sender?: TerminalLiveInputSender): ClickHarness { return handlers }, sent, - changeHandle: (handle) => { - activeHandle = handle - activeHandleRef.current = handle - act(() => renderer?.update(createElement(Harness))) - }, unmount: () => { act(() => renderer?.unmount()) } @@ -171,21 +165,18 @@ describe('terminal live input around a click', () => { } }) - it.each(['insertText', 'insertFromPaste', 'insertCompositionText'])( - 'preserves immediate matching DOM input %s without a key event', - async (inputType) => { - const { handlers, sent, captures, unmount } = createClickHarness() - try { - change(handlers, 'x') - await handlers.sendLiveInputClick('terminal-a', CLICK) - handlers.handleLiveInputChange({ - nativeEvent: { text: 'x', isComposing: false, inputType } - }) - await vi.waitFor(() => expect(sent.join('')).toBe('x' + CLICK + 'x')) - expect(captures.at(-1)).toBe('x') - } finally { - unmount() - } + it('preserves immediate matching DOM input without a key event', async () => { + const { handlers, sent, captures, unmount } = createClickHarness() + try { + change(handlers, 'x') + await handlers.sendLiveInputClick('terminal-a', CLICK) + handlers.handleLiveInputChange({ + nativeEvent: { text: 'x', isComposing: false, inputType: 'insertText' } + }) + await vi.waitFor(() => expect(sent.join('')).toBe(`x${CLICK}x`)) + expect(captures.at(-1)).toBe('x') + } finally { + unmount() } - ) + }) }) diff --git a/mobile/src/terminal/use-terminal-live-input-commit.test.ts b/mobile/src/terminal/use-terminal-live-input-commit.test.ts index 69927053d8e..9f45a1c1abc 100644 --- a/mobile/src/terminal/use-terminal-live-input-commit.test.ts +++ b/mobile/src/terminal/use-terminal-live-input-commit.test.ts @@ -3,7 +3,6 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer' import type { TextInputInstance } from 'react-native' import { afterEach, describe, expect, it, vi } from 'vitest' import type { TerminalLiveInputSender } from './terminal-live-input-sender' -import { TERMINAL_LIVE_HELD_PREEDIT_COMMIT_DELAY_MS } from './terminal-live-preedit-mirror' import { useTerminalLiveInputCommit } from './use-terminal-live-input-commit' vi.mock('../platform/live-input-composing-range', () => ({ @@ -21,25 +20,18 @@ function changeLiveInput( } type TerminalLiveInputCommitHarness = { - readonly failures: ReturnType readonly captures: readonly string[] - readonly getHandlers: () => TerminalLiveInputCommitHandlers readonly handlers: TerminalLiveInputCommitHandlers readonly sent: readonly string[] - readonly setActiveSessionTabType: (next: string | undefined) => void - readonly setConnected: (next: boolean) => void - readonly setSendResult: (next: boolean) => void readonly unmount: () => void } type TerminalLiveInputCommitHarnessOptions = { - readonly sendResult?: boolean readonly sender?: TerminalLiveInputSender readonly bracketedPaste?: boolean } function createTerminalLiveInputCommitHarness({ - sendResult = true, sender, bracketedPaste = false }: TerminalLiveInputCommitHarnessOptions = {}): TerminalLiveInputCommitHarness { @@ -52,16 +44,12 @@ function createTerminalLiveInputCommitHarness({ } const liveInputRef: RefObject = { current: null } const sent: string[] = [] - const failures = vi.fn() - let currentSendResult = sendResult const sendLiveTerminalInputRef: RefObject = { current: async (handle, bytes) => { sent.push(bytes) - return sender ? sender(handle, bytes) : currentSendResult + return sender ? sender(handle, bytes) : true } } - let currentActiveSessionTabType: string | undefined = 'terminal' - let currentConnected = true let handlers: TerminalLiveInputCommitHandlers | null = null let renderer: ReactTestRenderer | null = null @@ -69,10 +57,9 @@ function createTerminalLiveInputCommitHarness({ handlers = useTerminalLiveInputCommit({ activeHandle, activeHandleRef, - activeSessionTabType: currentActiveSessionTabType, + activeSessionTabType: 'terminal', activeSessionTabTypeRef, - connected: currentConnected, - onSendFailure: failures, + connected: true, framesLivePaste: () => bracketedPaste, liveInputRef, sendLiveTerminalInputRef, @@ -89,33 +76,9 @@ function createTerminalLiveInputCommitHarness({ } return { - failures, captures, - getHandlers: () => { - if (!handlers) { - throw new Error('terminal live input hook is not mounted') - } - return handlers - }, handlers, sent, - setActiveSessionTabType: (next: string | undefined): void => { - currentActiveSessionTabType = next - // Couple tab ref and prop as production does; both become null during inventory lag. - activeSessionTabTypeRef.current = next ?? null - act(() => { - renderer?.update(createElement(Harness)) - }) - }, - setConnected: (next: boolean): void => { - currentConnected = next - act(() => { - renderer?.update(createElement(Harness)) - }) - }, - setSendResult: (next: boolean): void => { - currentSendResult = next - }, unmount: () => { act(() => renderer?.unmount()) } @@ -134,7 +97,7 @@ describe('terminal live input commit hook', () => { .fn() .mockReturnValueOnce(pending) .mockResolvedValue(true) - const { handlers, sent, captures, failures, unmount } = createTerminalLiveInputCommitHarness({ + const { handlers, sent, captures, unmount } = createTerminalLiveInputCommitHarness({ sender }) changeLiveInput(handlers, 'abc', false) @@ -145,14 +108,12 @@ describe('terminal live input commit hook', () => { await expect(submit).resolves.toBe(false) expect(sent).toEqual(['abc']) expect(captures.at(-1)).toBe('') - expect(failures).toHaveBeenCalledTimes(1) changeLiveInput(handlers, 'ab', false) changeLiveInput(handlers, 'x', false) await vi.waitFor(() => expect(sent).toEqual(['abc', 'x'])) changeLiveInput(handlers, '', false) await vi.waitFor(() => expect(sent).toEqual(['abc', 'x', '\x7f'])) - expect(failures).toHaveBeenCalledTimes(1) unmount() } ) @@ -170,7 +131,7 @@ describe('terminal live input commit hook', () => { expect(sent).toEqual([`\x1b[200~${text}\x1b[201~`, 'CLICK']) expect(captures.at(-1)).toBe('') changeLiveInput(handlers, 'x', false) - await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_PREEDIT_COMMIT_DELAY_MS * 2) + await vi.advanceTimersByTimeAsync(600) expect(sent).toEqual([`\x1b[200~${text}\x1b[201~`, 'CLICK', 'x']) } finally { unmount() diff --git a/mobile/src/terminal/use-terminal-text-field-submit-binding.test.tsx b/mobile/src/terminal/use-terminal-text-field-submit-binding.test.tsx index 37688c3ff3a..e26bca9fc6b 100644 --- a/mobile/src/terminal/use-terminal-text-field-submit-binding.test.tsx +++ b/mobile/src/terminal/use-terminal-text-field-submit-binding.test.tsx @@ -7,27 +7,19 @@ import { useTerminalTextFieldSubmitBinding } from './use-terminal-text-field-sub // Mock only the platform listener seam so tests can invoke the closure it actually captured. const mocks = vi.hoisted(() => { - const boundHandlers: Array<() => void> = [] return { - boundHandlers, - chordHandlers: new Array<(input: TerminalLiveAccessoryInput) => boolean>(), - unbindCount: 0, - releasedCount: 0 + chordHandlers: new Array<(input: TerminalLiveAccessoryInput) => boolean>() } }) vi.mock('./terminal-text-field-submit-binding', () => ({ bindTerminalTextFieldSubmit: ( _node: unknown, - onSubmit: () => void, + _onSubmit: () => void, onChord: (input: TerminalLiveAccessoryInput) => boolean ) => { mocks.chordHandlers.push(onChord) - mocks.boundHandlers.push(onSubmit) - mocks.unbindCount += 1 - return () => { - mocks.releasedCount += 1 - } + return () => {} } })) @@ -59,31 +51,10 @@ const node = {} as TextInputInstance function reset(): void { submitted = [] mocks.chordHandlers.length = 0 - mocks.boundHandlers.length = 0 - mocks.unbindCount = 0 - mocks.releasedCount = 0 } describe('the terminal text field submit binding', () => { - it('calls the handler the newest commit supplied, not the first', () => { - reset() - let renderer: ReactTestRenderer | null = null - - act(() => { - renderer = create(createElement(Harness, { value: 'first' })) - }) - act(() => { - renderer?.update(createElement(Harness, { value: 'second' })) - }) - act(() => { - mocks.boundHandlers.at(-1)?.() - }) - - expect(submitted).toEqual(['second']) - act(() => renderer?.unmount()) - }) - - it('refreshes a keyboard chord handler after a terminal or connection change', () => { + it('refreshes a captured keyboard chord handler after a commit', () => { reset() let renderer: ReactTestRenderer | null = null act(() => { @@ -98,22 +69,4 @@ describe('the terminal text field submit binding', () => { expect(submitted).toEqual(['first', 'second']) act(() => renderer?.unmount()) }) - - it('drops the binding when the field unmounts', () => { - reset() - let renderer: ReactTestRenderer | null = null - - act(() => { - renderer = create(createElement(Harness, { value: 'first' })) - }) - act(() => { - mocks.boundHandlers.at(-1)?.() - }) - - expect(submitted).toEqual(['first']) - expect(field.current).toBe(node) - act(() => renderer?.unmount()) - expect(field.current).toBeNull() - expect(mocks.releasedCount).toBe(mocks.unbindCount) - }) }) diff --git a/src/cli/runtime/envelope-schema.test.ts b/src/cli/runtime/envelope-schema.test.ts index 75a00185cde..365f4c5de09 100644 --- a/src/cli/runtime/envelope-schema.test.ts +++ b/src/cli/runtime/envelope-schema.test.ts @@ -1,17 +1,7 @@ import { describe, expect, it } from 'vitest' -import { RuntimeRpcEnvelopeSchema, isKeepaliveFrame } from './envelope-schema' +import { RuntimeRpcEnvelopeSchema } from './envelope-schema' describe('RuntimeRpcEnvelopeSchema', () => { - it('accepts a well-formed success envelope', () => { - const parsed = RuntimeRpcEnvelopeSchema.safeParse({ - id: 'req-1', - ok: true, - result: { anything: 1 }, - _meta: { runtimeId: 'runtime-1' } - }) - expect(parsed.success).toBe(true) - }) - it('accepts and strips additive future success fields', () => { const parsed = RuntimeRpcEnvelopeSchema.safeParse({ id: 'req-1', @@ -32,33 +22,6 @@ describe('RuntimeRpcEnvelopeSchema', () => { }) }) - it('accepts a well-formed failure envelope', () => { - const parsed = RuntimeRpcEnvelopeSchema.safeParse({ - id: 'req-1', - ok: false, - error: { code: 'not_found', message: 'not_found' }, - _meta: { runtimeId: 'runtime-1' } - }) - expect(parsed.success).toBe(true) - }) - - it('accepts structured failure data for agent recovery hints', () => { - const parsed = RuntimeRpcEnvelopeSchema.safeParse({ - id: 'req-1', - ok: false, - error: { - code: 'app_not_found', - message: 'app not found: Gmail', - data: { - nextSteps: ['Target the desktop browser app/window that contains Gmail.'] - } - }, - _meta: { runtimeId: 'runtime-1' } - }) - - expect(parsed.success).toBe(true) - }) - it('accepts and strips additive future failure fields', () => { const parsed = RuntimeRpcEnvelopeSchema.safeParse({ id: 'req-1', @@ -83,82 +46,4 @@ describe('RuntimeRpcEnvelopeSchema', () => { } }) }) - - it('accepts a failure envelope without _meta', () => { - // Failures before runtimeId resolution legitimately omit _meta. - const parsed = RuntimeRpcEnvelopeSchema.safeParse({ - id: 'req-1', - ok: false, - error: { code: 'runtime_unavailable', message: 'runtime_unavailable' } - }) - expect(parsed.success).toBe(true) - }) - - it('rejects a frame missing the ok discriminator', () => { - const parsed = RuntimeRpcEnvelopeSchema.safeParse({ - id: 'req-1', - result: {}, - _meta: { runtimeId: 'runtime-1' } - }) - expect(parsed.success).toBe(false) - }) - - it('rejects a frame with a non-string id', () => { - const parsed = RuntimeRpcEnvelopeSchema.safeParse({ - id: 123, - ok: true, - result: {}, - _meta: { runtimeId: 'runtime-1' } - }) - expect(parsed.success).toBe(false) - }) - - it('still rejects malformed known fields when future fields are present', () => { - const parsed = RuntimeRpcEnvelopeSchema.safeParse({ - id: 'req-1', - ok: false, - error: { code: 500, message: 'Nope', futureErrorField: true }, - futureEnvelopeField: 'new' - }) - - expect(parsed.success).toBe(false) - }) - - it('rejects a frame with unrelated fields only', () => { - const parsed = RuntimeRpcEnvelopeSchema.safeParse({ hello: 'world' }) - expect(parsed.success).toBe(false) - }) - - it('accepts a keepalive frame', () => { - const parsed = RuntimeRpcEnvelopeSchema.safeParse({ _keepalive: true }) - expect(parsed.success).toBe(true) - }) - - it('rejects a keepalive frame with _keepalive !== true', () => { - const parsed = RuntimeRpcEnvelopeSchema.safeParse({ _keepalive: false }) - expect(parsed.success).toBe(false) - }) -}) - -describe('isKeepaliveFrame', () => { - it('returns true for a well-formed keepalive', () => { - expect(isKeepaliveFrame({ _keepalive: true })).toBe(true) - }) - - it('returns false for a success envelope', () => { - expect(isKeepaliveFrame({ id: 'x', ok: true, result: {}, _meta: { runtimeId: 'r' } })).toBe( - false - ) - }) - - it('returns false for non-object inputs', () => { - expect(isKeepaliveFrame(null)).toBe(false) - expect(isKeepaliveFrame(undefined)).toBe(false) - expect(isKeepaliveFrame('keepalive')).toBe(false) - }) - - it('returns false when _keepalive is not strictly true', () => { - expect(isKeepaliveFrame({ _keepalive: 1 })).toBe(false) - expect(isKeepaliveFrame({ _keepalive: 'true' })).toBe(false) - }) }) diff --git a/src/cli/runtime/profile-state-recovery-launch.test.ts b/src/cli/runtime/profile-state-recovery-launch.test.ts index 67ea0019a0e..c27b5b7bdcf 100644 --- a/src/cli/runtime/profile-state-recovery-launch.test.ts +++ b/src/cli/runtime/profile-state-recovery-launch.test.ts @@ -4,10 +4,7 @@ import { PROFILE_STATE_RECOVERY_FLAG, PROFILE_STATE_RECOVERY_RESULT_PREFIX } from '../../shared/profile-state-recovery-command' -import { - canLaunchProfileStateRecovery, - launchProfileStateRecovery -} from './profile-state-recovery-launch' +import { launchProfileStateRecovery } from './profile-state-recovery-launch' const mocks = vi.hoisted(() => ({ run: vi.fn() })) vi.mock('@orca/process-host', () => ({ runProcess: mocks.run })) @@ -47,17 +44,6 @@ beforeEach(() => { afterEach(() => vi.unstubAllEnvs()) describe('profile-state recovery launch', () => { - it('requires an app executable or Electron Node mode to launch recovery', () => { - vi.stubEnv('ELECTRON_RUN_AS_NODE', undefined) - vi.stubEnv('ORCA_APP_EXECUTABLE', undefined) - expect(canLaunchProfileStateRecovery()).toBe(false) - vi.stubEnv('ELECTRON_RUN_AS_NODE', '1') - expect(canLaunchProfileStateRecovery()).toBe(true) - vi.stubEnv('ELECTRON_RUN_AS_NODE', undefined) - vi.stubEnv('ORCA_APP_EXECUTABLE', '/explicit/Orca') - expect(canLaunchProfileStateRecovery()).toBe(true) - }) - it('uses a foreground-safe serve request and binds the canonical recovery root', async () => { vi.stubEnv('ELECTRON_RUN_AS_NODE', '1') vi.stubEnv('ORCA_USER_DATA_PATH', '/stale/root') @@ -81,21 +67,6 @@ describe('profile-state recovery launch', () => { expect(mocks.run.mock.calls[0][0].env).not.toHaveProperty('ELECTRON_RUN_AS_NODE') }) - it('round-trips current JSON selection without requiring an invented revision', async () => { - const current = { ...result, revision: null } - mocks.run.mockResolvedValue({ - code: 0, - signal: null, - timedOut: false, - stdout: `${PROFILE_STATE_RECOVERY_RESULT_PREFIX}${JSON.stringify({ ok: true, result: current })}\n`, - stderr: '' - }) - expect( - await launchProfileStateRecovery({ userDataPath: '.', selector: { kind: 'current-json' } }) - ).toEqual(current) - expect(mocks.run.mock.calls[0][0].args.at(-1)).toContain('"kind":"current-json"') - }) - it('preserves a structured refusal from the lock owner', async () => { mocks.run.mockResolvedValue({ code: 1, @@ -107,13 +78,6 @@ describe('profile-state recovery launch', () => { }) }) - it('round-trips latest JSON selection and the exported SQLite revision', async () => { - expect( - await launchProfileStateRecovery({ userDataPath: '.', selector: { kind: 'latest-json' } }) - ).toEqual(result) - expect(mocks.run.mock.calls[0][0].args.at(-1)).toContain('"kind":"latest-json"') - }) - it.each([ { code: 1 }, { signal: 'SIGKILL' }, @@ -121,41 +85,12 @@ describe('profile-state recovery launch', () => { { outputTruncated: true }, { stdout: '' }, { stdout: `${PROFILE_STATE_RECOVERY_RESULT_PREFIX}{` }, - { stdout: `${PROFILE_STATE_RECOVERY_RESULT_PREFIX}{"ok":true,"result":{}}` }, - { - stdout: `${PROFILE_STATE_RECOVERY_RESULT_PREFIX}{}\n${PROFILE_STATE_RECOVERY_RESULT_PREFIX}{}` - } - ])('rejects incomplete or ambiguous child results %j', async (override) => { + { stdout: `${PROFILE_STATE_RECOVERY_RESULT_PREFIX}{"ok":true,"result":{}}` } + ])('rejects incomplete child results %j', async (override) => { const original = await mocks.run() mocks.run.mockResolvedValue({ ...original, ...override }) await expect(launchProfileStateRecovery(request)).rejects.toMatchObject({ code: 'runtime_error' }) }) - - it('propagates launch failure without retrying another recovery path', async () => { - mocks.run.mockRejectedValue(new Error('Executable unavailable')) - await expect(launchProfileStateRecovery(request)).rejects.toThrow('Executable unavailable') - expect(mocks.run).toHaveBeenCalledOnce() - }) - - it('retains bounded child diagnostics when recovery exits without a result', async () => { - mocks.run.mockResolvedValue({ - code: null, - signal: 'SIGTRAP', - timedOut: false, - stdout: '', - stderr: `${'x'.repeat(5000)}\nsandbox unavailable\n` - }) - await expect(launchProfileStateRecovery(request)).rejects.toMatchObject({ - data: { - exitCode: null, - signal: 'SIGTRAP', - timedOut: false, - outputTruncated: false, - stderr: `${'x'.repeat(5000)}\nsandbox unavailable`.slice(-4096) - } - }) - expect(mocks.run).toHaveBeenCalledOnce() - }) }) diff --git a/src/cli/runtime/runtime-listener-probe.test.ts b/src/cli/runtime/runtime-listener-probe.test.ts index e0018e059c8..05ddf3ff7d1 100644 --- a/src/cli/runtime/runtime-listener-probe.test.ts +++ b/src/cli/runtime/runtime-listener-probe.test.ts @@ -109,30 +109,4 @@ describe('probeRuntimeListener', () => { vi.useRealTimers() } }) - - it.each([ - ['a missing endpoint', undefined], - ['a blank endpoint', ' '], - ['a numeric endpoint', 42] - ])('frees a profile whose metadata carries %s instead of dialling it', async (_label, value) => { - // Invalid endpoints can throw or dial an unrelated numeric TCP port. - const metadata = metadataFor(nextEndpoint()) - const transport = { ...metadata.transports[0]! } - Object.assign(transport, { endpoint: value }) - - await expect(probeRuntimeListener({ ...metadata, transports: [transport] })).resolves.toBe( - 'not-listening' - ) - }) - - it('frees a profile whose metadata published no local transport to probe', async () => { - const metadata = metadataFor(nextEndpoint()) - - await expect( - probeRuntimeListener({ - ...metadata, - transports: metadata.transports.filter((transport) => transport.kind === 'websocket') - }) - ).resolves.toBe('not-listening') - }) }) diff --git a/src/cli/runtime/runtime-source-fence.test.ts b/src/cli/runtime/runtime-source-fence.test.ts index 95667fa8c8e..e73676313b3 100644 --- a/src/cli/runtime/runtime-source-fence.test.ts +++ b/src/cli/runtime/runtime-source-fence.test.ts @@ -11,7 +11,6 @@ import { } from '../../shared/runtime-source-env' import { RuntimeClient } from './client' import { RuntimeClientError } from './types' -import { resolveFencedRuntimeSource } from './runtime-source-fence' const SOURCE_A = '11111111-1111-4111-8111-111111111111' const SOURCE_B = '22222222-2222-4222-8222-222222222222' @@ -126,27 +125,16 @@ describe('RuntimeClient runtime-source fence', () => { expect(runtime.methods).toEqual(['status.get', 'status.get']) }) - it('accepts a restarted runtime of the same source and probes it only once', async () => { + it('accepts a restarted runtime of the same source and carries its verified identity', async () => { const runtime = await startRuntime({ runtimeId: 'runtime-a2', installationId: SOURCE_A }) launchedBy(SOURCE_A, 'runtime-a1') const client = new RuntimeClient(runtime.userDataPath, 2_000, null, null, 'orca') await client.call('terminal.send', { terminal: 't1', text: 'hi' }) - await client.call('worktree.list') - expect(runtime.methods).toEqual(['status.get', 'terminal.send', 'worktree.list']) + expect(runtime.methods).toEqual(['status.get', 'terminal.send']) // The host must recheck identity if it changes after the probe. const expected = { sourceId: SOURCE_A, runtimeId: 'runtime-a2' } - expect(runtime.expectations).toEqual([undefined, expected, expected]) - }) - - it('sends without a probe when the launching runtime is still the live one', async () => { - const runtime = await startRuntime({ runtimeId: 'runtime-a', installationId: SOURCE_A }) - launchedBy(SOURCE_A, 'runtime-a') - const client = new RuntimeClient(runtime.userDataPath, 2_000, null, null, 'orca') - - await client.call('worktree.list') - expect(runtime.methods).toEqual(['worktree.list']) - expect(runtime.expectations).toEqual([{ sourceId: SOURCE_A, runtimeId: 'runtime-a' }]) + expect(runtime.expectations).toEqual([undefined, expected]) }) it('refuses when the live runtime cannot say which profile it serves', async () => { @@ -177,11 +165,4 @@ describe('RuntimeClient runtime-source fence', () => { await client.call('worktree.list') expect(runtime.methods).toEqual(['worktree.list']) }) - - it('still fences a terminal whose CLI fell back to the platform default profile', () => { - const stamp = { sourceId: SOURCE_A, incarnation: 'runtime-a', profilePath: '/profiles/custom' } - expect(resolveFencedRuntimeSource(stamp, '/profiles/default', '/profiles/default')).toBe(stamp) - expect(resolveFencedRuntimeSource(stamp, '/profiles/custom', '/profiles/default')).toBe(stamp) - expect(resolveFencedRuntimeSource(stamp, '/profiles/dev', '/profiles/default')).toBeNull() - }) }) diff --git a/src/cli/runtime/serve-appimage-update.test.ts b/src/cli/runtime/serve-appimage-update.test.ts index c9535d0c42a..90b44aa2424 100644 --- a/src/cli/runtime/serve-appimage-update.test.ts +++ b/src/cli/runtime/serve-appimage-update.test.ts @@ -68,14 +68,6 @@ function restartDeps(overrides: Partial = {}): AppImageRest } describe('supervised serve and a replaced AppImage', () => { - it('finds the AppImage a stable-launcher service was unpacked from', async () => { - const { appImagePath, executable } = await makeRunningGeneration() - const watch = resolveAppImageBuildWatch(executable, {}, 'linux') - expect(watch?.appImagePath).toBe(appImagePath) - expect(inspectAppImageBuild(watch!)).toBe('current') - expect(resolveAppImageBuildWatch(executable, {}, 'darwin')).toBeNull() - }) - it('ignores a source record that names another AppImage', async () => { const { root, executable } = await makeRunningGeneration() const namespacePath = join(executable, '..', '..') @@ -159,10 +151,9 @@ describe('supervised serve and a replaced AppImage', () => { const watch = resolveAppImageBuildWatch(executable, {}, 'linux')! await swapIn(appImagePath, 'build C') const attemptPath = join(root, 'attempt.json') - const logs: string[] = [] const make = (overrides: { force?: boolean; runningKey?: string } = {}) => createSupervisedAppImageUpdate({ - ...restartDeps({ log: (line) => logs.push(line) }), + ...restartDeps(), watch: { ...watch, runningKey: overrides.runningKey ?? watch.runningKey }, attemptPath, force: overrides.force ?? false @@ -172,13 +163,10 @@ describe('supervised serve and a replaced AppImage', () => { expect(first.inspect()).toBe('superseded') await first.restart(() => false) const candidateKey = resolveAppImageCacheKey(appImagePath) - expect(JSON.parse(await readFile(attemptPath, 'utf8'))).toEqual({ candidateKey }) // The new build died early; the service manager restarted the old one. const second = make() expect(second.inspect()).toBe('current') - expect(second.inspect()).toBe('current') - expect(logs.filter((line) => line.includes('did not come up'))).toHaveLength(1) expect(make({ force: true }).inspect()).toBe('superseded') await make().restart(() => false) diff --git a/src/cli/runtime/serve-bundle-park.test.ts b/src/cli/runtime/serve-bundle-park.test.ts index 29e8aa34dc1..ac17bf1b2c8 100644 --- a/src/cli/runtime/serve-bundle-park.test.ts +++ b/src/cli/runtime/serve-bundle-park.test.ts @@ -64,13 +64,6 @@ describe('openServeBundlePark', () => { await writeFile(parkRecordPath(dir), JSON.stringify(record)) } - it('takes part only under an installed launchd label on macOS', () => { - expect(open({})).toBeNull() - expect(open({ XPC_SERVICE_NAME: 'application.com.other' })).toBeNull() - expect(open({ XPC_SERVICE_NAME: LABEL }, 'linux')).toBeNull() - expect(open()).not.toBeNull() - }) - it('registers with its child pid and removes the registration on close', async () => { const park = open()! park.heartbeat(77) @@ -157,8 +150,6 @@ describe('openServeBundlePark', () => { it('holds while committed until the bundle changes, then releases', async () => { const park = open()! await writeRecord({ phase: 'commit', commitDeadlineAt: Date.now() + 60_000 }) - const bundleBefore = JSON.parse(await readFile(parkRecordPath(dir), 'utf8')).bundleBefore - expect(bundleBefore).toBe('unused') // 'unused' differs from the real identity, which models a bundle ShipIt already replaced. expect(park.holds()).toBe(false) const identity = formatBundleIdentity( diff --git a/src/cli/runtime/serve-electron-preflight.test.ts b/src/cli/runtime/serve-electron-preflight.test.ts index 4dcd0e1cfb9..51b2d1b21b3 100644 --- a/src/cli/runtime/serve-electron-preflight.test.ts +++ b/src/cli/runtime/serve-electron-preflight.test.ts @@ -167,16 +167,11 @@ describe.skipIf(process.platform === 'win32')('serve private admission snapshots writeFileSync(join(root, 'orca-runtime.json'), JSON.stringify(metadata)) return metadata } - it('keeps the missing-M0 default path synchronous without a second read', () => { - expect(preflightElectronServe({}, root, { platform: 'linux' })).toBeNull() - expect(metadataReader.tryReadMetadata).toHaveBeenCalledTimes(1) - }) it.each([2147483647, process.pid])( 'retains launch permission for absent listener even with PID %s', async (pid) => { publish(pid) await expect(preflightElectronServe({}, root, { platform: 'linux' })).resolves.toBeNull() - expect(metadataReader.tryReadMetadata).toHaveBeenCalledTimes(2) } ) it.each(['disappeared', 'replacement', 'incomplete'])( @@ -209,7 +204,6 @@ describe.skipIf(process.platform === 'win32')('serve private admission snapshots expect(String(vi.mocked(process.stderr.write).mock.calls.at(-1)?.[0])).toContain( change === 'replacement' ? `pid ${process.pid}` : 'pid 2147483647' ) - expect(metadataReader.tryReadMetadata).toHaveBeenCalledTimes(2) } ) }) diff --git a/src/cli/runtime/serve-install-hold.test.ts b/src/cli/runtime/serve-install-hold.test.ts deleted file mode 100644 index ad7a85848dc..00000000000 --- a/src/cli/runtime/serve-install-hold.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { mkdir, mkdtemp, 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 { readServeInstallEvidence } from './serve-install-hold' - -describe('readServeInstallEvidence', () => { - let root: string - let bundle: string - let executable: string - - beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-install-hold-')) - bundle = join(root, 'Orca.app') - executable = join(bundle, 'Contents', 'MacOS', 'Orca') - await mkdir(join(bundle, 'Contents', 'MacOS'), { recursive: true }) - await writeFile(executable, '') - }) - - afterEach(async () => { - await rm(root, { recursive: true, force: true }) - }) - - const shipIt = (b: string): string => - `${b}/Contents/Frameworks/Squirrel.framework/Resources/ShipIt com.stablyai.orca.ShipIt /state.plist` - - it('holds while ShipIt replaces this bundle, and when the check cannot run', async () => { - await expect( - readServeInstallEvidence(executable, { - platform: 'darwin', - listProcesses: async () => `/sbin/launchd\n${shipIt(bundle)}\n` - }) - ).resolves.toEqual({ kind: 'hold', reason: 'installer-running' }) - await expect( - readServeInstallEvidence(executable, { platform: 'darwin', listProcesses: async () => null }) - ).resolves.toEqual({ kind: 'hold', reason: 'installer-unknown' }) - }) - - it("ignores another bundle's ShipIt and an executable that merely contains the name", async () => { - await expect( - readServeInstallEvidence(executable, { - platform: 'darwin', - listProcesses: async () => `${shipIt('/Applications/Other.app')}\n${executable} --serve\n` - }) - ).resolves.toEqual({ kind: 'clear' }) - }) - - it('holds while the executable is missing, and skips the macOS check elsewhere', async () => { - await expect(readServeInstallEvidence(join(root, 'gone'))).resolves.toEqual({ - kind: 'hold', - reason: 'bundle-missing' - }) - await expect( - readServeInstallEvidence(executable, { - platform: 'linux', - listProcesses: async () => { - throw new Error('must not list processes off macOS') - } - }) - ).resolves.toEqual({ kind: 'clear' }) - }) -}) diff --git a/src/cli/runtime/serve-pid1-init-reaper.test.ts b/src/cli/runtime/serve-pid1-init-reaper.test.ts index f7d85f427d9..e453de42c3b 100644 --- a/src/cli/runtime/serve-pid1-init-reaper.test.ts +++ b/src/cli/runtime/serve-pid1-init-reaper.test.ts @@ -24,26 +24,6 @@ function deps(overrides: Partial = {}): ServeInitReaperDeps } describe('reexecServeUnderInitReaper', () => { - it('replaces a PID 1 Linux serve with bash running the same command and environment', () => { - const d = deps() - expect(reexecServeUnderInitReaper(d)).toBe('reexec') - expect(d.execve).toHaveBeenCalledWith( - '/bin/bash', - [ - 'bash', - '-c', - SERVE_INIT_REAPER_SCRIPT, - 'orca-serve-init', - '/opt/orca/orca-ide', - '/opt/orca/resources/cli/index.js', - 'serve', - '--port', - '6768' - ], - { ELECTRON_RUN_AS_NODE: '1' } - ) - }) - it('keeps the eval script of a `-e -- serve` launch', () => { const d = deps({ execArgv: ['-e', 'require(cli)'], argv: ['/opt/orca/orca-ide', 'serve'] }) expect(reexecServeUnderInitReaper(d)).toBe('reexec') @@ -87,29 +67,6 @@ describe('reexecServeUnderInitReaper', () => { expect(reexecServeUnderInitReaper(d)).toBe('unavailable') expect(d.execve).not.toHaveBeenCalled() }) - - it('leaves a serve that is not PID 1, or not on Linux, alone', () => { - for (const d of [deps({ pid: 4242 }), deps({ platform: 'darwin' })]) { - expect(reexecServeUnderInitReaper(d)).toBe('not-pid1') - expect(d.execve).not.toHaveBeenCalled() - expect(d.warn).not.toHaveBeenCalled() - } - }) - - it('warns and keeps serving when no bash is installed or the process cannot be replaced', () => { - for (const d of [ - deps({ exists: () => false }), - deps({ execve: null }), - deps({ - execve: () => { - throw new Error('ENOENT') - } - }) - ]) { - expect(reexecServeUnderInitReaper(d)).toBe('unavailable') - expect(d.warn).toHaveBeenCalledWith(expect.stringContaining('docker run --init')) - } - }) }) const bash = ['/bin/bash', '/usr/bin/bash'].find((path) => existsSync(path)) diff --git a/src/cli/runtime/serve-signal-exit-diagnostic.test.ts b/src/cli/runtime/serve-signal-exit-diagnostic.test.ts index cbb0bb24eb8..6868d97c0da 100644 --- a/src/cli/runtime/serve-signal-exit-diagnostic.test.ts +++ b/src/cli/runtime/serve-signal-exit-diagnostic.test.ts @@ -1,15 +1,13 @@ +import { MacServeStartupAbortError, serveSignalExitError } from './serve-signal-exit-diagnostic' import { EventEmitter } from 'node:events' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import { MacServeStartupAbortError, serveSignalExitError } from './serve-signal-exit-diagnostic' import { - SERVE_CHILD_FORCE_KILL_GRACE_MS, SERVE_CHILD_FORCE_KILL_SCHEDULING_MARGIN_MS, superviseForegroundServe } from './serve-update-supervisor' -import { RuntimeClientError } from './types' import { QUIT_RENDERER_ACK_TIMEOUT_MS, WILL_QUIT_TEARDOWN_DEADLINE_MS @@ -26,13 +24,6 @@ function setPlatform(platform: NodeJS.Platform): void { Object.defineProperty(process, 'platform', { configurable: true, value: platform }) } -function superviseUntilExit(code: number | null, signal: NodeJS.Signals | null): Promise { - const child = new FakeChildProcess() - const supervised = superviseChild(child) - child.emit('exit', code, signal) - return supervised -} - function superviseChild(child: FakeChildProcess): Promise { return superviseForegroundServe({ spec: { program: '/Applications/Orca.app/Contents/MacOS/Orca', args: ['--serve'] }, @@ -50,43 +41,9 @@ afterEach(() => { }) describe('serveSignalExitError', () => { - it('explains the Launch Services startup abort on darwin SIGABRT', () => { + it('classifies darwin SIGABRT as a startup abort', () => { const error = serveSignalExitError('SIGABRT', 'darwin') - expect(error).toBeInstanceOf(MacServeStartupAbortError) - expect(error).toBeInstanceOf(RuntimeClientError) - expect(error.code).toBe('runtime_serve_failed') - expect(error.message).toContain('aborted with SIGABRT on macOS') - expect(error.message).toContain('_RegisterApplication') - expect(error.data).toMatchObject({ - nextSteps: [ - expect.stringContaining('desktop Orca is already running'), - expect.stringContaining('refuse for 30 minutes'), - expect.stringContaining('~/Library/Logs/DiagnosticReports/Orca-*.ips') - ] - }) - }) - - it('does not claim the macOS cause off darwin', () => { - for (const platform of ['linux', 'win32'] as const) { - const error = serveSignalExitError('SIGABRT', platform) - - expect(error.message).toBe('Orca serve exited via SIGABRT.') - expect(error.data).toBeUndefined() - } - }) - - it('does not claim the macOS cause for other darwin signals', () => { - const error = serveSignalExitError('SIGKILL', 'darwin') - - expect(error.message).toBe('Orca serve exited via SIGKILL.') - expect(error.data).toBeUndefined() - }) - - it('stays clear when neither a code nor a signal is reported', () => { - expect(serveSignalExitError(null, 'darwin').message).toBe( - 'Orca serve exited without reporting an exit code or signal.' - ) }) }) @@ -97,13 +54,6 @@ describe('superviseForegroundServe signal exits', () => { const child = new FakeChildProcess() const supervised = superviseChild(child) - expect(SERVE_CHILD_FORCE_KILL_GRACE_MS).toBe( - QUIT_RENDERER_ACK_TIMEOUT_MS + - WILL_QUIT_TEARDOWN_DEADLINE_MS + - SERVE_CHILD_FORCE_KILL_SCHEDULING_MARGIN_MS - ) - expect(SERVE_CHILD_FORCE_KILL_GRACE_MS).toBeLessThanOrEqual(35_000) - process.emit('SIGTERM', 'SIGTERM') expect(child.kill).toHaveBeenCalledOnce() expect(child.kill).toHaveBeenLastCalledWith('SIGTERM') @@ -127,11 +77,9 @@ describe('superviseForegroundServe signal exits', () => { process.emit('SIGINT', 'SIGINT') expect(child.kill).not.toHaveBeenCalled() - expect(vi.getTimerCount()).toBe(1) child.emit('exit', 0, null) await expect(supervised).resolves.toBe(0) - expect(vi.getTimerCount()).toBe(0) }) it('forwards Linux terminal hangup and removes the listener after exit', async () => { @@ -193,29 +141,5 @@ describe('superviseForegroundServe signal exits', () => { await expect(supervised).resolves.toBe(1) expect(child.kill).not.toHaveBeenCalled() - expect(vi.getTimerCount()).toBe(0) - }) - - it('throws the macOS diagnostic when the child aborts on darwin', async () => { - setPlatform('darwin') - - await expect(superviseUntilExit(null, 'SIGABRT')).rejects.toThrow( - /aborted with SIGABRT on macOS/ - ) - }) - - it('reports the plain signal on linux', async () => { - setPlatform('linux') - - await expect(superviseUntilExit(null, 'SIGABRT')).rejects.toThrow( - 'Orca serve exited via SIGABRT.' - ) - }) - - it('returns numeric exit codes unchanged', async () => { - setPlatform('darwin') - - await expect(superviseUntilExit(0, null)).resolves.toBe(0) - await expect(superviseUntilExit(7, null)).resolves.toBe(7) }) }) diff --git a/src/cli/runtime/serve-supervised-service.test.ts b/src/cli/runtime/serve-supervised-service.test.ts index 195cb534bfe..f3b97dff825 100644 --- a/src/cli/runtime/serve-supervised-service.test.ts +++ b/src/cli/runtime/serve-supervised-service.test.ts @@ -3,14 +3,11 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { superviseServeService, type SupervisedServeOptions } from './serve-supervised-service' -import { readServeSupervisorBrake, SERVE_SUPERVISOR_FAILURE_LIMIT } from './serve-supervisor-brake' +import { readServeSupervisorBrake } from './serve-supervisor-brake' /** Virtual clock: timers fire in order as soon as the loop awaits them. */ function harness(overrides: Partial & { userDataPath: string }) { let clock = 1_000_000 - const sleeps: number[] = [] - const logs: string[] = [] - let stopListener: (() => void) | null = null const options: SupervisedServeOptions = { executable: '/opt/orca/orca', force: false, @@ -20,28 +17,16 @@ function harness(overrides: Partial & { userDataPath: st now: () => clock, random: () => 0.5, setTimer: (callback, ms) => { - sleeps.push(ms) clock += ms queueMicrotask(callback) return () => undefined }, - onStopSignal: (listener) => { - stopListener = listener - return () => { - stopListener = null - } - }, - log: (line) => logs.push(line), + onStopSignal: () => () => undefined, + log: () => undefined, ...overrides } return { - options, - sleeps, - logs, - stop: () => stopListener?.(), - advance: (ms: number) => { - clock += ms - } + options } } @@ -56,18 +41,9 @@ describe('superviseServeService', () => { await rm(userDataPath, { recursive: true, force: true }) }) - it('restarts a dying child with growing delays, then gives up and exits 0 durably', async () => { - let runs = 0 - const h = harness({ - userDataPath, - runOnce: async () => { - runs++ - return 1 - } - }) + it('persists give-up after repeated child failure and refuses another launch', async () => { + const h = harness({ userDataPath }) await expect(superviseServeService(h.options)).resolves.toBe(0) - expect(runs).toBe(SERVE_SUPERVISOR_FAILURE_LIMIT) - expect(h.sleeps).toEqual([1_000, 2_000, 4_000, 8_000, 16_000]) const record = readServeSupervisorBrake(userDataPath) expect(record?.gaveUp?.reason).toContain('exited with code 1') @@ -81,8 +57,5 @@ describe('superviseServeService', () => { }) await expect(superviseServeService(again.options)).resolves.toBe(0) expect(rerun).toBe(0) - expect(again.logs.join('\n')).toContain('--force') }) - - // #32177: a lock this machine cannot judge (a shared home) is a peer, not a fault to give up on. }) diff --git a/src/cli/runtime/serve-supervisor-relocation.test.ts b/src/cli/runtime/serve-supervisor-relocation.test.ts index c1d39a1ebfd..802db8ff75b 100644 --- a/src/cli/runtime/serve-supervisor-relocation.test.ts +++ b/src/cli/runtime/serve-supervisor-relocation.test.ts @@ -92,17 +92,6 @@ describe('relocateServeSupervisor', () => { expect(relocated.execve).not.toHaveBeenCalled() }) - it('stays in place outside a packaged app', async () => { - for (const d of [ - deps({ env: {} }), - deps({ env: { ELECTRON_RUN_AS_NODE: '1', ORCA_APP_EXECUTABLE: '/dev/Orca' } }), - deps({ appRoot: join(root, 'checkout') }) - ]) { - await expect(relocateServeSupervisor(d)).resolves.toBe('in-place') - expect(d.execve).not.toHaveBeenCalled() - } - }) - // Re-exec closes the AppImage keepalive pipe and unmounts libraries needed by the new image. it('stays in place when running from an AppImage, whose files cannot change under it', async () => { const d = deps({ @@ -235,14 +224,6 @@ describe('relocateServeSupervisor', () => { } const appCli = () => join(appRoot, 'out', 'cli', 'index.js') - it('re-runs plain serve with the same arguments on the replaced executable', () => { - const argv = ['/ignored', appCli(), 'serve', '--json'] - const { execve, execPath } = restart({ ELECTRON_RUN_AS_NODE: '1' }, argv) - expect(execve).toHaveBeenCalledWith(execPath, [execPath, ...argv.slice(1)], { - ELECTRON_RUN_AS_NODE: '1' - }) - }) - it('does not restart when the executable is the one it started from, as after a restart', () => { const argv = ['/ignored', appCli(), 'serve'] const installed = statSync(deps().execPath) @@ -265,14 +246,6 @@ describe('relocateServeSupervisor', () => { expect(log).toHaveBeenCalledWith(expect.stringContaining('not ready to run')) }) - it('logs instead of throwing on a runtime that cannot replace its own process', () => { - const execve = vi.fn(() => { - throw new Error('this runtime cannot replace its own process') - }) - expect(() => restart({}, ['/ignored', appCli(), 'serve'], { execve })).not.toThrow() - expect(log).toHaveBeenCalledWith(expect.stringContaining('cannot replace')) - }) - it('re-runs a relocated supervisor from the app, which copies the new release aside', async () => { const first = deps() await relocateServeSupervisor(first) diff --git a/src/cli/runtime/status.test.ts b/src/cli/runtime/status.test.ts index 86b9943e981..425a26d441b 100644 --- a/src/cli/runtime/status.test.ts +++ b/src/cli/runtime/status.test.ts @@ -4,9 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { getRuntimeMetadataPath } from '../../shared/runtime-bootstrap' -import type { RuntimeStatus } from '../../shared/runtime-types' import { RuntimeClient } from './client' -import { projectRemoteAppStatus } from './status' const servers = new Set>() const sockets = new Set() @@ -87,33 +85,30 @@ describe.skipIf(process.platform === 'win32')('CLI runtime status', () => { // A persisted host PID cannot establish liveness in the caller's namespace. describe.skipIf(process.platform === 'win32')('CLI status connection authority', () => { - it.each(['EPERM', 'ESRCH'])( - 'reports an unavailable endpoint without using a PID probe (%s)', - async (code) => { - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-status-probe-')) - writeFileSync( - getRuntimeMetadataPath(userDataPath), - JSON.stringify({ - runtimeId: 'runtime-unreachable', - pid: 424242, - transport: { kind: 'unix', endpoint: join(userDataPath, 'absent.sock') }, - authToken: 'token', - startedAt: 1 - }) - ) - const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => { - throw Object.assign(new Error(`kill ${code}`), { code }) + it('reports an unavailable endpoint without using a PID probe', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-status-probe-')) + writeFileSync( + getRuntimeMetadataPath(userDataPath), + JSON.stringify({ + runtimeId: 'runtime-unreachable', + pid: 424242, + transport: { kind: 'unix', endpoint: join(userDataPath, 'absent.sock') }, + authToken: 'token', + startedAt: 1 }) - try { - await expect(new RuntimeClient(userDataPath).getCliStatus()).rejects.toMatchObject({ - code: 'runtime_unavailable' - }) - expect(killSpy).not.toHaveBeenCalled() - } finally { - killSpy.mockRestore() - } + ) + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => { + throw new Error('unexpected PID probe') + }) + try { + await expect(new RuntimeClient(userDataPath).getCliStatus()).rejects.toMatchObject({ + code: 'runtime_unavailable' + }) + expect(killSpy).not.toHaveBeenCalled() + } finally { + killSpy.mockRestore() } - ) + }) it('reports a status timeout from a live listener without declaring its hidden PID dead', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-status-timeout-')) @@ -148,41 +143,3 @@ describe.skipIf(process.platform === 'win32')('CLI status connection authority', } }) }) - -describe('projectRemoteAppStatus', () => { - function remoteStatus(overrides: Partial = {}): RuntimeStatus { - return { - runtimeId: 'remote-runtime', - graphStatus: 'ready', - authoritativeWindowId: null, - ...overrides - } as RuntimeStatus - } - - it('reports the target app as running when a renderer owns the graph there', () => { - expect(projectRemoteAppStatus(remoteStatus({ desktopWindowStatus: 'available' }))).toEqual({ - running: true, - pid: null, - desktopWindowStatus: 'available' - }) - }) - - it.each(['openable', 'initializing', 'blocked'] as const)( - 'does not claim a running desktop for window status %s', - (desktopWindowStatus) => { - expect(projectRemoteAppStatus(remoteStatus({ desktopWindowStatus })).running).toBe(false) - } - ) - - it('reports not running when the target has no desktop window status', () => { - expect(projectRemoteAppStatus(remoteStatus())).toEqual({ running: false, pid: null }) - }) - - it('honors the authoritativeWindowId fallback for older runtimes', () => { - expect(projectRemoteAppStatus(remoteStatus({ authoritativeWindowId: 3 }))).toEqual({ - running: true, - pid: null, - desktopWindowStatus: 'available' - }) - }) -}) From a5854aec6e0330f6dd7b69f075e0e6f879e2037b Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 13 Nov 2026 05:43:27 -0800 Subject: [PATCH 25/55] test: remove low-value transport and shared suites (#42026) * test: remove low-value automation and shared suites * test: retire stale gate references and stabilize retained fixture date --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- config/flaky-test-pattern-baseline.txt | 2 - config/focus-setting-read-baseline.txt | 1 - config/reliability-gates.jsonc | 10 +- src/cli/runtime/transport-framing.test.ts | 70 +-- src/cli/runtime/websocket-transport.test.ts | 263 ------------ .../AutomationsPage.behavior.test.tsx | 4 - ...AutomationsPage.escape-precedence.spec.tsx | 72 ---- .../AutomationsPage.external-scope.spec.tsx | 69 --- ...AutomationsPage.refresh-selection.spec.tsx | 133 ------ .../automations-page-actions-test-harness.ts | 159 ------- ...tomations-page-child-props.test-fixture.ts | 65 --- .../automations-page-destination-fixtures.ts | 69 --- .../automations/automations-page-fixtures.ts | 198 --------- .../automations-page-listed-items.ts | 27 -- .../automations-page-runtime-fixtures.ts | 66 --- .../automations-page-test-harness.tsx | 399 ------------------ .../grok-inline-startup-pty-trace.ts | 124 ------ .../__fixtures__/grok-startup-pty-trace.ts | 163 ------- src/shared/bundled-skill-summary.test.ts | 45 -- src/shared/claimed-agent-pty-owner.test.ts | 130 ++---- src/shared/claude-model-list-probe.test.ts | 28 +- src/shared/codex-subagent-transcript.test.ts | 149 +------ .../command-code-output-retention.test.ts | 26 -- src/shared/command-code-output-status.test.ts | 75 +--- src/shared/crash-reporting.test.ts | 79 ---- .../darwin-process-table-capture.test.ts | 45 +- ...rwin-process-table-inventory-drain.test.ts | 47 +-- .../darwin-terminal-names-real-ps.test.ts | 17 - src/shared/darwin-terminal-names.test.ts | 114 ++--- ...te-ready-scanner-grok-trace-replay.test.ts | 58 --- .../dsb-native-one-shot-recognition.test.ts | 26 +- src/shared/dsh-real-cli.test.ts | 53 --- 32 files changed, 78 insertions(+), 2708 deletions(-) delete mode 100644 src/cli/runtime/websocket-transport.test.ts delete mode 100644 src/renderer/src/components/automations/AutomationsPage.behavior.test.tsx delete mode 100644 src/renderer/src/components/automations/AutomationsPage.escape-precedence.spec.tsx delete mode 100644 src/renderer/src/components/automations/AutomationsPage.external-scope.spec.tsx delete mode 100644 src/renderer/src/components/automations/AutomationsPage.refresh-selection.spec.tsx delete mode 100644 src/renderer/src/components/automations/automations-page-actions-test-harness.ts delete mode 100644 src/renderer/src/components/automations/automations-page-child-props.test-fixture.ts delete mode 100644 src/renderer/src/components/automations/automations-page-destination-fixtures.ts delete mode 100644 src/renderer/src/components/automations/automations-page-listed-items.ts delete mode 100644 src/renderer/src/components/automations/automations-page-runtime-fixtures.ts delete mode 100644 src/renderer/src/components/automations/automations-page-test-harness.tsx delete mode 100644 src/shared/__fixtures__/grok-inline-startup-pty-trace.ts delete mode 100644 src/shared/__fixtures__/grok-startup-pty-trace.ts delete mode 100644 src/shared/bundled-skill-summary.test.ts delete mode 100644 src/shared/command-code-output-retention.test.ts delete mode 100644 src/shared/draft-paste-ready-scanner-grok-trace-replay.test.ts delete mode 100644 src/shared/dsh-real-cli.test.ts diff --git a/config/flaky-test-pattern-baseline.txt b/config/flaky-test-pattern-baseline.txt index 85c8cc9f0c6..7f8164b1eb4 100644 --- a/config/flaky-test-pattern-baseline.txt +++ b/config/flaky-test-pattern-baseline.txt @@ -214,7 +214,6 @@ 1 test-no-real-time-wait:src/renderer/src/lib/agent-session/resume/cli-fork/native-chat-cli-fork-preparation.test.ts 2 test-no-real-time-wait:src/renderer/src/store/slices/workspace-cleanup/workspace-cleanup-removal-preflight.test.ts 2 test-no-real-time-wait:src/shared/__tests__/process-host/windows-tree-kill-late-taskkill.win32.test.ts -1 test-no-real-time-wait:src/shared/darwin-process-table-capture.test.ts 1 test-no-real-time-wait:src/shared/darwin-terminal-names-real-ps.test.ts 1 test-no-real-time-wait:src/shared/darwin-terminal-names.test.ts 1 test-no-real-time-wait:src/shared/git/status/git-branch-line-total-soft-deadline.test.ts @@ -628,7 +627,6 @@ 1 test-timeout-needs-reason:src/renderer/src/hooks/agent-hook-completion-notifications.test.ts 1 test-timeout-needs-reason:src/renderer/src/lib/source-control/checks/check-run-details-log-normalization-performance.test.ts 1 test-timeout-needs-reason:src/shared/__tests__/process-host/windows-tree-kill-late-taskkill.win32.test.ts -1 test-timeout-needs-reason:src/shared/dsh-real-cli.test.ts 1 test-timeout-needs-reason:src/shared/wsl-login-shell-command.test.ts 1 test-timeout-needs-reason:tests/e2e/active-view-restart-restore.spec.ts 1 test-timeout-needs-reason:tests/e2e/agent-send-picker-reveal.spec.ts diff --git a/config/focus-setting-read-baseline.txt b/config/focus-setting-read-baseline.txt index 491e47fa748..bef6f0b90de 100644 --- a/config/focus-setting-read-baseline.txt +++ b/config/focus-setting-read-baseline.txt @@ -20,7 +20,6 @@ 1 src/renderer/src/components/activity/use-agent-pane-threads.ts 4 src/renderer/src/components/agent/AgentSettingsDialog.tsx 1 src/renderer/src/components/automations/authoring/use-automations-page-destination-form.ts -1 src/renderer/src/components/automations/automations-page-fixtures.ts 1 src/renderer/src/components/automations/hosts/automation-host-client.ts 1 src/renderer/src/components/automations/use-automations-page-controller.ts 1 src/renderer/src/components/browser-pane/ReopenBrowserPageOnServerButton.tsx diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index c361edcc424..e976b64d092 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -3614,11 +3614,11 @@ "https://github.com/stablyai/orca/issues/9563" ], "invariant": "A safely promotable headless serve process is the single app owner. Desktop activation preserves its daemon-backed sessions. On macOS, a CLI-supervised serve update keeps the node-mode parent alive across ShipIt's atomic bundle swap, restarts with the original serve arguments only after the target bundle is present, and clears handoff state only after that target version reports runtime readiness. Once a supervised child exits or fails to start, no late handoff completion may signal it or arm force-kill escalation. Unsupported or failed handoffs leave the current serving owner intact or recover it once without an install retry loop.", - "oracle": "Audit4362 intentionally deletes the imported stale-waiter retry and manually assembled OSC9999 mobile-republish cases, plus the mocked missing-worktree cleanup suite (4 authored / 5 finite cases in three suites). Lost alarms include repeated unread worker_done pointers, working-to-waiting mobile status publication, missing-worktree filtering, and scoped daemon shutdown with unrelated duplicate identities. No replacement coverage or new platform proof is claimed. Audit3832 deliberately retires76 authored/79 finite cases:75 runtime compatibility cases/78 finite cases across14 fragments, plus one localization extractor default-flag case. Three whole runtime fragments are deleted;27 authored/30 finite cases remain across the reviewed sources. This gate retains only its own selected scope; no equivalent replacement or fresh runtime/native/remote evidence is claimed. Runtime component A: Audit3828 composes3822 with independent3824: deliberately retires49 authored/52 finite cases from seven compatibility fragments, leaving15 authored/18 finite cases and deleting two entire fragments. Accepted losses: positive folder-worker recovery/materialization and reveal deadline; persisted-key normalization and legacy split/group/focus projection; fixed stale CAS refusal; occupied/alias/restored/preallocated terminal-handle variants; fixed OpenCode/Cursor/Claude/Codex/Antigravity classification, readiness, title and management examples; direct private-map browser authority projections; browser-focus and OMP title projection; already-aborted claim cleanup. Additional3824 loss: src/main/runtime/orca-runtime-tests/terminal-handles-and-agent-status-part-02.spec.ts :: does not poll a wrapper foreground for a speculative CLI prompt send :: Speculative retryForegroundWrappers:false check must return false on a wrapper after one foreground read, without polling the supplied later codex response. These real bounded alarms are deliberately retired for low marginal value. Retained callbacks are unchanged and provide only their own bounded scope, not equivalent replacement. Historical receipts do not validate reduced callbacks. No fresh runtime/native/SSH/WSL/phone/browser evidence or full transitive closure is claimed. Runtime component B: Audit3834 deliberately retires26 authored/26 finite cases from seven fresh compatibility fragments, leaving10/10 and deleting the one-case saved-binding reattachment fragment. Original position58 is excluded: its existing substantive3578 KEEP7 review remains historical, with no fresh credit. Accepted losses: fixed hook/provider-session identities and stale metadata refusal; decorative/pre-title foreground ordering variants; saved-binding surface reattachment; saved-row exit ghost removal and silent activation selection; unsupported headless wake; positive pending materialization/group projections; per-caller navigation selection; positive SSH hydration/read partition; laptop mobile publication/buffer and background/pending/status projections; abnormal/unregistered SSH grace, normal-exit refusal, persisted SSH hydration, positive serve reattachment, pending-agent launch arguments and direct seeded running-launch admission guard. These are real bounded alarms deliberately retired for low marginal value. Retained cases preserve their own distinct deferred foreground coalescing, usable handle continuity, stale inventory refresh, concurrent browser snapshot preservation, renderer graph fencing, caller dedup isolation, SSH partition writes, runtime split lifetime, independent relay-ready publication, lease admission/state/expiry. The direct running-launch no-spawn guard alarm is deliberately retired; no remaining owning-launch refusal coverage is claimed. Historical runs do not validate reduced callbacks. No equivalent replacement, runtime/native/SSH/WSL/phone/browser result or full transitive closure is claimed. Additional extractor loss: config/scripts/localization/source-extraction/audit-localization-native-dialogs.test.mjs :: does not change the existing renderer scan by default :: Default options must omit the five native-only fields in this supplied snippet. No equivalent replacement is claimed. Historical prior account (preserved verbatim): Audit3814 deliberately retires60 authored/60 finite cases:53runtime compatibility cases,4native XCTest methods and3Python clipboard methods, including the entire StableElementIndexes suite. Clipboard cuts retire the one-over-cap sample, incoming-offer destruction trace and sequential20-copy replay; actual wire, FD/pipe, startup and Xvfb/GTK boundaries remain without equivalent replacement claims. Accepted losses include narrow terminal-send payload/provider projections, legacy and direct no-op/error permutations, timing/work-count examples, private state/title/activity projections and fixture-driven readiness examples, individually enumerated below. These include real bounded production alarms consciously retired for marginal value; retained failed writes, duplicate submission, stale capture, transcript/ring and allocation fences are not equivalent replacements. No fresh native/remote/browser execution or complete transitive closure is claimed. Earlier dated receipts and raw selectors remain historical accounts of prior callback sets. Audit3806 deliberately retires40 authored/40 finite runtime compatibility cases, including the terminal-side-effect-facts-part03 headless-snapshots-part02 and agent-status-and-waits-part03 fragments, plus20 scalar Swift precondition sites. Lost runtime alarms include status/title/attention projection examples, simple renderer/headless frame restoration and status waiting outcomes detailed in the accepted-loss ledger. Swift cuts retire duplicate generation-map/schema/navigation/bridge and notification-wrapper assertions; no standalone native program is deleted. These include real bounded production alarms consciously retired for marginal value. Retained races, pending writes/reflow, namespace and waiter ownership are not equivalent replacements. No fresh native/remote/browser execution or full transitive closure is claimed. Earlier dated receipts and raw selectors remain historical accounts of prior callback sets. Audit3798 deliberately retires21 authored/24 finite compatibility cases, including the entire SSH-worktree-lifecycle and repository-project-operations fragments. Accepted losses include navigation-only settings acknowledgement, browser session cleanup and host preservation projections, window positive/policy-state examples, terminal recovery/reload and no-incarnation remint examples, terminal listing compatibility/malformed IDs, directory browse/sort/Windows path smoke, SSH create refusal/manual lineage, recursive parent-folder creation and fork/origin/avatar/icon projections. Real bounded production alarms are consciously retired for marginal value; retained authority races, cleanup/clone ownership, pending waiters, nested-folder and scan invalidation cases are not equivalent replacements. No fresh native/remote/browser execution or full transitive closure is claimed. Earlier dated receipts and raw selectors remain historical accounts of prior callback sets. Audit3417 composes root-adopted3415v2/independent3416v2: intentionally removes117authored/173potential expanded registrations from12semantic sources, deleting4whole conventional suites and3registration modules and retaining4authored/5expanded cases, without equivalent replacement or fresh execution. The whole main-window attach-services suite is removed: runtime/window attach, notifier wiring, reload/teardown and permission-handler mock orchestration alarms are intentionally lost. Prune its exact deleted command selectors, testFiles entries and notifier assertionRef where present; complete prior selectors/ref are archived. Unrelated dated receipts remain exact and retain their original scope; no fresh reduced-selector, native-window or remote execution is claimed. Audit3403 composes root-adopted3393/3394,3400/3401 and3398/3399: intentionally retires167 authored registrations/196 potential expanded cases across93 whole suites without equivalent replacement or fresh execution. Retained65 registrations/79 potential cases include one conditional missing-input failure sentinel; potential counts are not executions. Historical notes and complete dated receipts retain their exact original scopes. The retained runtime registration driver loses three complete imported fragments: changed-working-label title facts and suppressed-frame tracked snapshots; refusing SSH GitLab MR-base lookup before local Git/glab calls; and missing folder-workspace path creation/update refusal before spawn/store mutation. These alarms are intentionally lost. Other imported fragments and separately rooted dated receipts are unchanged; they are not equivalent replacement coverage. Audit2252: The removed synthetic lock/gate/hydration fixture supplies no current promotion continuity oracle. Existing other selectors retain only their own independent scope; changed selector execution is pending. Historical prior account (preserved verbatim): Audit2133 current-parent preparation: Current reduced sources/selectors intentionally retire the alarms listed in knownGaps; prior dated runs and replacement claims do not validate the changed set. Historical prior account (preserved verbatim): Audit2095 current-parent preparation2126: Current reduced sources/selectors intentionally retire the alarms listed in knownGaps; prior dated runs and replacement claims do not validate the changed set. Historical prior account (preserved verbatim): Audit2090 (sources2088/2089): Current reduced suites intentionally retire the alarms listed in knownGaps; prior dated execution and replacement claims do not validate changed source bytes or selectors. Historical prior account (preserved verbatim): Audit2086 (sources2082/2083): Current reduced suites intentionally retire the alarms listed in knownGaps; prior dated execution and replacement claims do not validate changed source bytes or selectors. Historical prior account (preserved verbatim): Audit2076 (sources2073/2074): Intentionally retire these low-value alarms without replacement: src/main/runtime/orca-runtime-tests/terminal-handles-part-02.spec.ts: recovers a moved SSH pane through the tab it now sits in, not the one its lease froze; reports a plain expired orphan; does not report a pane whose only recent lease was superseded; does not report a pane whose only recent lease had its relay id recycled; picks the eligible orphan over a superseded predecessor that matches first; collects the same tabs the per-tab reader reports, in one sweep of the leases; reports no candidate when no lease names any of the worktree tabs. src/main/runtime/orca-runtime-tests/runtime-availability.spec.ts: starts unavailable with no authoritative window; reports the configured Windows terminal shell on status; reports floating workspace availability from settings on status; polls floating tabs with targeted PTY liveness and no repo/provider inventory; hydrates persisted tabs when the store cannot report repos; advertises browser screencast only when a renderer window is available; routes mobile Codex reset consumption through the account mutation coordinator; maps a definite pre-provider rejection into an authoritative current snapshot. Existing unrelated evidenceRuns retain their original scope; unchanged dated receipt objects do not validate changed source bytes or reduced selectors. No fresh gate execution or historical import-closure equivalence is claimed. Frozen current literal-import graph reaches these changed test bytes; unresolved local and computed edges remain debt, and current reachability is not historical execution or equivalent replacement evidence. Historical prior account (preserved verbatim): Audit2020: Retire CLI supervisor outcome, local-open promotion and paired-open projection alarms; launch retains only the two referenced stream-framing cases. Remaining CLI refusal/timeout cases do not replace headless-promotion coverage. Preserve both independent dated receipts at their original commands. Reduced selectors have no fresh result or equivalent replacement claim. Historical prior account (preserved verbatim): Existing exact unchanged-selector receipts remain the only current execution evidence. Changed source selectors have no fresh execution result. Historical prior-source oracle account: Unit tests coalesce early activation, preserve daemon and SSH identity, and reproduce the update race with a staged target, old serving child, persistent CLI parent, atomic .app replacement, and replacement readiness message. They assert the parent does not exit for launchd to respawn the old app, the native updater does not launch an interactive GUI, the replacement version is verified before handoff completion, mismatches become durable failures without retries, and unsupported/preflight-failed installs do not invoke native quit or PTY cleanup. Force handoff completion to fail after the replacement child exits and require zero later child signals and zero escalation timers. A joined lock-owner/activation/hydration contract asserts that a forced relaunch opens exactly one window and that the renderer promoted inside the serve process launches zero agent resumes, creates no replacement tab or startup command, and leaves every surviving session record untouched. The Electron journey independently verifies headless promotion retains owner/runtime/daemon/PTY identity and terminal I/O.", + "oracle": "Audit4362 intentionally deletes the imported stale-waiter retry and manually assembled OSC9999 mobile-republish cases, plus the mocked missing-worktree cleanup suite (4 authored / 5 finite cases in three suites). Lost alarms include repeated unread worker_done pointers, working-to-waiting mobile status publication, missing-worktree filtering, and scoped daemon shutdown with unrelated duplicate identities. No replacement coverage or new platform proof is claimed. Audit3832 deliberately retires76 authored/79 finite cases:75 runtime compatibility cases/78 finite cases across14 fragments, plus one localization extractor default-flag case. Three whole runtime fragments are deleted;27 authored/30 finite cases remain across the reviewed sources. This gate retains only its own selected scope; no equivalent replacement or fresh runtime/native/remote evidence is claimed. Runtime component A: Audit3828 composes3822 with independent3824: deliberately retires49 authored/52 finite cases from seven compatibility fragments, leaving15 authored/18 finite cases and deleting two entire fragments. Accepted losses: positive folder-worker recovery/materialization and reveal deadline; persisted-key normalization and legacy split/group/focus projection; fixed stale CAS refusal; occupied/alias/restored/preallocated terminal-handle variants; fixed OpenCode/Cursor/Claude/Codex/Antigravity classification, readiness, title and management examples; direct private-map browser authority projections; browser-focus and OMP title projection; already-aborted claim cleanup. Additional3824 loss: src/main/runtime/orca-runtime-tests/terminal-handles-and-agent-status-part-02.spec.ts :: does not poll a wrapper foreground for a speculative CLI prompt send :: Speculative retryForegroundWrappers:false check must return false on a wrapper after one foreground read, without polling the supplied later codex response. These real bounded alarms are deliberately retired for low marginal value. Retained callbacks are unchanged and provide only their own bounded scope, not equivalent replacement. Historical receipts do not validate reduced callbacks. No fresh runtime/native/SSH/WSL/phone/browser evidence or full transitive closure is claimed. Runtime component B: Audit3834 deliberately retires26 authored/26 finite cases from seven fresh compatibility fragments, leaving10/10 and deleting the one-case saved-binding reattachment fragment. Original position58 is excluded: its existing substantive3578 KEEP7 review remains historical, with no fresh credit. Accepted losses: fixed hook/provider-session identities and stale metadata refusal; decorative/pre-title foreground ordering variants; saved-binding surface reattachment; saved-row exit ghost removal and silent activation selection; unsupported headless wake; positive pending materialization/group projections; per-caller navigation selection; positive SSH hydration/read partition; laptop mobile publication/buffer and background/pending/status projections; abnormal/unregistered SSH grace, normal-exit refusal, persisted SSH hydration, positive serve reattachment, pending-agent launch arguments and direct seeded running-launch admission guard. These are real bounded alarms deliberately retired for low marginal value. Retained cases preserve their own distinct deferred foreground coalescing, usable handle continuity, stale inventory refresh, concurrent browser snapshot preservation, renderer graph fencing, caller dedup isolation, SSH partition writes, runtime split lifetime, independent relay-ready publication, lease admission/state/expiry. The direct running-launch no-spawn guard alarm is deliberately retired; no remaining owning-launch refusal coverage is claimed. Historical runs do not validate reduced callbacks. No equivalent replacement, runtime/native/SSH/WSL/phone/browser result or full transitive closure is claimed. Additional extractor loss: config/scripts/localization/source-extraction/audit-localization-native-dialogs.test.mjs :: does not change the existing renderer scan by default :: Default options must omit the five native-only fields in this supplied snippet. No equivalent replacement is claimed. Historical prior account (preserved verbatim): Audit3814 deliberately retires60 authored/60 finite cases:53runtime compatibility cases,4native XCTest methods and3Python clipboard methods, including the entire StableElementIndexes suite. Clipboard cuts retire the one-over-cap sample, incoming-offer destruction trace and sequential20-copy replay; actual wire, FD/pipe, startup and Xvfb/GTK boundaries remain without equivalent replacement claims. Accepted losses include narrow terminal-send payload/provider projections, legacy and direct no-op/error permutations, timing/work-count examples, private state/title/activity projections and fixture-driven readiness examples, individually enumerated below. These include real bounded production alarms consciously retired for marginal value; retained failed writes, duplicate submission, stale capture, transcript/ring and allocation fences are not equivalent replacements. No fresh native/remote/browser execution or complete transitive closure is claimed. Earlier dated receipts and raw selectors remain historical accounts of prior callback sets. Audit3806 deliberately retires40 authored/40 finite runtime compatibility cases, including the terminal-side-effect-facts-part03 headless-snapshots-part02 and agent-status-and-waits-part03 fragments, plus20 scalar Swift precondition sites. Lost runtime alarms include status/title/attention projection examples, simple renderer/headless frame restoration and status waiting outcomes detailed in the accepted-loss ledger. Swift cuts retire duplicate generation-map/schema/navigation/bridge and notification-wrapper assertions; no standalone native program is deleted. These include real bounded production alarms consciously retired for marginal value. Retained races, pending writes/reflow, namespace and waiter ownership are not equivalent replacements. No fresh native/remote/browser execution or full transitive closure is claimed. Earlier dated receipts and raw selectors remain historical accounts of prior callback sets. Audit3798 deliberately retires21 authored/24 finite compatibility cases, including the entire SSH-worktree-lifecycle and repository-project-operations fragments. Accepted losses include navigation-only settings acknowledgement, browser session cleanup and host preservation projections, window positive/policy-state examples, terminal recovery/reload and no-incarnation remint examples, terminal listing compatibility/malformed IDs, directory browse/sort/Windows path smoke, SSH create refusal/manual lineage, recursive parent-folder creation and fork/origin/avatar/icon projections. Real bounded production alarms are consciously retired for marginal value; retained authority races, cleanup/clone ownership, pending waiters, nested-folder and scan invalidation cases are not equivalent replacements. No fresh native/remote/browser execution or full transitive closure is claimed. Earlier dated receipts and raw selectors remain historical accounts of prior callback sets. Audit3417 composes root-adopted3415v2/independent3416v2: intentionally removes117authored/173potential expanded registrations from12semantic sources, deleting4whole conventional suites and3registration modules and retaining4authored/5expanded cases, without equivalent replacement or fresh execution. The whole main-window attach-services suite is removed: runtime/window attach, notifier wiring, reload/teardown and permission-handler mock orchestration alarms are intentionally lost. Prune its exact deleted command selectors, testFiles entries and notifier assertionRef where present; complete prior selectors/ref are archived. Unrelated dated receipts remain exact and retain their original scope; no fresh reduced-selector, native-window or remote execution is claimed. Audit3403 composes root-adopted3393/3394,3400/3401 and3398/3399: intentionally retires167 authored registrations/196 potential expanded cases across93 whole suites without equivalent replacement or fresh execution. Retained65 registrations/79 potential cases include one conditional missing-input failure sentinel; potential counts are not executions. Historical notes and complete dated receipts retain their exact original scopes. The retained runtime registration driver loses three complete imported fragments: changed-working-label title facts and suppressed-frame tracked snapshots; refusing SSH GitLab MR-base lookup before local Git/glab calls; and missing folder-workspace path creation/update refusal before spawn/store mutation. These alarms are intentionally lost. Other imported fragments and separately rooted dated receipts are unchanged; they are not equivalent replacement coverage. Audit2252: The removed synthetic lock/gate/hydration fixture supplies no current promotion continuity oracle. Existing other selectors retain only their own independent scope; changed selector execution is pending. Historical prior account (preserved verbatim): Audit2133 current-parent preparation: Current reduced sources/selectors intentionally retire the alarms listed in knownGaps; prior dated runs and replacement claims do not validate the changed set. Historical prior account (preserved verbatim): Audit2095 current-parent preparation2126: Current reduced sources/selectors intentionally retire the alarms listed in knownGaps; prior dated runs and replacement claims do not validate the changed set. Historical prior account (preserved verbatim): Audit2090 (sources2088/2089): Current reduced suites intentionally retire the alarms listed in knownGaps; prior dated execution and replacement claims do not validate changed source bytes or selectors. Historical prior account (preserved verbatim): Audit2086 (sources2082/2083): Current reduced suites intentionally retire the alarms listed in knownGaps; prior dated execution and replacement claims do not validate changed source bytes or selectors. Historical prior account (preserved verbatim): Audit2076 (sources2073/2074): Intentionally retire these low-value alarms without replacement: src/main/runtime/orca-runtime-tests/terminal-handles-part-02.spec.ts: recovers a moved SSH pane through the tab it now sits in, not the one its lease froze; reports a plain expired orphan; does not report a pane whose only recent lease was superseded; does not report a pane whose only recent lease had its relay id recycled; picks the eligible orphan over a superseded predecessor that matches first; collects the same tabs the per-tab reader reports, in one sweep of the leases; reports no candidate when no lease names any of the worktree tabs. src/main/runtime/orca-runtime-tests/runtime-availability.spec.ts: starts unavailable with no authoritative window; reports the configured Windows terminal shell on status; reports floating workspace availability from settings on status; polls floating tabs with targeted PTY liveness and no repo/provider inventory; hydrates persisted tabs when the store cannot report repos; advertises browser screencast only when a renderer window is available; routes mobile Codex reset consumption through the account mutation coordinator; maps a definite pre-provider rejection into an authoritative current snapshot. Existing unrelated evidenceRuns retain their original scope; unchanged dated receipt objects do not validate changed source bytes or reduced selectors. No fresh gate execution or historical import-closure equivalence is claimed. Frozen current literal-import graph reaches these changed test bytes; unresolved local and computed edges remain debt, and current reachability is not historical execution or equivalent replacement evidence. Historical prior account (preserved verbatim): Audit2020: Retire CLI supervisor outcome, local-open promotion and paired-open projection alarms; launch retains only the two referenced stream-framing cases. Remaining CLI refusal/timeout cases do not replace headless-promotion coverage. Preserve both independent dated receipts at their original commands. Reduced selectors have no fresh result or equivalent replacement claim. Historical prior account (preserved verbatim): Existing exact unchanged-selector receipts remain the only current execution evidence. Changed source selectors have no fresh execution result. Historical prior-source oracle account: Unit tests coalesce early activation, preserve daemon and SSH identity, and reproduce the update race with a staged target, old serving child, persistent CLI parent, atomic .app replacement, and replacement readiness message. They assert the parent does not exit for launchd to respawn the old app, the native updater does not launch an interactive GUI, the replacement version is verified before handoff completion, mismatches become durable failures without retries, and unsupported/preflight-failed installs do not invoke native quit or PTY cleanup. Force handoff completion to fail after the replacement child exits and require zero later child signals and zero escalation timers. A joined lock-owner/activation/hydration contract asserts that a forced relaunch opens exactly one window and that the renderer promoted inside the serve process launches zero agent resumes, creates no replacement tab or startup command, and leaves every surviving session record untouched. The Electron journey independently verifies headless promotion retains owner/runtime/daemon/PTY identity and terminal I/O. Audit4587 retires the one-case CLI WebSocket protocol/preflight suite and its hand-built server; its cross-layer non-delivery witness is intentionally lost, with no replacement coverage claimed.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/cli/runtime/launch.test.ts src/main/updater/serve-update-handoff.test.ts src/main/updater/updater.headless-serve-install.test.ts src/main/updater/mac-install/updater.mac-install.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/cli/runtime/serve-signal-exit-diagnostic.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/main/startup/serve-launch/serve-desktop-activation.test.ts src/cli/runtime/client.test.ts src/cli/runtime/websocket-transport.test.ts src/main/runtime/construction/orca-runtime.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/startup/serve-launch/serve-desktop-activation.test.ts src/cli/runtime/client.test.ts src/main/runtime/construction/orca-runtime.test.ts", "pnpm exec electron-vite build --mode e2e", "pnpm run test:e2e -- tests/e2e/headless-serve-desktop-activation.spec.ts --workers=1" ], @@ -3629,7 +3629,6 @@ "src/cli/runtime/serve-signal-exit-diagnostic.test.ts", "src/main/startup/serve-launch/serve-desktop-activation.test.ts", "src/cli/runtime/client.test.ts", - "src/cli/runtime/websocket-transport.test.ts", "src/main/runtime/construction/orca-runtime.test.ts", "tests/e2e/headless-serve-desktop-activation.spec.ts" ], @@ -6394,9 +6393,9 @@ "https://github.com/stablyai/orca/issues/9229" ], "invariant": "Reconnect, replay, or lifecycle observations from a viewer that negotiated explicit close intent must never kill a live PTY. A capable reasonless close must keep and republish; a legacy paired viewer must retain current-main behavior because its intentional close and cleanup echo are wire-identical. Lifecycle close requires the exact observed publication, terminal, environment, and authoritative liveness, never signals a process, and leaves renderer-owned or partial-split retirement to its owner. Legacy daemon hello and warm reattachment remain non-destructive.", - "oracle": "Audit4362 intentionally deletes the imported stale-waiter retry and manually assembled OSC9999 mobile-republish cases, plus the mocked missing-worktree cleanup suite (4 authored / 5 finite cases in three suites). Lost alarms include repeated unread worker_done pointers, working-to-waiting mobile status publication, missing-worktree filtering, and scoped daemon shutdown with unrelated duplicate identities. No replacement coverage or new platform proof is claimed. Audit3881 intentionally removes the entire legacy E2EE channel and mobile socket-wiring test suites and the v2 additive runtime-capability refusal callback, without equivalent replacement. Those deleted suites leave current selectors and that v2 assertion alias is retired. Retained v2 replay/queue-counter cases do not establish the removed capability refusal. Reduced test bytes/selectors have no fresh execution in this static packet; independent dated daemon E2E receipts retain their complete original scope. Historical prior account (preserved verbatim): Audit3832 deliberately retires76 authored/79 finite cases:75 runtime compatibility cases/78 finite cases across14 fragments, plus one localization extractor default-flag case. Three whole runtime fragments are deleted;27 authored/30 finite cases remain across the reviewed sources. This gate retains only its own selected scope; no equivalent replacement or fresh runtime/native/remote evidence is claimed. Runtime component A: Audit3828 composes3822 with independent3824: deliberately retires49 authored/52 finite cases from seven compatibility fragments, leaving15 authored/18 finite cases and deleting two entire fragments. Accepted losses: positive folder-worker recovery/materialization and reveal deadline; persisted-key normalization and legacy split/group/focus projection; fixed stale CAS refusal; occupied/alias/restored/preallocated terminal-handle variants; fixed OpenCode/Cursor/Claude/Codex/Antigravity classification, readiness, title and management examples; direct private-map browser authority projections; browser-focus and OMP title projection; already-aborted claim cleanup. Additional3824 loss: src/main/runtime/orca-runtime-tests/terminal-handles-and-agent-status-part-02.spec.ts :: does not poll a wrapper foreground for a speculative CLI prompt send :: Speculative retryForegroundWrappers:false check must return false on a wrapper after one foreground read, without polling the supplied later codex response. These real bounded alarms are deliberately retired for low marginal value. Retained callbacks are unchanged and provide only their own bounded scope, not equivalent replacement. Historical receipts do not validate reduced callbacks. No fresh runtime/native/SSH/WSL/phone/browser evidence or full transitive closure is claimed. Runtime component B: Audit3834 deliberately retires26 authored/26 finite cases from seven fresh compatibility fragments, leaving10/10 and deleting the one-case saved-binding reattachment fragment. Original position58 is excluded: its existing substantive3578 KEEP7 review remains historical, with no fresh credit. Accepted losses: fixed hook/provider-session identities and stale metadata refusal; decorative/pre-title foreground ordering variants; saved-binding surface reattachment; saved-row exit ghost removal and silent activation selection; unsupported headless wake; positive pending materialization/group projections; per-caller navigation selection; positive SSH hydration/read partition; laptop mobile publication/buffer and background/pending/status projections; abnormal/unregistered SSH grace, normal-exit refusal, persisted SSH hydration, positive serve reattachment, pending-agent launch arguments and direct seeded running-launch admission guard. These are real bounded alarms deliberately retired for low marginal value. Retained cases preserve their own distinct deferred foreground coalescing, usable handle continuity, stale inventory refresh, concurrent browser snapshot preservation, renderer graph fencing, caller dedup isolation, SSH partition writes, runtime split lifetime, independent relay-ready publication, lease admission/state/expiry. The direct running-launch no-spawn guard alarm is deliberately retired; no remaining owning-launch refusal coverage is claimed. Historical runs do not validate reduced callbacks. No equivalent replacement, runtime/native/SSH/WSL/phone/browser result or full transitive closure is claimed. Additional extractor loss: config/scripts/localization/source-extraction/audit-localization-native-dialogs.test.mjs :: does not change the existing renderer scan by default :: Default options must omit the five native-only fields in this supplied snippet. No equivalent replacement is claimed. Historical prior account (preserved verbatim): Audit3814 deliberately retires60 authored/60 finite cases:53runtime compatibility cases,4native XCTest methods and3Python clipboard methods, including the entire StableElementIndexes suite. Clipboard cuts retire the one-over-cap sample, incoming-offer destruction trace and sequential20-copy replay; actual wire, FD/pipe, startup and Xvfb/GTK boundaries remain without equivalent replacement claims. Accepted losses include narrow terminal-send payload/provider projections, legacy and direct no-op/error permutations, timing/work-count examples, private state/title/activity projections and fixture-driven readiness examples, individually enumerated below. These include real bounded production alarms consciously retired for marginal value; retained failed writes, duplicate submission, stale capture, transcript/ring and allocation fences are not equivalent replacements. No fresh native/remote/browser execution or complete transitive closure is claimed. Earlier dated receipts and raw selectors remain historical accounts of prior callback sets. Audit3806 deliberately retires40 authored/40 finite runtime compatibility cases, including the terminal-side-effect-facts-part03 headless-snapshots-part02 and agent-status-and-waits-part03 fragments, plus20 scalar Swift precondition sites. Lost runtime alarms include status/title/attention projection examples, simple renderer/headless frame restoration and status waiting outcomes detailed in the accepted-loss ledger. Swift cuts retire duplicate generation-map/schema/navigation/bridge and notification-wrapper assertions; no standalone native program is deleted. These include real bounded production alarms consciously retired for marginal value. Retained races, pending writes/reflow, namespace and waiter ownership are not equivalent replacements. No fresh native/remote/browser execution or full transitive closure is claimed. Earlier dated receipts and raw selectors remain historical accounts of prior callback sets. Audit3798 deliberately retires21 authored/24 finite compatibility cases, including the entire SSH-worktree-lifecycle and repository-project-operations fragments. Accepted losses include navigation-only settings acknowledgement, browser session cleanup and host preservation projections, window positive/policy-state examples, terminal recovery/reload and no-incarnation remint examples, terminal listing compatibility/malformed IDs, directory browse/sort/Windows path smoke, SSH create refusal/manual lineage, recursive parent-folder creation and fork/origin/avatar/icon projections. Real bounded production alarms are consciously retired for marginal value; retained authority races, cleanup/clone ownership, pending waiters, nested-folder and scan invalidation cases are not equivalent replacements. No fresh native/remote/browser execution or full transitive closure is claimed. Earlier dated receipts and raw selectors remain historical accounts of prior callback sets. Audit3774 (independent peer expansion of3773): deliberately retire7authored/13potential expanded registrations across4whole suites: standalone truncated8-byte E2EE guard; open/empty readiness null fast-path; capability parser acceptance/atomic malformed-array bounds; phone/delegated-parent/desktop/in-process creator provenance projections. These are real guard/policy alarms accepted as low marginal value, not tautologies. No equivalent replacement, authenticated-refusal, observer delivery, persisted visibility, synchronous scheduling or fresh execution is claimed. Prior dated receipts retain their exact original scopes and do not validate the changed/reduced current selector. Independent daemon E2E receipts remain scoped to their own unchanged commands. Historical prior account (preserved verbatim): Audit3403 composes root-adopted3393/3394,3400/3401 and3398/3399: intentionally retires167 authored registrations/196 potential expanded cases across93 whole suites without equivalent replacement or fresh execution. Retained65 registrations/79 potential cases include one conditional missing-input failure sentinel; potential counts are not executions. Historical notes and complete dated receipts retain their exact original scopes. The retained runtime registration driver loses three complete imported fragments: changed-working-label title facts and suppressed-frame tracked snapshots; refusing SSH GitLab MR-base lookup before local Git/glab calls; and missing folder-workspace path creation/update refusal before spawn/store mutation. These alarms are intentionally lost. Other imported fragments and separately rooted dated receipts are unchanged; they are not equivalent replacement coverage. Audit2133 current-parent preparation: Current reduced sources/selectors intentionally retire the alarms listed in knownGaps; prior dated runs and replacement claims do not validate the changed set. Historical prior account (preserved verbatim): Audit2095 current-parent preparation2126: Current reduced sources/selectors intentionally retire the alarms listed in knownGaps; prior dated runs and replacement claims do not validate the changed set. Historical prior account (preserved verbatim): Audit2090 (sources2088/2089): Current reduced suites intentionally retire the alarms listed in knownGaps; prior dated execution and replacement claims do not validate changed source bytes or selectors. Historical prior account (preserved verbatim): Audit2086 (sources2082/2083): Current reduced suites intentionally retire the alarms listed in knownGaps; prior dated execution and replacement claims do not validate changed source bytes or selectors. Historical prior account (preserved verbatim): Audit2076 (sources2073/2074): Intentionally retire these low-value alarms without replacement: src/main/runtime/orca-runtime-tests/terminal-handles-part-02.spec.ts: recovers a moved SSH pane through the tab it now sits in, not the one its lease froze; reports a plain expired orphan; does not report a pane whose only recent lease was superseded; does not report a pane whose only recent lease had its relay id recycled; picks the eligible orphan over a superseded predecessor that matches first; collects the same tabs the per-tab reader reports, in one sweep of the leases; reports no candidate when no lease names any of the worktree tabs. src/main/runtime/orca-runtime-tests/runtime-availability.spec.ts: starts unavailable with no authoritative window; reports the configured Windows terminal shell on status; reports floating workspace availability from settings on status; polls floating tabs with targeted PTY liveness and no repo/provider inventory; hydrates persisted tabs when the store cannot report repos; advertises browser screencast only when a renderer window is available; routes mobile Codex reset consumption through the account mutation coordinator; maps a definite pre-provider rejection into an authoritative current snapshot. Existing unrelated evidenceRuns retain their original scope; unchanged dated receipt objects do not validate changed source bytes or reduced selectors. No fresh gate execution or historical import-closure equivalence is claimed. Frozen current literal-import graph reaches these changed test bytes; unresolved local and computed edges remain debt, and current reachability is not historical execution or equivalent replacement evidence. Historical prior account (preserved verbatim): Audit1983: Delete the reasonless-close scalar suite and optional-capability socket forwarding alias. Delete the mocked close-attribution topology field-inventory suite and its aliases/selectors; preserve independent dated receipts. Changed-source prior results do not validate reduced-source bytes. No fresh execution, equivalent replacement, red/green, measured budget, soak, provider/PTY, rendered, live remote/mobile or cross-platform result is claimed. Historical prior account (preserved verbatim): Audit1959 (sources 1957:11, 1957:35, 1957:36): The fixture-supplied repository roundtrip, isolated capability metadata readback, parser/default-timer/size-threshold/internal failure-counter examples and standalone handshake-only replay example are removed. Source11 retains real encrypted multi-client event ordering, reconnect snapshot and multiplex delivery under synthetic runtime state, and does not establish the broad handshake-to-reasonless-close refusal alias; retire that whole assertion object. Source36 capability metadata readback alias is retired. Source35 retains the actual v2 runtime-only metadata/downgrade refusal callback and captured mutating-trace fresh-nonce rejection; its mapped mobile-v2 refusal alias remains within modeled credential lookup scope. Both independent daemon E2E dated receipts remain byte-exact with their original commands and results. No historical passed receipt is narrowed or transferred to changed source bytes. No fresh execution, equivalent replacement, physical provider/PTY, live remote/mobile, rendered, cross-platform, soak or measured-budget result is claimed by this metadata packet. Historical prior account (preserved verbatim): Audit1939 (sources1934/1935): Retain actual parked exit/disposal/cancel/leaf progression using injected exit and close adapters. The retained last-watcher case checks exact lifecyclePtyId, but no retained callback establishes the whole existing replacement-or-sibling-incarnation alias. Retire that broad assertion object rather than transfer partial support. Capability admission, static fields and duplicate routing examples are intentionally lost; unchanged daemon E2E receipts remain scoped to their own commands. Preserve meaningful remaining selectors/assertions and unchanged dated receipts within their original scope. Reduced selectors have no fresh gate execution evidence in this metadata packet. No equivalent replacement, physical watcher/PTY/provider, live SSH/remote/mobile, rendered, cross-platform, soak or measured-budget result is claimed. Historical prior account (preserved verbatim): Existing exact unchanged-selector receipts remain the only current execution evidence. Changed source selectors have no fresh execution result. Historical prior-source oracle account: Start isolated v25 and v26 daemon generations with one capable-viewer PTY and one legacy-viewer PTY per generation in four target worktrees plus an unaddressed control PTY in a fifth worktree. Route them through the production desktop scanner, runtime, RPC dispatcher, renderer-close relay, and daemon router. First issue sequential reasonless closes from an authenticated capable connection and require refusal, snapshot republish, zero shutdown calls, exact process survival, and post-close I/O. Then issue the byte-identical requests without the negotiated capability and require current-main behavior: two ordered immediate shutdowns, session-killed events, and exact root/descendant death, while capable and control PTYs survive. An observer lists all targets before, between, and after while issuing zero closes. Unit contracts also require in-process reasonless refusal, legacy runtime/mobile compatibility, explicit user closes, encrypted client-auth advertisement, and old-server lifecycle calls never to fall back.", + "oracle": "Audit4362 intentionally deletes the imported stale-waiter retry and manually assembled OSC9999 mobile-republish cases, plus the mocked missing-worktree cleanup suite (4 authored / 5 finite cases in three suites). Lost alarms include repeated unread worker_done pointers, working-to-waiting mobile status publication, missing-worktree filtering, and scoped daemon shutdown with unrelated duplicate identities. No replacement coverage or new platform proof is claimed. Audit3881 intentionally removes the entire legacy E2EE channel and mobile socket-wiring test suites and the v2 additive runtime-capability refusal callback, without equivalent replacement. Those deleted suites leave current selectors and that v2 assertion alias is retired. Retained v2 replay/queue-counter cases do not establish the removed capability refusal. Reduced test bytes/selectors have no fresh execution in this static packet; independent dated daemon E2E receipts retain their complete original scope. Historical prior account (preserved verbatim): Audit3832 deliberately retires76 authored/79 finite cases:75 runtime compatibility cases/78 finite cases across14 fragments, plus one localization extractor default-flag case. Three whole runtime fragments are deleted;27 authored/30 finite cases remain across the reviewed sources. This gate retains only its own selected scope; no equivalent replacement or fresh runtime/native/remote evidence is claimed. Runtime component A: Audit3828 composes3822 with independent3824: deliberately retires49 authored/52 finite cases from seven compatibility fragments, leaving15 authored/18 finite cases and deleting two entire fragments. Accepted losses: positive folder-worker recovery/materialization and reveal deadline; persisted-key normalization and legacy split/group/focus projection; fixed stale CAS refusal; occupied/alias/restored/preallocated terminal-handle variants; fixed OpenCode/Cursor/Claude/Codex/Antigravity classification, readiness, title and management examples; direct private-map browser authority projections; browser-focus and OMP title projection; already-aborted claim cleanup. Additional3824 loss: src/main/runtime/orca-runtime-tests/terminal-handles-and-agent-status-part-02.spec.ts :: does not poll a wrapper foreground for a speculative CLI prompt send :: Speculative retryForegroundWrappers:false check must return false on a wrapper after one foreground read, without polling the supplied later codex response. These real bounded alarms are deliberately retired for low marginal value. Retained callbacks are unchanged and provide only their own bounded scope, not equivalent replacement. Historical receipts do not validate reduced callbacks. No fresh runtime/native/SSH/WSL/phone/browser evidence or full transitive closure is claimed. Runtime component B: Audit3834 deliberately retires26 authored/26 finite cases from seven fresh compatibility fragments, leaving10/10 and deleting the one-case saved-binding reattachment fragment. Original position58 is excluded: its existing substantive3578 KEEP7 review remains historical, with no fresh credit. Accepted losses: fixed hook/provider-session identities and stale metadata refusal; decorative/pre-title foreground ordering variants; saved-binding surface reattachment; saved-row exit ghost removal and silent activation selection; unsupported headless wake; positive pending materialization/group projections; per-caller navigation selection; positive SSH hydration/read partition; laptop mobile publication/buffer and background/pending/status projections; abnormal/unregistered SSH grace, normal-exit refusal, persisted SSH hydration, positive serve reattachment, pending-agent launch arguments and direct seeded running-launch admission guard. These are real bounded alarms deliberately retired for low marginal value. Retained cases preserve their own distinct deferred foreground coalescing, usable handle continuity, stale inventory refresh, concurrent browser snapshot preservation, renderer graph fencing, caller dedup isolation, SSH partition writes, runtime split lifetime, independent relay-ready publication, lease admission/state/expiry. The direct running-launch no-spawn guard alarm is deliberately retired; no remaining owning-launch refusal coverage is claimed. Historical runs do not validate reduced callbacks. No equivalent replacement, runtime/native/SSH/WSL/phone/browser result or full transitive closure is claimed. Additional extractor loss: config/scripts/localization/source-extraction/audit-localization-native-dialogs.test.mjs :: does not change the existing renderer scan by default :: Default options must omit the five native-only fields in this supplied snippet. No equivalent replacement is claimed. Historical prior account (preserved verbatim): Audit3814 deliberately retires60 authored/60 finite cases:53runtime compatibility cases,4native XCTest methods and3Python clipboard methods, including the entire StableElementIndexes suite. Clipboard cuts retire the one-over-cap sample, incoming-offer destruction trace and sequential20-copy replay; actual wire, FD/pipe, startup and Xvfb/GTK boundaries remain without equivalent replacement claims. Accepted losses include narrow terminal-send payload/provider projections, legacy and direct no-op/error permutations, timing/work-count examples, private state/title/activity projections and fixture-driven readiness examples, individually enumerated below. These include real bounded production alarms consciously retired for marginal value; retained failed writes, duplicate submission, stale capture, transcript/ring and allocation fences are not equivalent replacements. No fresh native/remote/browser execution or complete transitive closure is claimed. Earlier dated receipts and raw selectors remain historical accounts of prior callback sets. Audit3806 deliberately retires40 authored/40 finite runtime compatibility cases, including the terminal-side-effect-facts-part03 headless-snapshots-part02 and agent-status-and-waits-part03 fragments, plus20 scalar Swift precondition sites. Lost runtime alarms include status/title/attention projection examples, simple renderer/headless frame restoration and status waiting outcomes detailed in the accepted-loss ledger. Swift cuts retire duplicate generation-map/schema/navigation/bridge and notification-wrapper assertions; no standalone native program is deleted. These include real bounded production alarms consciously retired for marginal value. Retained races, pending writes/reflow, namespace and waiter ownership are not equivalent replacements. No fresh native/remote/browser execution or full transitive closure is claimed. Earlier dated receipts and raw selectors remain historical accounts of prior callback sets. Audit3798 deliberately retires21 authored/24 finite compatibility cases, including the entire SSH-worktree-lifecycle and repository-project-operations fragments. Accepted losses include navigation-only settings acknowledgement, browser session cleanup and host preservation projections, window positive/policy-state examples, terminal recovery/reload and no-incarnation remint examples, terminal listing compatibility/malformed IDs, directory browse/sort/Windows path smoke, SSH create refusal/manual lineage, recursive parent-folder creation and fork/origin/avatar/icon projections. Real bounded production alarms are consciously retired for marginal value; retained authority races, cleanup/clone ownership, pending waiters, nested-folder and scan invalidation cases are not equivalent replacements. No fresh native/remote/browser execution or full transitive closure is claimed. Earlier dated receipts and raw selectors remain historical accounts of prior callback sets. Audit3774 (independent peer expansion of3773): deliberately retire7authored/13potential expanded registrations across4whole suites: standalone truncated8-byte E2EE guard; open/empty readiness null fast-path; capability parser acceptance/atomic malformed-array bounds; phone/delegated-parent/desktop/in-process creator provenance projections. These are real guard/policy alarms accepted as low marginal value, not tautologies. No equivalent replacement, authenticated-refusal, observer delivery, persisted visibility, synchronous scheduling or fresh execution is claimed. Prior dated receipts retain their exact original scopes and do not validate the changed/reduced current selector. Independent daemon E2E receipts remain scoped to their own unchanged commands. Historical prior account (preserved verbatim): Audit3403 composes root-adopted3393/3394,3400/3401 and3398/3399: intentionally retires167 authored registrations/196 potential expanded cases across93 whole suites without equivalent replacement or fresh execution. Retained65 registrations/79 potential cases include one conditional missing-input failure sentinel; potential counts are not executions. Historical notes and complete dated receipts retain their exact original scopes. The retained runtime registration driver loses three complete imported fragments: changed-working-label title facts and suppressed-frame tracked snapshots; refusing SSH GitLab MR-base lookup before local Git/glab calls; and missing folder-workspace path creation/update refusal before spawn/store mutation. These alarms are intentionally lost. Other imported fragments and separately rooted dated receipts are unchanged; they are not equivalent replacement coverage. Audit2133 current-parent preparation: Current reduced sources/selectors intentionally retire the alarms listed in knownGaps; prior dated runs and replacement claims do not validate the changed set. Historical prior account (preserved verbatim): Audit2095 current-parent preparation2126: Current reduced sources/selectors intentionally retire the alarms listed in knownGaps; prior dated runs and replacement claims do not validate the changed set. Historical prior account (preserved verbatim): Audit2090 (sources2088/2089): Current reduced suites intentionally retire the alarms listed in knownGaps; prior dated execution and replacement claims do not validate changed source bytes or selectors. Historical prior account (preserved verbatim): Audit2086 (sources2082/2083): Current reduced suites intentionally retire the alarms listed in knownGaps; prior dated execution and replacement claims do not validate changed source bytes or selectors. Historical prior account (preserved verbatim): Audit2076 (sources2073/2074): Intentionally retire these low-value alarms without replacement: src/main/runtime/orca-runtime-tests/terminal-handles-part-02.spec.ts: recovers a moved SSH pane through the tab it now sits in, not the one its lease froze; reports a plain expired orphan; does not report a pane whose only recent lease was superseded; does not report a pane whose only recent lease had its relay id recycled; picks the eligible orphan over a superseded predecessor that matches first; collects the same tabs the per-tab reader reports, in one sweep of the leases; reports no candidate when no lease names any of the worktree tabs. src/main/runtime/orca-runtime-tests/runtime-availability.spec.ts: starts unavailable with no authoritative window; reports the configured Windows terminal shell on status; reports floating workspace availability from settings on status; polls floating tabs with targeted PTY liveness and no repo/provider inventory; hydrates persisted tabs when the store cannot report repos; advertises browser screencast only when a renderer window is available; routes mobile Codex reset consumption through the account mutation coordinator; maps a definite pre-provider rejection into an authoritative current snapshot. Existing unrelated evidenceRuns retain their original scope; unchanged dated receipt objects do not validate changed source bytes or reduced selectors. No fresh gate execution or historical import-closure equivalence is claimed. Frozen current literal-import graph reaches these changed test bytes; unresolved local and computed edges remain debt, and current reachability is not historical execution or equivalent replacement evidence. Historical prior account (preserved verbatim): Audit1983: Delete the reasonless-close scalar suite and optional-capability socket forwarding alias. Delete the mocked close-attribution topology field-inventory suite and its aliases/selectors; preserve independent dated receipts. Changed-source prior results do not validate reduced-source bytes. No fresh execution, equivalent replacement, red/green, measured budget, soak, provider/PTY, rendered, live remote/mobile or cross-platform result is claimed. Historical prior account (preserved verbatim): Audit1959 (sources 1957:11, 1957:35, 1957:36): The fixture-supplied repository roundtrip, isolated capability metadata readback, parser/default-timer/size-threshold/internal failure-counter examples and standalone handshake-only replay example are removed. Source11 retains real encrypted multi-client event ordering, reconnect snapshot and multiplex delivery under synthetic runtime state, and does not establish the broad handshake-to-reasonless-close refusal alias; retire that whole assertion object. Source36 capability metadata readback alias is retired. Source35 retains the actual v2 runtime-only metadata/downgrade refusal callback and captured mutating-trace fresh-nonce rejection; its mapped mobile-v2 refusal alias remains within modeled credential lookup scope. Both independent daemon E2E dated receipts remain byte-exact with their original commands and results. No historical passed receipt is narrowed or transferred to changed source bytes. No fresh execution, equivalent replacement, physical provider/PTY, live remote/mobile, rendered, cross-platform, soak or measured-budget result is claimed by this metadata packet. Historical prior account (preserved verbatim): Audit1939 (sources1934/1935): Retain actual parked exit/disposal/cancel/leaf progression using injected exit and close adapters. The retained last-watcher case checks exact lifecyclePtyId, but no retained callback establishes the whole existing replacement-or-sibling-incarnation alias. Retire that broad assertion object rather than transfer partial support. Capability admission, static fields and duplicate routing examples are intentionally lost; unchanged daemon E2E receipts remain scoped to their own commands. Preserve meaningful remaining selectors/assertions and unchanged dated receipts within their original scope. Reduced selectors have no fresh gate execution evidence in this metadata packet. No equivalent replacement, physical watcher/PTY/provider, live SSH/remote/mobile, rendered, cross-platform, soak or measured-budget result is claimed. Historical prior account (preserved verbatim): Existing exact unchanged-selector receipts remain the only current execution evidence. Changed source selectors have no fresh execution result. Historical prior-source oracle account: Start isolated v25 and v26 daemon generations with one capable-viewer PTY and one legacy-viewer PTY per generation in four target worktrees plus an unaddressed control PTY in a fifth worktree. Route them through the production desktop scanner, runtime, RPC dispatcher, renderer-close relay, and daemon router. First issue sequential reasonless closes from an authenticated capable connection and require refusal, snapshot republish, zero shutdown calls, exact process survival, and post-close I/O. Then issue the byte-identical requests without the negotiated capability and require current-main behavior: two ordered immediate shutdowns, session-killed events, and exact root/descendant death, while capable and control PTYs survive. An observer lists all targets before, between, and after while issuing zero closes. Unit contracts also require in-process reasonless refusal, legacy runtime/mobile compatibility, explicit user closes, encrypted client-auth advertisement, and old-server lifecycle calls never to fall back. Audit4587 retires the one-case CLI WebSocket protocol/preflight suite and its hand-built server; its cross-layer non-delivery witness is intentionally lost, with no replacement coverage claimed.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/construction/orca-runtime.test.ts src/main/runtime/rpc/mobile/e2ee/e2ee-channel-v2.test.ts src/shared/remote-runtime/remote-runtime-client.test.ts src/shared/remote-runtime/control/remote-runtime-shared-control-connection.test.ts src/cli/runtime/websocket-transport.test.ts src/renderer/src/web/web-runtime-client.test.ts src/renderer/src/components/terminal/terminal-close-incarnation.test.ts src/renderer/src/components/terminal-pane/parking/output-watchers/terminal-parked-tab-watchers.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/construction/orca-runtime.test.ts src/main/runtime/rpc/mobile/e2ee/e2ee-channel-v2.test.ts src/shared/remote-runtime/remote-runtime-client.test.ts src/shared/remote-runtime/control/remote-runtime-shared-control-connection.test.ts src/renderer/src/web/web-runtime-client.test.ts src/renderer/src/components/terminal/terminal-close-incarnation.test.ts src/renderer/src/components/terminal-pane/parking/output-watchers/terminal-parked-tab-watchers.test.ts", "pnpm exec playwright test tests/e2e/daemon-generation-reconnect-safety.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "pnpm exec playwright test tests/e2e/daemon-generation-legacy-close-safety.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" ], @@ -6405,7 +6404,6 @@ "src/main/runtime/rpc/mobile/e2ee/e2ee-channel-v2.test.ts", "src/shared/remote-runtime/remote-runtime-client.test.ts", "src/shared/remote-runtime/control/remote-runtime-shared-control-connection.test.ts", - "src/cli/runtime/websocket-transport.test.ts", "src/renderer/src/web/web-runtime-client.test.ts", "src/renderer/src/components/terminal/terminal-close-incarnation.test.ts", "src/renderer/src/components/terminal-pane/parking/output-watchers/terminal-parked-tab-watchers.test.ts", diff --git a/src/cli/runtime/transport-framing.test.ts b/src/cli/runtime/transport-framing.test.ts index 2c7bfb5ecf0..66fbcbfa177 100644 --- a/src/cli/runtime/transport-framing.test.ts +++ b/src/cli/runtime/transport-framing.test.ts @@ -18,10 +18,10 @@ const reply = (result: unknown) => `${JSON.stringify({ id: 'request-1', ok: true, result, _meta: { runtimeId: 'runtime-1' } })}\n` class TestSocket extends EventEmitter { - setEncoding = vi.fn() - write = vi.fn() - end = vi.fn() - destroy = vi.fn() + setEncoding = (): void => {} + write = (): void => {} + end = (): void => {} + destroy = (): void => {} } let socket: TestSocket @@ -30,50 +30,16 @@ beforeEach(() => { createConnection.mockReturnValue(socket) }) afterEach(() => { - vi.restoreAllMocks() vi.useRealTimers() }) describe('CLI runtime response framing', () => { - it.each([1, 7, 256, 4096])( - 'reads a fragmented response with %i-character chunks', - async (size) => { - const result = { data: '界😀'.repeat(10000) } - const encoded = reply(result) - const pending = sendRequest(metadata, 'terminal.read', {}, 30000) - for (let offset = 0; offset < encoded.length; offset += size) { - socket.emit('data', encoded.slice(offset, offset + size)) - } - await expect(pending).resolves.toMatchObject({ result }) - expect(socket.setEncoding).toHaveBeenCalledExactlyOnceWith('utf8') - expect(socket.end).toHaveBeenCalledOnce() - } - ) - - it('searches each fragment once without rescanning the accumulated reply', async () => { - const encoded = reply({ data: 'x'.repeat(1024 * 1024) }) + it('reads a response split across chunks without losing its payload', async () => { + const encoded = reply({ data: '界😀done' }) const pending = sendRequest(metadata, 'terminal.read', {}, 30000) - const originalIndexOf = String.prototype.indexOf - let searchedCharacters = 0 - const search = vi.spyOn(String.prototype, 'indexOf').mockImplementation(function ( - this: string, - value, - position - ) { - if (value === '\n') { - searchedCharacters += this.length - (position ?? 0) - } - return originalIndexOf.call(this, value, position) - }) - try { - for (let offset = 0; offset < encoded.length; offset += 256) { - socket.emit('data', encoded.slice(offset, offset + 256)) - } - } finally { - search.mockRestore() - } - await expect(pending).resolves.toMatchObject({ ok: true }) - expect(searchedCharacters).toBe(encoded.length) + socket.emit('data', encoded.slice(0, 7)) + socket.emit('data', encoded.slice(7)) + await expect(pending).resolves.toMatchObject({ result: { data: '界😀done' } }) }) it('refreshes keepalives across chunks and ignores blanks and data after the final frame', async () => { @@ -86,13 +52,10 @@ describe('CLI runtime response framing', () => { socket.emit('data', `${reply({ data: 'done' })}invalid JSON\n`) socket.emit('data', 'more ignored data') await expect(pending).resolves.toMatchObject({ result: { data: 'done' } }) - expect(socket.end).toHaveBeenCalledOnce() - expect(vi.getTimerCount()).toBe(0) }) it.each([ ['broken JSON\n', 'invalid_runtime_response'], - ['{}\n', 'invalid_runtime_response'], ['{"id":"other","ok":true,"result":{}}\n', 'invalid_runtime_response'], [ '{"id":"request-1","ok":true,"result":{},"_meta":{"runtimeId":"other"}}\n', @@ -105,29 +68,18 @@ describe('CLI runtime response framing', () => { socket.emit('data', line.slice(0, 2)) socket.emit('data', line.slice(2) + reply({ data: 'ignored' })) await expect(pending).rejects.toMatchObject({ code }) - expect(socket.end).toHaveBeenCalledOnce() } ) - it('preserves terminal failure envelopes', async () => { - const pending = sendRequest(metadata, 'terminal.read', {}, 30000) - socket.emit('data', '{"id":"request-1","ok":false,"error":{"code":"bad","message":"no"}}\n') - await expect(pending).resolves.toMatchObject({ - ok: false, - error: { code: 'bad', message: 'no' } - }) - }) - it('rejects close with an incomplete frame and does not parse later data', async () => { const pending = sendRequest(metadata, 'terminal.read', {}, 30000) socket.emit('data', '{"id":') socket.emit('close') socket.emit('data', reply({ data: 'ignored' })) await expect(pending).rejects.toMatchObject({ code: 'runtime_unavailable' }) - expect(socket.end).toHaveBeenCalledOnce() }) - it('destroys a timed out socket holding an incomplete frame', async () => { + it('rejects a timed out incomplete frame and ignores a late reply', async () => { vi.useFakeTimers() const pending = sendRequest(metadata, 'terminal.read', {}, 100) const rejected = expect(pending).rejects.toMatchObject({ code: 'runtime_timeout' }) @@ -135,7 +87,5 @@ describe('CLI runtime response framing', () => { await vi.advanceTimersByTimeAsync(100) socket.emit('data', reply({ data: 'ignored' })) await rejected - expect(socket.destroy).toHaveBeenCalledOnce() - expect(vi.getTimerCount()).toBe(0) }) }) diff --git a/src/cli/runtime/websocket-transport.test.ts b/src/cli/runtime/websocket-transport.test.ts deleted file mode 100644 index a0cfb912efa..00000000000 --- a/src/cli/runtime/websocket-transport.test.ts +++ /dev/null @@ -1,263 +0,0 @@ -import { createServer, type Server } from 'node:http' -import 'node:fs' -import 'node:os' -import 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { WebSocketServer } from 'ws' -import { encodePairingOffer } from '../../shared/remote-runtime/pairing/pairing' - -import type { RuntimeStatus } from '../../shared/runtime-types' -import { - decrypt, - deriveSharedKey, - encrypt, - generateKeyPair, - publicKeyToBase64 -} from '../../shared/remote-runtime/e2ee/node/e2ee-crypto' -import './websocket-transport' -import '../../shared/remote-runtime/requests/remote-runtime-prepared-request-admission' -import { RuntimeClient } from './client' -import '../workspaces/worktree-removal-request' -import { launchOrcaApp } from './launch' -import './environments' -import { - MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, - RUNTIME_PROTOCOL_VERSION -} from '../../shared/protocol-version' - -vi.mock('./launch', () => ({ - launchOrcaApp: vi.fn() -})) - -type TestRuntime = { - endpoint: string - publicKeyB64: string - deviceToken: string - authFrames: Record[] - requestMethods: string[] - requestParams: unknown[] - connectionCount: () => number - close: () => Promise -} - -describe('CLI remote WebSocket transport', () => { - const servers: TestRuntime[] = [] - - afterEach(async () => { - vi.mocked(launchOrcaApp).mockClear() - await Promise.all(servers.splice(0).map((server) => server.close())) - }) - - it('blocks remote RPCs when the server protocol is too old', async () => { - const runtime = await startTestRuntime('runtime-old', { runtimeProtocolVersion: 1 }) - servers.push(runtime) - - const client = new RuntimeClient( - '/tmp/unused', - 5_000, - encodePairingOffer({ - v: 2, - endpoint: runtime.endpoint, - deviceToken: runtime.deviceToken, - publicKeyB64: runtime.publicKeyB64 - }) - ) - - await expect(client.call('repo.list')).rejects.toMatchObject({ - code: 'incompatible_runtime', - message: expect.stringContaining('server is too old') - }) - expect(runtime.connectionCount()).toBe(1) - expect(runtime.authFrames).toHaveLength(1) - expect(runtime.requestMethods).toEqual(['status.get']) - }) -}) - -async function startTestRuntime( - runtimeId: string, - statusOverrides: { - runtimeProtocolVersion?: number - minCompatibleRuntimeClientVersion?: number - desktopWindowStatus?: 'available' | 'openable' | 'initializing' | 'blocked' - appVersion?: string - remoteUpdateSupport?: { - installMode: 'unsupported-headless-serve' - automatic: false - reason: 'manual-service-update-required' - } - capabilities?: string[] - malformed?: boolean - failure?: boolean - hold?: string - onRequest?: (method: string) => void - disconnect?: boolean - mutationDisconnect?: boolean - rejectAuth?: boolean - degradations?: RuntimeStatus['degradations'] - } = {} -): Promise { - const serverKeyPair = generateKeyPair() - const deviceToken = `token-${runtimeId}` - const httpServer = createServer() - const wss = new WebSocketServer({ server: httpServer }) - const authFrames: Record[] = [] - const requestMethods: string[] = [] - const requestParams: unknown[] = [] - let connectionCount = 0 - - wss.on('connection', (ws) => { - let sharedKey: Uint8Array | null = null - let authenticated = false - - ws.on('message', (data) => { - const frame = data.toString() - if (!sharedKey) { - const hello = JSON.parse(frame) as Record & { - type?: string - publicKeyB64?: string - } - if (!hello.publicKeyB64) { - // Legacy-only host: refuses a v2 hello the way hosts before v2 do. - ws.close(4001, 'Invalid e2ee_hello') - return - } - connectionCount += 1 - const clientPublicKey = Buffer.from(hello.publicKeyB64 ?? '', 'base64') - sharedKey = deriveSharedKey(serverKeyPair.secretKey, clientPublicKey) - ws.send(JSON.stringify({ type: 'e2ee_ready' })) - return - } - - const plaintext = decrypt(frame, sharedKey) - if (!plaintext) { - ws.close(4003, 'decrypt failed') - return - } - if (!authenticated) { - const auth = JSON.parse(plaintext) as Record & { - type?: string - deviceToken?: string - } - authFrames.push(auth) - if ( - auth.type !== 'e2ee_auth' || - auth.deviceToken !== deviceToken || - statusOverrides.rejectAuth - ) { - ws.send(encrypt(JSON.stringify({ type: 'e2ee_error' }), sharedKey)) - ws.close(4001, 'auth failed') - return - } - authenticated = true - ws.send(encrypt(JSON.stringify({ type: 'e2ee_authenticated' }), sharedKey)) - return - } - - const request: { id: string; method: string; params?: unknown } = JSON.parse(plaintext) - requestMethods.push(request.method) - requestParams.push(request.params) - statusOverrides.onRequest?.(request.method) - if (request.method === statusOverrides.hold) { - return - } - if (request.method === 'status.get' && statusOverrides.disconnect) { - ws.close() - return - } - if (request.method === 'worktree.rm' && statusOverrides.mutationDisconnect) { - ws.close() - return - } - const response = - request.method === 'status.get' && statusOverrides.failure - ? { - id: request.id, - ok: false, - error: { code: 'runtime_unavailable', message: 'status failed' }, - _meta: { runtimeId } - } - : request.method === 'worktree.rm' - ? { id: request.id, ok: true, result: { removed: true }, _meta: { runtimeId } } - : request.method === 'status.get' - ? { - id: request.id, - ok: true, - result: statusOverrides.malformed - ? { capabilities: 42 } - : { - runtimeId, - rendererGraphEpoch: 1, - graphStatus: 'ready', - authoritativeWindowId: null, - desktopWindowStatus: statusOverrides.desktopWindowStatus, - liveTabCount: 0, - liveLeafCount: 0, - runtimeProtocolVersion: - statusOverrides.runtimeProtocolVersion ?? RUNTIME_PROTOCOL_VERSION, - minCompatibleRuntimeClientVersion: - statusOverrides.minCompatibleRuntimeClientVersion ?? - MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, - appVersion: statusOverrides.appVersion, - remoteUpdateSupport: statusOverrides.remoteUpdateSupport, - capabilities: statusOverrides.capabilities, - degradations: statusOverrides.degradations - }, - _meta: { runtimeId } - } - : { - id: request.id, - ok: false, - error: { code: 'method_not_found', message: 'Unknown method' }, - _meta: { runtimeId } - } - ws.send(encrypt(JSON.stringify(response), sharedKey)) - }) - }) - - await listen(httpServer) - const address = httpServer.address() - if (!address || typeof address === 'string') { - throw new Error('Expected TCP test server') - } - - return { - endpoint: `ws://127.0.0.1:${address.port}`, - publicKeyB64: publicKeyToBase64(serverKeyPair.publicKey), - deviceToken, - authFrames, - requestMethods, - requestParams, - connectionCount: () => connectionCount, - close: async () => { - await new Promise((resolve) => { - wss.close(() => resolve()) - for (const client of wss.clients) { - client.close() - } - }) - await closeHttpServer(httpServer) - } - } -} - -async function listen(server: Server): Promise { - await new Promise((resolve, reject) => { - server.once('error', reject) - server.listen(0, '127.0.0.1', () => { - server.off('error', reject) - resolve() - }) - }) -} - -async function closeHttpServer(server: Server): Promise { - await new Promise((resolve, reject) => { - server.close((error) => { - if (error) { - reject(error) - return - } - resolve() - }) - }) -} diff --git a/src/renderer/src/components/automations/AutomationsPage.behavior.test.tsx b/src/renderer/src/components/automations/AutomationsPage.behavior.test.tsx deleted file mode 100644 index c5510a4f3e1..00000000000 --- a/src/renderer/src/components/automations/AutomationsPage.behavior.test.tsx +++ /dev/null @@ -1,4 +0,0 @@ -// @vitest-environment happy-dom -import './AutomationsPage.escape-precedence.spec' -import './AutomationsPage.external-scope.spec' -import './AutomationsPage.refresh-selection.spec' diff --git a/src/renderer/src/components/automations/AutomationsPage.escape-precedence.spec.tsx b/src/renderer/src/components/automations/AutomationsPage.escape-precedence.spec.tsx deleted file mode 100644 index 99171389359..00000000000 --- a/src/renderer/src/components/automations/AutomationsPage.escape-precedence.spec.tsx +++ /dev/null @@ -1,72 +0,0 @@ -// @vitest-environment happy-dom - -/** Window capture runs before Radix document capture; preventing Escape here would veto dismissal of an overlaid dialog (STA-5207). */ - -import { describe, expect, it, vi } from 'vitest' -import { - installAutomationsPageHarness, - mocks, - renderPage, - settleHostQueries -} from './automations-page-test-harness' - -function pressEscape(target: EventTarget): KeyboardEvent { - const event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }) - target.dispatchEvent(event) - return event -} - -async function renderWithCloseSpy(): Promise> { - const closeAutomationsPage = vi.fn() - mocks.state.closeAutomationsPage = closeAutomationsPage - await renderPage() - await settleHostQueries() - return closeAutomationsPage -} - -describe('automations page Escape precedence', () => { - installAutomationsPageHarness() - - it('closes the page when nothing is layered above it', async () => { - const closeAutomationsPage = await renderWithCloseSpy() - - const event = pressEscape(document.body) - - expect(event.defaultPrevented).toBe(true) - expect(closeAutomationsPage).toHaveBeenCalledTimes(1) - }) - - it('leaves Escape to a store modal opened over the page', async () => { - mocks.state.activeModal = 'new-workspace-composer' - const closeAutomationsPage = await renderWithCloseSpy() - const input = document.createElement('input') - document.body.appendChild(input) - - const event = pressEscape(input) - - expect(event.defaultPrevented).toBe(false) - expect(closeAutomationsPage).not.toHaveBeenCalled() - }) - - it('leaves Escape to a store modal even when focus is on page chrome', async () => { - mocks.state.activeModal = 'worktree-palette' - const closeAutomationsPage = await renderWithCloseSpy() - - const event = pressEscape(document.body) - - expect(event.defaultPrevented).toBe(false) - expect(closeAutomationsPage).not.toHaveBeenCalled() - }) - - it('leaves Escape to a visible overlay that the store does not track', async () => { - const closeAutomationsPage = await renderWithCloseSpy() - const overlay = document.createElement('div') - overlay.setAttribute('role', 'dialog') - document.body.appendChild(overlay) - - const event = pressEscape(document.body) - - expect(event.defaultPrevented).toBe(false) - expect(closeAutomationsPage).not.toHaveBeenCalled() - }) -}) diff --git a/src/renderer/src/components/automations/AutomationsPage.external-scope.spec.tsx b/src/renderer/src/components/automations/AutomationsPage.external-scope.spec.tsx deleted file mode 100644 index 6b417860ff1..00000000000 --- a/src/renderer/src/components/automations/AutomationsPage.external-scope.spec.tsx +++ /dev/null @@ -1,69 +0,0 @@ -// @vitest-environment happy-dom - -/** Drive the page’s scope producer and inspect IPC; client tests with pre-narrowed scopes cannot verify which hosts the page asks. */ - -import { describe, expect, it } from 'vitest' - -import { - api, - installAutomationsPageHarness, - mocks, - renderPage, - settleHostQueries -} from './automations-page-test-harness' -import { listedExternalEntries } from './automations-page-listed-items' -import { makeExternalManager } from './automations-page-fixtures' - -const DESKTOP_SELF_FILTER = { - kind: 'host' as const, - host: { authority: { kind: 'desktop' as const }, selector: { kind: 'self' as const } } -} - -function sshFilter(targetId: string): unknown { - return { - kind: 'host', - host: { authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId } } - } -} - -/** Registered SSH targets, each with the generation that makes it fenceable. */ -function withSshHosts(...targetIds: string[]): void { - mocks.state.sshTargetLabels = new Map(targetIds.map((targetId) => [targetId, targetId])) - mocks.state.sshTargetGenerations = new Map(targetIds.map((targetId) => [targetId, 1])) - mocks.state.sshConnectionStates = new Map( - targetIds.map((targetId) => [targetId, { status: 'connected' }]) - ) -} - -function openclawOnly(): void { - api.automations.listExternalManagerForOwner.mockImplementation( - async ({ provider }: { provider: string }) => - provider === 'openclaw' - ? { manager: makeExternalManager(), error: null, updatedAt: 1 } - : { manager: null, error: null, updatedAt: 1 } - ) -} - -describe('AutomationsPage external manager probes', () => { - installAutomationsPageHarness() - - it('drops the previous host rows when the selection moves, not when the new probe lands', async () => { - withSshHosts('staging') - mocks.state.automationHostFilter = DESKTOP_SELF_FILTER - openclawOnly() - - const { rerender } = await renderPage() - await settleHostQueries() - expect(listedExternalEntries().length).toBeGreaterThan(0) - - // The new host never answers, so anything still listed belongs to the old one. - api.automations.listExternalManagerForOwner.mockImplementation( - async () => await new Promise(() => undefined) - ) - mocks.state.automationHostFilter = sshFilter('staging') - await rerender() - await settleHostQueries() - - expect(listedExternalEntries()).toEqual([]) - }) -}) diff --git a/src/renderer/src/components/automations/AutomationsPage.refresh-selection.spec.tsx b/src/renderer/src/components/automations/AutomationsPage.refresh-selection.spec.tsx deleted file mode 100644 index 2a96389539d..00000000000 --- a/src/renderer/src/components/automations/AutomationsPage.refresh-selection.spec.tsx +++ /dev/null @@ -1,133 +0,0 @@ -// @vitest-environment happy-dom - -/** Refresh reaches one authority at a time; deriving selection/history from that answer would drop or mislabel other hosts’ rows under All hosts. */ - -import { act } from 'react' -import { describe, expect, it, vi } from 'vitest' -import { - api, - installAutomationsPageHarness, - mocks, - refreshOnFocus, - renderPage, - RUNTIME_SELF_FILTER, - runtimeHost, - scopedList, - SELF_PRECONDITION, - settleHostQueries -} from './automations-page-test-harness' -import { listedRows } from './automations-page-listed-items' -import { makeAutomation, makeRun } from './automations-page-fixtures' - -async function openAutomationRow(container: HTMLElement, name: string): Promise { - const row = [ - ...container.querySelectorAll('[data-testid="automation-row"]') - ].find((candidate) => candidate.textContent === name) - expect(row).toBeTruthy() - await act(async () => row?.click()) - await settleHostQueries() -} - -describe('AutomationsPage selection', () => { - installAutomationsPageHarness() - - it('fetches the newly selected automation runs when the selection changes', async () => { - scopedList([ - makeAutomation({ id: 'a-1', name: 'Nightly' }), - makeAutomation({ id: 'a-2', name: 'Weekly' }) - ]) - - const { container, rerender } = await renderPage() - api.automations.listRuns.mockClear() - - mocks.state.selectedAutomationId = 'a-2' - await rerender() - await openAutomationRow(container, 'Weekly') - - // Fence history reads too so moved rows refuse rather than answer from a former host. - expect(api.automations.listRuns).toHaveBeenCalledWith({ - automationId: 'a-2', - expectedOwner: SELF_PRECONDITION, - preview: true - }) - expect(mocks.detailPane?.selected?.id).toBe('a-2') - }) -}) - -describe('AutomationsPage multi-host selection', () => { - installAutomationsPageHarness() - - it('keeps a selection that lives on another host when the window regains focus', async () => { - runtimeHost([makeAutomation({ id: 'a-remote', name: 'Remote nightly' })], []) - const desktop = makeAutomation({ id: 'a-desktop', name: 'Desktop nightly' }) - scopedList([desktop]) - api.automations.list.mockResolvedValue([desktop]) - const setSelectedAutomationId = vi.fn((id: string | null) => { - mocks.state.selectedAutomationId = id - }) - mocks.state.selectedAutomationId = 'a-remote' - mocks.state.setSelectedAutomationId = setSelectedAutomationId - - const { container, rerender } = await renderPage() - await settleHostQueries() - await refreshOnFocus() - await rerender() - - // One authority’s refresh cannot decide whether another authority’s selected row still exists. - expect(setSelectedAutomationId).not.toHaveBeenCalled() - await openAutomationRow(container, 'Remote nightly') - expect(container.querySelector('[data-testid="detail-name"]')?.textContent).toBe( - 'Remote nightly' - ) - }) - - it('shows the selected host’s runs when another authority holds the same id', async () => { - runtimeHost( - [makeAutomation({ id: 'a-1', name: 'Remote nightly' })], - [makeRun({ id: 'run-remote', automationId: 'a-1' })] - ) - // IDs can collide across authorities; desktop remains independently reachable. - api.automations.list.mockResolvedValue([makeAutomation({ id: 'a-1', name: 'Desktop nightly' })]) - api.automations.listRuns.mockResolvedValue([ - makeRun({ id: 'run-desktop', automationId: 'a-1' }) - ]) - scopedList([]) - mocks.state.automationHostFilter = RUNTIME_SELF_FILTER - mocks.state.selectedAutomationId = 'a-1' - - const { container } = await renderPage() - await settleHostQueries() - await refreshOnFocus() - await openAutomationRow(container, 'Remote nightly') - - expect(mocks.detailPane?.selected?.name).toBe('Remote nightly') - expect(mocks.detailPane?.selectedRuns.map((run) => run.id)).toEqual(['run-remote']) - }) - - it('keeps both authorities’ rows on screen and selectable under All hosts', async () => { - runtimeHost( - [makeAutomation({ id: 'a-1', name: 'Remote nightly' })], - [makeRun({ id: 'run-remote', automationId: 'a-1' })] - ) - scopedList([makeAutomation({ id: 'a-1', name: 'Desktop nightly' })]) - // The stored id names the record; only the row key can say whose copy. - mocks.state.selectedAutomationId = 'a-1' - - const { container } = await renderPage() - await settleHostQueries() - - // Two stored records, not one row and a silent drop into #44's empty state. - expect( - [...container.querySelectorAll('[data-testid="automation-row"]')].map( - (row) => row.textContent - ) - ).toEqual(['Desktop nightly', 'Remote nightly']) - - const remote = listedRows().find((row) => row.automation.name === 'Remote nightly') - await act(async () => { - mocks.listPanel?.selectAutomationRow(remote?.key ?? '') - }) - - expect(mocks.detailPane?.selected?.name).toBe('Remote nightly') - }) -}) diff --git a/src/renderer/src/components/automations/automations-page-actions-test-harness.ts b/src/renderer/src/components/automations/automations-page-actions-test-harness.ts deleted file mode 100644 index cb581cd733e..00000000000 --- a/src/renderer/src/components/automations/automations-page-actions-test-harness.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { EVERY_AGENT_CHAT } from '../../../../shared/native-chat/every-agent-chat.test-fixture' -import { - addRuntimeProject, - api, - mocks, - renderPage, - runtimeHost, - RUNTIME_ID, - RUNTIME_SELF_FILTER, - scopedList, - settleHostQueries -} from './automations-page-test-harness' -import { RUNTIME_REPO_ID, RUNTIME_WORKSPACE_ID } from './automations-page-runtime-fixtures' -import { act } from 'react' -import { expect, vi } from 'vitest' -import { checkRuntimeHooks, type HookCheckResult } from '@/runtime/repo-hooks/runtime-hooks-client' -import { loadHostStructuredAgents } from '@/runtime/native-chat/agent-catalogs/host-structured-agents' -import { getDefaultSettings } from '../../../../shared/constants' -import type { Automation } from '../../../../shared/automations/automation-definition' -import type { AutomationDraft } from './authoring/AutomationEditorDialog' -import { - AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY as LIST_HOST_SCOPE, - AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY as OWNER_FENCING, - AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY as CREATE_IDEMPOTENCY, - AUTOMATION_SURFACE_RUNTIME_CAPABILITY, - AUTOMATION_EXTRA_AGENT_ARGS_RUNTIME_CAPABILITY -} from '../../../../shared/automations/automation-runtime-capabilities' -import { - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, - STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY -} from '../../../../shared/agent-session/agent-session-runtime-capabilities' -import { STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY } from '../../../../shared/agent-session/structured-agent-session-surface-capabilities' -import { makeAutomation } from './automations-page-fixtures' - -export const CHAT_CAPABILITIES = [ - LIST_HOST_SCOPE, - OWNER_FENCING, - CREATE_IDEMPOTENCY, - AUTOMATION_SURFACE_RUNTIME_CAPABILITY, - AUTOMATION_EXTRA_AGENT_ARGS_RUNTIME_CAPABILITY, - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, - STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, - STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY -] - -export function draftRecord(current: unknown): object { - if (current === null || typeof current !== 'object') { - throw new Error('Missing editor draft') - } - return current -} - -/** The runtime answers a create; without this the RPC double returns an empty result. */ -export function runtimeCreateReturns(automation: Automation): void { - const previous = mocks.callRuntimeRpc.getMockImplementation() - mocks.callRuntimeRpc.mockImplementation( - async (target: unknown, method: string, params: unknown, options: unknown) => { - if (method === 'automation.create') { - return { automation } - } - return await previous?.(target, method, params, options) - } - ) -} - -export async function save(): Promise { - await act(async () => { - mocks.editorDialog?.onSave() - }) -} - -export function createRpcCalls(): unknown[][] { - return mocks.callRuntimeRpc.mock.calls.filter((call) => call[1] === 'automation.create') -} - -export const REGISTERED_CHAT_CAPABILITIES = [ - ...CHAT_CAPABILITIES, - STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY -] - -export function observeRuntimeIncarnation( - runtimeId: string, - capabilities = REGISTERED_CHAT_CAPABILITIES -): void { - const status = { runtimeId, capabilities } - mocks.state.runtimeStatusByEnvironmentId = new Map([[RUNTIME_ID, { status }]]) - mocks.getRuntimeEnvironmentStatus.mockResolvedValue(status) -} - -export async function learnRuntimeAgents(runtimeId: string, agents: string[]): Promise { - const previous = mocks.callRuntimeRpc.getMockImplementation() - mocks.callRuntimeRpc.mockImplementation(async (target, method, params, options) => - method === 'agentSession.agents' - ? { agents: agents.map((agent) => ({ agent, capabilities: {} })) } - : await previous?.(target, method, params, options) - ) - try { - await loadHostStructuredAgents(`runtime:${RUNTIME_ID}`, REGISTERED_CHAT_CAPABILITIES, runtimeId) - } finally { - mocks.callRuntimeRpc.mockImplementation(previous ?? (() => ({}))) - } -} - -export async function openCreateDialogFor(projectId: string, workspaceId: string): Promise { - await act(async () => { - mocks.listPanel?.openCreateDialog() - }) - await act(async () => { - mocks.editorDialog?.onDraftChange((current) => ({ - ...draftRecord(current), - name: 'Sweep', - prompt: 'Do the sweep', - projectId, - workspaceMode: 'existing', - workspaceId - })) - }) -} - -export async function holdRuntimeSave( - draft: Partial = {}, - initialAgents = ['pi'] -) { - api.automations.list.mockResolvedValue([]) - scopedList([]) - runtimeHost([], []) - addRuntimeProject() - mocks.state.settings = { ...getDefaultSettings('/tmp'), nativeChatProviders: EVERY_AGENT_CHAT } - mocks.state.automationHostFilter = RUNTIME_SELF_FILTER - observeRuntimeIncarnation('A') - await learnRuntimeAgents('A', initialAgents) - runtimeCreateReturns(makeAutomation({ id: 'a-new' })) - const held = Promise.withResolvers() - vi.mocked(checkRuntimeHooks).mockReturnValue(held.promise) - const page = await renderPage() - await settleHostQueries() - await openCreateDialogFor(RUNTIME_REPO_ID, RUNTIME_WORKSPACE_ID) - await act(async () => { - mocks.editorDialog?.onDraftChange((current) => ({ - ...draftRecord(current), - agentId: 'pi', - workspaceMode: 'new_per_run', - workspaceId: '', - ...draft - })) - }) - await save() - expect(checkRuntimeHooks).toHaveBeenCalledWith(`runtime:${RUNTIME_ID}`, RUNTIME_REPO_ID, {}) - expect(createRpcCalls()).toHaveLength(0) - return { - page, - async release() { - await act(async () => { - held.resolve({ status: 'ok', hasHooks: false, hooks: null, mayNeedUpdate: false }) - }) - await settleHostQueries() - } - } -} diff --git a/src/renderer/src/components/automations/automations-page-child-props.test-fixture.ts b/src/renderer/src/components/automations/automations-page-child-props.test-fixture.ts deleted file mode 100644 index 7852a857b10..00000000000 --- a/src/renderer/src/components/automations/automations-page-child-props.test-fixture.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { AutomationAuthorityRef } from '../../../../shared/automations/ownership/automation-owner-ref' -import type { Automation } from '../../../../shared/automations/automation-definition' -import type { Worktree } from '../../../../shared/worktree/types' -import type { AutomationHostCatalogView } from './hosts/use-automation-host-catalog' -import type { AutomationCreateDestinationControl } from './authoring/use-automation-create-destination' -import type { ExternalAutomationListEntry } from './external/external-automation-list-entries' -import type { AutomationListRow } from './listing/automation-list-row-identity' -import type { AutomationListViewItem } from './listing/automation-list-view' -import type { AutomationTemplate } from './authoring/automation-templates' - -export type ListPanelProps = { - sortedListItems: readonly AutomationListViewItem[] - selectedExternal: ExternalAutomationListEntry | null - externalActionKey: string | null - requestExternalAction: ( - manager: ExternalAutomationListEntry['manager'], - job: ExternalAutomationListEntry['job'], - action: 'run' | 'pause' | 'resume' | 'delete', - scope: ExternalAutomationListEntry['scope'] - ) => void - hasListItems: boolean - hasFilteredListItems: boolean - selectedRowKey: string | null - selectedExternalKey: string | null - hostCatalog: AutomationHostCatalogView - searchCounts: { hostRowCount: number; visibleRowCount: number; searchActive: boolean } - externalManagersUncheckedNotice: string | null - isActionEnabled: (row: AutomationListRow, action: string) => boolean - onSelectHost: (filter: unknown) => void - selectAutomationRow: (rowKey: string | null) => void - selectExternalKey: (entryKey: string | null) => void - onOpenDetail: () => void - onRefresh: () => void - onOpenRuns: () => void - runNow: (row: AutomationListRow) => void - openEditDialog: (row: AutomationListRow) => void - toggleAutomation: (row: AutomationListRow) => void - requestDeleteAutomation: (row: AutomationListRow) => void - openCreateDialog: () => void - canCreateAutomation: boolean -} - -export type EditorDialogProps = { - launchAuthority?: AutomationAuthorityRef - open: boolean - isEditing: boolean - isSaving: boolean - createDestination?: AutomationCreateDestinationControl - editDestination?: AutomationCreateDestinationControl - notice?: { message: string; recovery: string | null } | null - onNoticeRecover?: (action: string) => void - repos?: { id: string }[] - worktrees: Worktree[] - draft?: { projectId: string; workspaceId: string } - canSave?: boolean - onSave: () => void - onProjectChange: (projectId: string) => void - onDraftChange: (updater: (current: unknown) => unknown) => void - onApplyTemplate: (template: AutomationTemplate) => void -} - -export type DeleteDialogProps = { - deleteTarget: Automation | null - onConfirm: () => void -} diff --git a/src/renderer/src/components/automations/automations-page-destination-fixtures.ts b/src/renderer/src/components/automations/automations-page-destination-fixtures.ts deleted file mode 100644 index 53a9be5a2dc..00000000000 --- a/src/renderer/src/components/automations/automations-page-destination-fixtures.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { mocks, RUNTIME_ID } from './automations-page-test-harness' -import { CHAT_CAPABILITIES } from './automations-page-actions-test-harness' -import type { Repo } from '../../../../shared/repo-types' -import { hostStableKey } from '../../../../shared/automations/ownership/automation-owner-key' - -export function observeRuntimeChat(capabilities: string[] | null): void { - mocks.state.runtimeStatusByEnvironmentId = new Map( - capabilities === null ? [] : [[RUNTIME_ID, { status: { capabilities } }]] - ) -} - -export function capableChatDestination(): void { - observeRuntimeChat(CHAT_CAPABILITIES) - mocks.getRuntimeEnvironmentStatus.mockResolvedValue({ capabilities: CHAT_CAPABILITIES }) -} - -export const SSH_TARGET_ID = 'ssh-target-1' -export const SSH_REPO_ID = 'repo-ssh' -export const SSH_HOST_KEY = hostStableKey({ - authority: { kind: 'desktop' }, - selector: { kind: 'ssh', targetId: SSH_TARGET_ID } -}) - -export const DESKTOP_SELF_KEY = hostStableKey({ - authority: { kind: 'desktop' }, - selector: { kind: 'self' } -}) -export const RUNTIME_SELF_KEY = hostStableKey({ - authority: { kind: 'runtime', environmentId: RUNTIME_ID }, - selector: { kind: 'self' } -}) - -/** A desktop-registered SSH host, with the generation that makes it fenceable. */ -export function addSshHost(): void { - mocks.state.sshTargetLabels = new Map([[SSH_TARGET_ID, 'openclaw']]) - mocks.state.sshTargetGenerations = new Map([[SSH_TARGET_ID, 1]]) - mocks.state.sshConnectionStates = new Map([[SSH_TARGET_ID, { status: 'connected' }]]) -} - -/** A project checked out on that SSH host rather than locally. */ -export function addSshProject(): void { - const repo: Repo = { - id: SSH_REPO_ID, - displayName: 'orca', - path: '/repos/orca', - badgeColor: '#222222', - addedAt: 1, - worktreeBaseRef: 'main', - connectionId: SSH_TARGET_ID - } - mocks.state.repos = [...(mocks.state.repos as Repo[]), repo] - mocks.repoMap.set(SSH_REPO_ID, repo) - // The host needs a ready setup or save refuses the project before choosing a destination. - mocks.state.projectHostSetups = [ - ...mocks.state.projectHostSetups, - { - id: 'setup-ssh', - projectId: 'project-ssh', - hostId: `ssh:${SSH_TARGET_ID}`, - repoId: SSH_REPO_ID, - path: '/repos/orca', - displayName: 'orca', - setupState: 'ready', - setupMethod: 'legacy-repo', - createdAt: 1, - updatedAt: 1 - } - ] -} diff --git a/src/renderer/src/components/automations/automations-page-fixtures.ts b/src/renderer/src/components/automations/automations-page-fixtures.ts index 9b60daacd74..02c5c2e0fd5 100644 --- a/src/renderer/src/components/automations/automations-page-fixtures.ts +++ b/src/renderer/src/components/automations/automations-page-fixtures.ts @@ -1,29 +1,14 @@ -/** Default to runnable local records so Run Now reaches its handler; cases needing refusal override the relevant fields. */ - import type { Automation } from '../../../../shared/automations/automation-definition' import type { AutomationRun, AutomationRunUsage } from '../../../../shared/automations/history/automation-run' -import type { ExternalAutomationManager } from '../../../../shared/automations/external/external-automation' -import type { ProjectHostSetup } from '../../../../shared/project-types' -import type { AutomationHostCatalogEntry } from './hosts/automation-host-catalog-types' -import type { AppState } from '@/store/types' -import type { Repo } from '../../../../shared/repo-types' -import type { Worktree } from '../../../../shared/worktree/types' -import type { - ExternalAutomationScope, - ScopedExternalAutomationManager -} from './external/external-automation-scope-client' import { automationListRowKey, type AutomationListRow } from './listing/automation-list-row-identity' -import { ALL_AUTOMATION_HOSTS_FILTER } from '../../../../shared/automations/ownership/automation-host-filter' -import { getDefaultSettings } from '../../../../shared/constants' export const REPO_ID = 'repo-1' -export const WORKSPACE_ID = 'workspace-1' export function makeAutomation(overrides: Partial = {}): Automation { return { @@ -114,186 +99,3 @@ export function makeRunUsage(overrides: Partial = {}): Autom ...overrides } } - -export function makeExternalManager( - overrides: Partial = {} -): ExternalAutomationManager { - return { - id: 'openclaw:local', - provider: 'openclaw', - label: 'OpenClaw', - targetLabel: 'This computer', - target: { type: 'local' }, - status: 'available', - error: null, - canManage: true, - jobs: [ - { - id: 'job-1', - managerId: 'openclaw:local', - provider: 'openclaw', - name: 'OpenClaw job', - schedule: 'Daily at 09:00', - rawSchedule: '0 9 * * *', - enabled: true, - state: 'active', - prompt: null, - promptPreview: 'Sweep', - nextRunAt: null, - lastRunAt: null, - lastStatus: null, - lastError: null, - workdir: null, - runCount: 0, - runs: [] - } - ], - ...overrides - } -} - -/** The desktop-self scope every manager fixture is discovered under by default. */ -export function makeExternalAutomationScope( - overrides: Partial = {} -): ExternalAutomationScope { - return { - owner: { - authority: { kind: 'desktop' }, - selector: { kind: 'self' } - }, - provider: 'openclaw', - ...overrides - } -} - -export function makeScopedExternalManager( - overrides: Partial = {}, - scope: ExternalAutomationScope = makeExternalAutomationScope() -): ScopedExternalAutomationManager { - return { scope, manager: makeExternalManager(overrides) } -} - -function makeRepo(): Repo { - return { - id: REPO_ID, - displayName: 'orca', - path: '/repos/orca', - badgeColor: '#000000', - addedAt: 1, - worktreeBaseRef: 'main' - } -} - -/** Ready and local, so the editor can build a run context and state a Self destination. */ -function makeProjectHostSetup(): ProjectHostSetup { - return { - id: 'setup-1', - projectId: 'project-1', - hostId: 'local', - repoId: REPO_ID, - path: '/repos/orca', - displayName: 'orca', - setupState: 'ready', - setupMethod: 'legacy-repo', - createdAt: 1, - updatedAt: 1 - } -} - -export function makeWorktree(overrides: Partial = {}): Worktree { - return { - id: WORKSPACE_ID, - repoId: REPO_ID, - displayName: 'main', - path: '/repos/orca', - branch: 'main', - head: 'abc123', - isBare: false, - isMainWorktree: true, - comment: '', - linkedIssue: null, - linkedPR: null, - linkedLinearIssue: null, - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 0, - lastActivityAt: 0, - ...overrides - } -} - -export type AutomationsPageFixtureState = Record & - Pick - -export type AutomationsPageStoreFixtures = { - state: AutomationsPageFixtureState - repoMap: Map - worktreeMap: Map -} - -/** A store shaped like the page's reads; actions are spies so nothing escapes the test. */ -export function makeStoreState(): AutomationsPageStoreFixtures { - const repo = makeRepo() - const worktree = makeWorktree() - const noop = (): void => undefined - return { - state: { - repos: [repo], - projectHostSetups: [makeProjectHostSetup()], - worktreesByRepo: { [REPO_ID]: [worktree] }, - unifiedTabsByWorktree: {}, - terminalLayoutsByTabId: {}, - ptyIdsByTabId: {}, - activeWorktreeId: null, - fetchWorktrees: noop, - fetchAllWorktrees: noop, - fetchRuntimeEnvironmentRepos: async () => [], - startupWorktreeRefreshCompleted: true, - updateSettings: noop, - openSettingsPage: noop, - openSettingsTarget: noop, - closeAutomationsPage: noop, - activeModal: 'none', - sshConnectionStates: new Map(), - sshTargetLabels: new Map(), - removedSshTargetLabels: new Map(), - // Hydrated absence proves no desktop SSH targets; the catalog should project only Self. - sshTargetsHydrated: true, - sshStateByEnvironment: new Map(), - runtimeEnvironments: [], - runtimeEnvironmentCatalogSettled: true, - runtimeStatusByEnvironmentId: new Map(), - automationHostFilter: ALL_AUTOMATION_HOSTS_FILTER, - setAutomationHostFilter: noop, - settings: getDefaultSettings('/tmp'), - preflightStatus: null, - preflightStatusChecked: true, - preflightStatusContextKey: null, - refreshPreflightStatus: noop, - selectedAutomationId: null, - setSelectedAutomationId: noop, - pendingAutomationRunNavigation: null, - setPendingAutomationRunNavigation: noop, - hydratePersistedUI: noop, - recordFeatureInteraction: noop, - allWorktrees: () => [worktree], - getKnownWorktreeById: () => worktree - }, - repoMap: new Map([[REPO_ID, repo]]), - worktreeMap: new Map([[WORKSPACE_ID, worktree]]) - } -} - -export type DetailPaneProps = { - selected: Automation | null - selectedHostEntry: AutomationHostCatalogEntry | null - selectedRuns: AutomationRun[] - selectedRunsNotice: { message: string } | null - runNow: (automation: Automation) => void - toggleAutomation: (automation: Automation) => void - requestDeleteAutomation: (automation: Automation) => void - openEditDialog: (automation: Automation) => void - openAutomationRunPage: (run: AutomationRun) => void - recoverSelectedRuns: (action: 'retry') => void -} diff --git a/src/renderer/src/components/automations/automations-page-listed-items.ts b/src/renderer/src/components/automations/automations-page-listed-items.ts deleted file mode 100644 index 2f64eff3037..00000000000 --- a/src/renderer/src/components/automations/automations-page-listed-items.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** Read authority-qualified row keys and render order from the mocked list panel, matching real clicks rather than synthesizing identity. */ - -import type { AutomationListRow } from './listing/automation-list-row-identity' -import type { ExternalAutomationListEntry } from './external/external-automation-list-entries' -import { mocks } from './automations-page-test-harness' - -function listedItems() { - return mocks.listPanel?.sortedListItems ?? [] -} - -/** Local rows the page listed, in render order. */ -export function listedRows(): readonly AutomationListRow[] { - return listedItems().flatMap((item) => (item.kind === 'local' ? [item.row] : [])) -} - -/** External entries the page listed, in render order. */ -export function listedExternalEntries(): readonly ExternalAutomationListEntry[] { - return listedItems().flatMap((item) => (item.kind === 'external' ? [item.entry] : [])) -} - -export function listedRow(automationId: string): AutomationListRow { - const row = listedRows().find((entry) => entry.automation.id === automationId) - if (!row) { - throw new Error(`no listed row for ${automationId}`) - } - return row -} diff --git a/src/renderer/src/components/automations/automations-page-runtime-fixtures.ts b/src/renderer/src/components/automations/automations-page-runtime-fixtures.ts deleted file mode 100644 index f35be180c75..00000000000 --- a/src/renderer/src/components/automations/automations-page-runtime-fixtures.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { AutomationsPageFixtureState } from './automations-page-fixtures' -import type { Automation } from '../../../../shared/automations/automation-definition' -import type { ProjectHostSetup } from '../../../../shared/project-types' -import type { Repo } from '../../../../shared/repo-types' -import type { Worktree } from '../../../../shared/worktree/types' - -export const RUNTIME_REPO_ID = 'repo-2' -export const RUNTIME_WORKSPACE_ID = 'workspace-2' - -/** Builds the host-scoped response used by the page harness. */ -export function selfScopedList(automations: Automation[]): Record { - return { - automations, - items: automations.map((automation) => ({ - automationId: automation.id, - selector: { kind: 'self' } - })), - orphanCount: 0 - } -} - -type RuntimeFixtureMocks = { - state: AutomationsPageFixtureState - repoMap: Map - worktreeMap: Map -} - -/** Adds a runtime-owned project whose id is ambiguous in a flat repo lookup. */ -export function addRuntimeProject(mocks: RuntimeFixtureMocks, runtimeId: string): void { - const repo = { - id: RUNTIME_REPO_ID, - displayName: 'gpu-orca', - path: '/repos/gpu-orca', - badgeColor: '#111111', - addedAt: 1, - worktreeBaseRef: 'main', - executionHostId: `runtime:${runtimeId}` - } as Repo - const worktree = { - id: RUNTIME_WORKSPACE_ID, - repoId: RUNTIME_REPO_ID, - displayName: 'main', - path: '/repos/gpu-orca', - branch: 'main' - } as Worktree - const setup: ProjectHostSetup = { - id: 'setup-2', - projectId: 'project-2', - hostId: `runtime:${runtimeId}`, - repoId: RUNTIME_REPO_ID, - path: '/repos/gpu-orca', - displayName: 'gpu-orca', - setupState: 'ready', - setupMethod: 'legacy-repo', - createdAt: 1, - updatedAt: 1 - } - const repos = mocks.state.repos as Repo[] - const setups = mocks.state.projectHostSetups - const worktreesByRepo = mocks.state.worktreesByRepo as Record - mocks.state.repos = [...repos, repo] - mocks.state.projectHostSetups = [...setups, setup] - mocks.state.worktreesByRepo = { ...worktreesByRepo, [RUNTIME_REPO_ID]: [worktree] } - mocks.repoMap.set(RUNTIME_REPO_ID, repo) - mocks.worktreeMap.set(RUNTIME_WORKSPACE_ID, worktree) -} diff --git a/src/renderer/src/components/automations/automations-page-test-harness.tsx b/src/renderer/src/components/automations/automations-page-test-harness.tsx deleted file mode 100644 index 1dbb50644f4..00000000000 --- a/src/renderer/src/components/automations/automations-page-test-harness.tsx +++ /dev/null @@ -1,399 +0,0 @@ -import type { - ListPanelProps, - EditorDialogProps, - DeleteDialogProps -} from './automations-page-child-props.test-fixture' -import { resetHostStructuredAgentsForTests } from '@/runtime/native-chat/agent-catalogs/host-structured-agents' -import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/hosts/capabilities/local-runtime-capabilities' -/** Record child props to test page orchestration rather than presentation. Import this harness first so shared vi.mock registrations precede AutomationsPage loading. */ - -import { act, createElement, StrictMode, type ReactNode } from 'react' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, type Mock, vi } from 'vitest' - -import type { Automation } from '../../../../shared/automations/automation-definition' -import type { AutomationRun } from '../../../../shared/automations/history/automation-run' - -import { - AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY as LIST_HOST_SCOPE, - AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY as OWNER_FENCING, - AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY as CREATE_IDEMPOTENCY -} from '../../../../shared/automations/automation-runtime-capabilities' -import type { AppState } from '@/store/types' - -import { AutomationDestinationField } from './authoring/AutomationDestinationField' -import { TooltipProvider } from '@/components/ui/tooltip' - -import { resetAutomationCapabilityProbes } from './hosts/automation-capability-probe' -import { - addRuntimeProject as addRuntimeProjectFixture, - selfScopedList -} from './automations-page-runtime-fixtures' - -type AutomationsPageMocks = { - state: AutomationsPageFixtureState - repoMap: Map - worktreeMap: Map - listPanel: ListPanelProps | null - detailPane: DetailPaneProps | null - editorDialog: EditorDialogProps | null - deleteDialog: DeleteDialogProps | null - callRuntimeRpc: Mock - getRuntimeEnvironmentStatus: Mock - setAutomationHostFilter: Mock - toastError: Mock - toastSuccess: Mock - toastMessage: Mock - setPendingRunNavigation: Mock -} - -// Mock factories only close over this exported binding; vi.hoisted bindings cannot be exported. -export const mocks: AutomationsPageMocks = { - state: { repos: [], projectHostSetups: [], worktreesByRepo: {} }, - repoMap: new Map(), - worktreeMap: new Map(), - listPanel: null, - detailPane: null, - editorDialog: null, - deleteDialog: null, - callRuntimeRpc: vi.fn(), - getRuntimeEnvironmentStatus: vi.fn(), - setAutomationHostFilter: vi.fn(), - toastError: vi.fn(), - toastSuccess: vi.fn(), - toastMessage: vi.fn(), - setPendingRunNavigation: vi.fn() -} - -vi.mock('@/store/index', () => { - const useAppStore = (selector: (state: Partial) => unknown): unknown => - selector(mocks.state as Partial) - useAppStore.getState = (): Partial => mocks.state as Partial - return { useAppStore } -}) - -vi.mock('@/store/repos/use-repository-catalog', () => ({ - useRepoMap: () => mocks.repoMap -})) -vi.mock('@/store/slices/worktrees/listing/use-worktree-catalog', () => ({ - useWorktreeMap: () => mocks.worktreeMap -})) - -vi.mock('@/runtime/rpc/calls/runtime-rpc-call', () => ({ - callRuntimeRpc: (...args: unknown[]) => mocks.callRuntimeRpc(...args) -})) -vi.mock('@/runtime/rpc/compatibility/runtime-compatibility-probe', () => ({ - getRuntimeEnvironmentStatus: (...args: unknown[]) => mocks.getRuntimeEnvironmentStatus(...args) -})) - -vi.mock('sonner', () => ({ - toast: { - error: (...args: unknown[]) => mocks.toastError(...args), - success: (...args: unknown[]) => mocks.toastSuccess(...args), - message: (...args: unknown[]) => mocks.toastMessage(...args) - } -})) - -vi.mock('@/lib/window-activity/window-visibility-interval', () => ({ - installWindowVisibilityInterval: () => () => undefined -})) - -vi.mock('@/components/contextual-tours/use-contextual-tour', () => ({ - useContextualTour: () => undefined -})) - -vi.mock('@/components/ui/tooltip', () => ({ - TooltipProvider: ({ children }: { children: ReactNode }) => <>{children}, - Tooltip: ({ children }: { children: ReactNode }) => <>{children}, - TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}, - TooltipContent: () => null -})) - -vi.mock('@/runtime/repo-hooks/runtime-hooks-client', () => ({ - checkRuntimeHooks: vi.fn(async () => ({ hooks: null, ok: true })) -})) - -vi.mock('@/runtime/repo-hooks/ensure-hooks-confirmed', () => ({ - ensureHooksConfirmed: vi.fn(async () => 'inherit'), - ensureHooksApproved: vi.fn(async () => ({ decision: 'run' })) -})) - -vi.mock('@/lib/workspace/activation/worktree-activation', () => ({ - activateAndRevealWorktree: vi.fn() -})) - -vi.mock('./listing/AutomationsListPanel', () => ({ - AutomationsListPanel: (props: ListPanelProps) => { - const selectAutomationRow = (rowKey: string | null): void => { - props.selectAutomationRow(rowKey) - props.onOpenDetail() - } - mocks.listPanel = { ...props, selectAutomationRow } - return ( -
- - {props.sortedListItems.map((item) => - item.kind === 'local' ? ( - - ) : ( - - ) - )} - {props.hasListItems ? null :
} -
- ) - } -})) - -vi.mock('./details/AutomationsDetailPane', () => ({ - AutomationsDetailPane: (props: DetailPaneProps) => { - mocks.detailPane = props - return ( -
- {props.selected?.name ?? 'none'} - {props.selectedRuns.length} -
- ) - } -})) - -vi.mock('./authoring/AutomationEditorDialog', () => ({ - AutomationEditorDialog: (props: EditorDialogProps) => { - mocks.editorDialog = props - return ( -
- {props.open ? 'open' : 'closed'} - {props.open && props.editDestination ? ( - - - - - ) : null} -
- ) - } -})) - -vi.mock('./authoring/AutomationDeleteDialogs', () => ({ - AutomationDeleteDialog: (props: DeleteDialogProps) => { - mocks.deleteDialog = props - return null - }, - ExternalAutomationDeleteDialog: () => null -})) - -import AutomationsPage from './AutomationsPage' -import { - makeAutomation, - makeRun, - makeStoreState, - type AutomationsPageFixtureState, - type DetailPaneProps -} from './automations-page-fixtures' - -function mockGroup(...names: K[]): Record { - return Object.fromEntries(names.map((name) => [name, vi.fn()])) as Record -} - -export const api = { - automations: mockGroup( - 'list', - 'listRuns', - 'create', - 'update', - 'delete', - 'runNow', - 'listScoped', - 'listExternalManagerForOwner', - 'runExternalActionForOwner', - 'retainExternalScopes' - ), - ssh: mockGroup('listTargets', 'connect'), - runtimeEnvironments: mockGroup('connect'), - ui: mockGroup('get') -} - -/** The precondition a Desktop + Self row captures; every fenced request repeats it. */ -export const SELF_PRECONDITION = { selector: { kind: 'self' } } -/** The owner the catalog projects for Desktop + Self, which every scoped call names. */ -export const DESKTOP_SELF_OWNER = { authority: { kind: 'desktop' }, selector: { kind: 'self' } } - -export const RUNTIME_ID = 'gpu' -/** A runtime that advertises both automation capabilities, so its rows carry owners. */ -const RUNTIME_CAPABILITIES = [LIST_HOST_SCOPE, OWNER_FENCING, CREATE_IDEMPOTENCY] -export const RUNTIME_SELF_FILTER = { - kind: 'host' as const, - host: { - authority: { kind: 'runtime' as const, environmentId: RUNTIME_ID }, - selector: { kind: 'self' as const } - } -} - -/** Serve desktop-local RPCs from the programmable API mocks and runtime-target RPCs from runtimeHost state. */ -async function answerAutomationRpc( - target: unknown, - method: string, - params?: unknown -): Promise { - if ((target as { kind?: string } | null)?.kind === 'environment') { - const answers = mocks.state.runtimeAnswers as - | { automations: Automation[]; runs: AutomationRun[] } - | undefined - if (method === 'automation.list') { - return selfScopedList(answers?.automations ?? []) - } - return method === 'automation.runs' ? { runs: answers?.runs ?? [] } : {} - } - const args = params as Record | undefined - switch (method) { - case 'automation.list': - return args?.selector - ? await api.automations.listScoped({ selector: args.selector }) - : { automations: await api.automations.list() } - case 'automation.runs': - return { runs: await api.automations.listRuns(args) } - case 'automation.create': - return { automation: await api.automations.create(args) } - case 'automation.update': - return { automation: await api.automations.update(args) } - case 'automation.delete': - return await api.automations.delete(args).then(() => ({})) - case 'automation.runNow': - return { run: await api.automations.runNow(args) } - default: - return {} - } -} - -/** Puts a reachable, capable second authority in the catalog and answers for it. */ -export function runtimeHost(automations: Automation[], runs: AutomationRun[]): void { - mocks.state.runtimeEnvironments = [ - { id: RUNTIME_ID, name: 'GPU box', createdAt: 1, pairingRevision: 4 } - ] - mocks.state.runtimeStatusByEnvironmentId = new Map([ - [RUNTIME_ID, { status: { capabilities: RUNTIME_CAPABILITIES } }] - ]) - mocks.getRuntimeEnvironmentStatus.mockResolvedValue({ capabilities: RUNTIME_CAPABILITIES }) - mocks.state.runtimeAnswers = { automations, runs } -} - -export const addRuntimeProject = (): void => addRuntimeProjectFixture(mocks, RUNTIME_ID) - -/** Answers the host-scoped read, which is where the list's rows and owners come from. */ -export function scopedList(automations: Automation[]): void { - api.automations.listScoped.mockResolvedValue(selfScopedList(automations)) -} - -const roots: Root[] = [] - -export async function renderPage(options?: { strict?: boolean }): Promise<{ - container: HTMLDivElement - rerender: () => Promise -}> { - const container = document.createElement('div') - document.body.appendChild(container) - const root = createRoot(container) - roots.push(root) - const rerender = async (): Promise => { - await act(async () => { - // StrictMode doubles effects to expose dispose-without-revive controller bugs. - const page = options?.strict - ? createElement(StrictMode, null, createElement(AutomationsPage)) - : createElement(AutomationsPage) - root.render(page) - }) - } - await rerender() - return { container, rerender } -} - -/** Lets the per-host queries, their capability probes, and the history read settle. */ -export async function settleHostQueries(): Promise { - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)) - }) -} - -/** The alt-tab-back trigger: one of the two events bound to the page refresh. */ -export async function refreshOnFocus(): Promise { - await act(async () => { - window.dispatchEvent(new Event('focus')) - await new Promise((resolve) => setTimeout(resolve, 0)) - }) -} - -export function rows(container: HTMLElement, testId: string): string[] { - return [...container.querySelectorAll(`[data-testid="${testId}"]`)].map( - (node) => node.textContent ?? '' - ) -} - -/** Registers the per-case store reset and renderer cleanup for the current suite. */ -export function installAutomationsPageHarness(): void { - beforeEach(() => { - globalThis.IS_REACT_ACT_ENVIRONMENT = true - vi.clearAllMocks() - setLocalRuntimeCapabilitiesForTests([]) - // Confirmed capabilities are module-level and must not leak between tests. - resetAutomationCapabilityProbes() - resetHostStructuredAgentsForTests() - // A prior test's wholesale mockImplementation must not leak forward. - mocks.callRuntimeRpc.mockReset() - mocks.callRuntimeRpc.mockImplementation(answerAutomationRpc) - mocks.listPanel = null - mocks.detailPane = null - mocks.editorDialog = null - mocks.deleteDialog = null - const fixtures = makeStoreState() - mocks.state = fixtures.state - mocks.state.folderWorkspaces = [] - mocks.state.setAutomationHostFilter = mocks.setAutomationHostFilter - mocks.repoMap = fixtures.repoMap - mocks.worktreeMap = fixtures.worktreeMap - api.automations.list.mockResolvedValue([makeAutomation()]) - api.automations.listRuns.mockResolvedValue([]) - api.automations.listExternalManagerForOwner.mockResolvedValue({ - manager: null, - error: null, - updatedAt: 0 - }) - api.automations.retainExternalScopes.mockResolvedValue(undefined) - api.automations.listScoped.mockResolvedValue(selfScopedList([makeAutomation()])) - api.automations.create.mockResolvedValue(makeAutomation()) - api.automations.update.mockResolvedValue(makeAutomation()) - api.automations.delete.mockResolvedValue(undefined) - api.automations.runNow.mockResolvedValue(makeRun()) - api.ui.get.mockResolvedValue({}) - // @ts-expect-error test window mock - globalThis.window.api = api - }) - - afterEach(async () => { - await act(async () => { - roots.splice(0).forEach((root) => root.unmount()) - }) - document.body.innerHTML = '' - setLocalRuntimeCapabilitiesForTests(null) - }) -} diff --git a/src/shared/__fixtures__/grok-inline-startup-pty-trace.ts b/src/shared/__fixtures__/grok-inline-startup-pty-trace.ts deleted file mode 100644 index 97846def799..00000000000 --- a/src/shared/__fixtures__/grok-inline-startup-pty-trace.ts +++ /dev/null @@ -1,124 +0,0 @@ -import type { GrokStartupTraceChunk } from './grok-startup-pty-trace' - -/** - * Recorded PTY output of `grok --no-alt-screen`, grok's inline (non - * alternate-screen) rendering mode — also reachable via `--minimal` and - * `[ui] screen_mode = "minimal"` in ~/.grok/config.toml. - * - * Recorded from: grok 1.0.0 (3cd0d0cbcebe) [stable] on macOS (darwin 25.3.0), - * xterm-256color 120x30 via node-pty, cwd = an Orca git worktree, 10s of output - * from spawn. - * - * This mode emits NO alternate-screen switch, so the composer-glyph marker never - * anchors and the 2004-anchored quiet window is the only delivery path. Elision - * follows the alt-screen fixture: entries without `data` are marker-free render - * frames kept only as timestamp + byte count. - */ -export const GROK_INLINE_STARTUP_PTY_TRACE: GrokStartupTraceChunk[] = [ - { - t: 279, - data: '\u001b]0;grok\u0007\u001b[?1000h\u001b[?1002h\u001b[?1003h\u001b[?1015h\u001b[?1006h\u001b[?1004h\u001b[?2004h\u001b[?25l\u001b]12;rgb:c8/c8' - }, - { t: 279, bytes: 8 }, - { t: 2280, bytes: 11 }, - { t: 2587, bytes: 5 }, - { t: 2612, bytes: 825 }, - { t: 2612, bytes: 925 }, - { t: 2612, bytes: 928 }, - { t: 2612, bytes: 958 }, - { t: 2612, bytes: 810 }, - { t: 2613, bytes: 1000 }, - { - t: 2613, - data: ';80;80;88;48;2;20;20;20m╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮\u001b[39;48;2;20;20;20m \u001b[26;1H \u001b[38;2;80;80;88;48;2;20;20;20m│\u001b[38;2;225;225;225;48;2;20;20;20m \u001b[38;2;200;200;200;48;2;20;20;20m❯ \u001b[38;2;225;225;225;48;2;20;20;20m \u001b[38;2;80;80;88;48;2;20;20;20m│\u001b[39;48;2;20;20;20m \u001b[27;1H \u001b[38;2;80;80;88;48;2;20;20;20m╰─────────────────────────────────────────────────────────────────────────────── \u001b[38;2;128;128;128;48;2;20;20;20mGrok 4.' - }, - { - t: 2613, - data: '5 (high)\u001b[38;2;88;88;88;48;2;20;20;20m · \u001b[38;2;108;108;108;48;2;20;20;20malways-approve\u001b[38;2;80;80;88;48;2;20;20;20m ─╯\u001b[39;48;2;20;20;20m \u001b[28;1H \u001b[29;1H \u001b[38;2;108;108;108;48;2;20;20;20m[stable]\u001b[39;48;2;20;20;20m \u001b[30;1H \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[26;7H\u001b[?25h\u001b[?2026l' - }, - { t: 2614, bytes: 169 }, - { t: 2676, bytes: 158 }, - { t: 2697, bytes: 158 }, - { t: 2772, bytes: 586 }, - { t: 2781, bytes: 410 }, - { t: 2866, bytes: 971 }, - { t: 2866, bytes: 69 }, - { t: 2878, bytes: 970 }, - { t: 2878, bytes: 308 }, - { t: 2951, bytes: 970 }, - { t: 2951, bytes: 784 }, - { t: 3036, bytes: 970 }, - { t: 3036, bytes: 968 }, - { t: 3036, bytes: 417 }, - { t: 3121, bytes: 948 }, - { t: 3121, bytes: 954 }, - { t: 3121, bytes: 792 }, - { t: 3207, bytes: 966 }, - { t: 3207, bytes: 964 }, - { t: 3207, bytes: 837 }, - { t: 3291, bytes: 970 }, - { t: 3291, bytes: 966 }, - { t: 3291, bytes: 956 }, - { t: 3291, bytes: 195 }, - { t: 3376, bytes: 968 }, - { t: 3376, bytes: 962 }, - { t: 3376, bytes: 785 }, - { t: 3461, bytes: 944 }, - { t: 3461, bytes: 912 }, - { t: 3461, bytes: 79 }, - { t: 3546, bytes: 962 }, - { t: 3546, bytes: 568 }, - { t: 3630, bytes: 839 }, - { t: 3630, bytes: 40 }, - { t: 3715, bytes: 424 }, - { t: 3800, bytes: 168 }, - { t: 3885, bytes: 218 }, - { t: 4139, bytes: 219 }, - { t: 4394, bytes: 219 }, - { t: 4732, bytes: 219 }, - { t: 5582, bytes: 219 }, - { t: 5922, bytes: 219 }, - { t: 6177, bytes: 219 }, - { t: 6432, bytes: 219 }, - { t: 6602, bytes: 219 }, - { t: 6686, bytes: 158 }, - { t: 6771, bytes: 410 }, - { t: 6857, bytes: 878 }, - { t: 6857, bytes: 463 }, - { t: 6941, bytes: 970 }, - { t: 6941, bytes: 718 }, - { t: 7026, bytes: 970 }, - { t: 7026, bytes: 968 }, - { t: 7026, bytes: 292 }, - { t: 7110, bytes: 964 }, - { t: 7110, bytes: 962 }, - { t: 7110, bytes: 549 }, - { t: 7195, bytes: 964 }, - { t: 7195, bytes: 969 }, - { t: 7195, bytes: 967 }, - { t: 7195, bytes: 306 }, - { t: 7279, bytes: 970 }, - { t: 7279, bytes: 968 }, - { t: 7279, bytes: 961 }, - { t: 7279, bytes: 103 }, - { t: 7364, bytes: 968 }, - { t: 7364, bytes: 956 }, - { t: 7364, bytes: 795 }, - { t: 7449, bytes: 964 }, - { t: 7450, bytes: 962 }, - { t: 7450, bytes: 316 }, - { t: 7535, bytes: 954 }, - { t: 7535, bytes: 550 }, - { t: 7621, bytes: 960 }, - { t: 7621, bytes: 94 }, - { t: 7704, bytes: 438 }, - { t: 7789, bytes: 168 }, - { t: 7874, bytes: 83 }, - { t: 8043, bytes: 219 }, - { t: 8381, bytes: 219 }, - { t: 8635, bytes: 219 }, - { t: 8890, bytes: 219 }, - { t: 9144, bytes: 219 }, - { t: 9398, bytes: 219 }, - { t: 9738, bytes: 219 } -] diff --git a/src/shared/__fixtures__/grok-startup-pty-trace.ts b/src/shared/__fixtures__/grok-startup-pty-trace.ts deleted file mode 100644 index c8b23c0d5ad..00000000000 --- a/src/shared/__fixtures__/grok-startup-pty-trace.ts +++ /dev/null @@ -1,163 +0,0 @@ -/** - * Recorded PTY output of a `grok` launch, used to pin when the draft-paste - * readiness scanner would deliver a launch draft. - * - * Recorded from: grok 1.0.0 (3cd0d0cbcebe) [stable] on macOS (darwin 25.3.0), - * xterm-256color 120x30 via node-pty, cwd = an Orca git worktree, 10s of output - * from spawn. - * - * Entries without `data` are grok logo-shimmer render frames, verified to hold - * none of DECSET 1049h / 2004h, DECTCEM show-cursor, or the composer glyphs - * U+276F / U+203A. Only their timestamp and byte count are kept so the fixture - * stays small; the replay substitutes filler of the recorded length. Timings are - * what the scanner reacts to, so they are preserved verbatim. - */ -export type GrokStartupTraceChunk = { - /** ms after spawn */ - t: number - data?: string - /** Length of an elided, marker-free animation frame. */ - bytes?: number -} - -export const GROK_STARTUP_PTY_TRACE: GrokStartupTraceChunk[] = [ - { - t: 274, - data: '\u001b]0;grok\u0007\u001b[?1049h\u001b[?1000h\u001b[?1002h\u001b[?1003h\u001b[?1015h\u001b[?1006h\u001b[?1004h\u001b[?2004h\u001b[?25l' - }, - { t: 274, bytes: 18 }, - { t: 582, bytes: 5 }, - { t: 607, bytes: 825 }, - { t: 607, bytes: 925 }, - { t: 607, bytes: 928 }, - { t: 607, bytes: 958 }, - { t: 607, bytes: 810 }, - { t: 607, bytes: 1000 }, - { - t: 607, - data: ';80;80;88;48;2;20;20;20m╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮\u001b[39;48;2;20;20;20m \u001b[26;1H \u001b[38;2;80;80;88;48;2;20;20;20m│\u001b[38;2;225;225;225;48;2;20;20;20m \u001b[38;2;200;200;200;48;2;20;20;20m❯ \u001b[38;2;225;225;225;48;2;20;20;20m \u001b[38;2;80;80;88;48;2;20;20;20m│\u001b[39;48;2;20;20;20m \u001b[27;1H \u001b[38;2;80;80;88;48;2;20;20;20m╰─────────────────────────────────────────────────────────────────────────────── \u001b[38;2;128;128;128;48;2;20;20;20mGrok 4.' - }, - { - t: 607, - data: '5 (high)\u001b[38;2;88;88;88;48;2;20;20;20m · \u001b[38;2;108;108;108;48;2;20;20;20malways-approve\u001b[38;2;80;80;88;48;2;20;20;20m ─╯\u001b[39;48;2;20;20;20m \u001b[28;1H \u001b[29;1H \u001b[38;2;108;108;108;48;2;20;20;20m[stable]\u001b[39;48;2;20;20;20m \u001b[30;1H \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[26;7H\u001b[?25h\u001b[?2026l' - }, - { t: 613, bytes: 169 }, - { t: 664, bytes: 158 }, - { t: 691, bytes: 158 }, - { t: 762, bytes: 410 }, - { t: 776, bytes: 586 }, - { t: 861, bytes: 971 }, - { t: 861, bytes: 69 }, - { t: 945, bytes: 970 }, - { t: 945, bytes: 784 }, - { t: 1031, bytes: 970 }, - { t: 1031, bytes: 968 }, - { t: 1031, bytes: 417 }, - { t: 1109, bytes: 948 }, - { t: 1110, bytes: 954 }, - { t: 1110, bytes: 792 }, - { t: 1115, bytes: 972 }, - { t: 1115, bytes: 968 }, - { t: 1115, bytes: 336 }, - { t: 1200, bytes: 964 }, - { t: 1200, bytes: 964 }, - { t: 1200, bytes: 962 }, - { t: 1200, bytes: 62 }, - { t: 1286, bytes: 970 }, - { t: 1286, bytes: 966 }, - { t: 1286, bytes: 956 }, - { t: 1286, bytes: 195 }, - { t: 1370, bytes: 968 }, - { t: 1370, bytes: 962 }, - { t: 1370, bytes: 785 }, - { t: 1455, bytes: 944 }, - { t: 1455, bytes: 912 }, - { t: 1455, bytes: 79 }, - { t: 1540, bytes: 962 }, - { t: 1540, bytes: 568 }, - { t: 1625, bytes: 839 }, - { t: 1625, bytes: 40 }, - { t: 1710, bytes: 424 }, - { t: 1795, bytes: 168 }, - { t: 1879, bytes: 218 }, - { t: 2134, bytes: 219 }, - { t: 2389, bytes: 219 }, - { t: 2728, bytes: 219 }, - { t: 3579, bytes: 219 }, - { t: 3918, bytes: 219 }, - { t: 4172, bytes: 219 }, - { t: 4425, bytes: 219 }, - { t: 4594, bytes: 219 }, - { t: 4680, bytes: 158 }, - { t: 4764, bytes: 410 }, - { t: 4850, bytes: 878 }, - { t: 4850, bytes: 463 }, - { t: 4934, bytes: 970 }, - { t: 4934, bytes: 718 }, - { t: 5019, bytes: 970 }, - { t: 5019, bytes: 968 }, - { t: 5019, bytes: 292 }, - { t: 5103, bytes: 964 }, - { t: 5103, bytes: 962 }, - { t: 5103, bytes: 549 }, - { t: 5188, bytes: 964 }, - { t: 5188, bytes: 969 }, - { t: 5188, bytes: 967 }, - { t: 5188, bytes: 306 }, - { t: 5272, bytes: 970 }, - { t: 5272, bytes: 968 }, - { t: 5272, bytes: 961 }, - { t: 5272, bytes: 103 }, - { t: 5358, bytes: 968 }, - { t: 5358, bytes: 956 }, - { t: 5358, bytes: 795 }, - { t: 5442, bytes: 964 }, - { t: 5442, bytes: 962 }, - { t: 5442, bytes: 316 }, - { t: 5527, bytes: 954 }, - { t: 5527, bytes: 550 }, - { t: 5612, bytes: 960 }, - { t: 5612, bytes: 94 }, - { t: 5696, bytes: 438 }, - { t: 5781, bytes: 168 }, - { t: 5866, bytes: 83 }, - { t: 6120, bytes: 219 }, - { t: 6375, bytes: 219 }, - { t: 6631, bytes: 219 }, - { t: 6884, bytes: 219 }, - { t: 7139, bytes: 219 }, - { t: 7393, bytes: 219 }, - { t: 7732, bytes: 219 }, - { t: 8580, bytes: 219 }, - { t: 8664, bytes: 158 }, - { t: 8750, bytes: 410 }, - { t: 8835, bytes: 796 }, - { t: 8919, bytes: 908 }, - { t: 8919, bytes: 861 }, - { t: 9004, bytes: 966 }, - { t: 9004, bytes: 894 }, - { t: 9090, bytes: 970 }, - { t: 9090, bytes: 968 }, - { t: 9090, bytes: 768 }, - { t: 9175, bytes: 964 }, - { t: 9175, bytes: 969 }, - { t: 9175, bytes: 967 }, - { t: 9175, bytes: 306 }, - { t: 9260, bytes: 970 }, - { t: 9260, bytes: 968 }, - { t: 9260, bytes: 958 }, - { t: 9260, bytes: 291 }, - { t: 9344, bytes: 960 }, - { t: 9344, bytes: 956 }, - { t: 9344, bytes: 605 }, - { t: 9430, bytes: 952 }, - { t: 9430, bytes: 936 }, - { t: 9430, bytes: 377 }, - { t: 9515, bytes: 960 }, - { t: 9515, bytes: 775 }, - { t: 9600, bytes: 914 }, - { t: 9600, bytes: 427 }, - { t: 9684, bytes: 618 }, - { t: 9770, bytes: 180 }, - { t: 9855, bytes: 219 } -] diff --git a/src/shared/bundled-skill-summary.test.ts b/src/shared/bundled-skill-summary.test.ts deleted file mode 100644 index 8b40536315d..00000000000 --- a/src/shared/bundled-skill-summary.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { BundledSkillOp, BundledSkillPlan } from './bundled-skill-plan' -import { bundledSkillNudgeKey, summarizeBundledSkillPlan } from './bundled-skill-summary' - -const folder = { kind: 'folder', entries: ['SKILL.md:file'], skillDigest: 'a'.repeat(64) } as const - -function op(fields: Partial): BundledSkillOp { - return { - kind: 'keep', - path: '/home/me/.agents/skills/x', - role: 'shared', - agents: [], - observed: folder, - cleanup: [], - ...fields - } -} - -/** A plan as a paired server returns it: planned on the server's disk against the server's own bundle. */ -const serverPlan = (ops: BundledSkillOp[]): BundledSkillPlan => ({ - scope: 'global', - homeDirectory: '/home/me', - skills: [{ name: 'orca-cli', digest: 'b'.repeat(64), ops }], - planDigest: 'c'.repeat(64) -}) - -describe('another Orca build’s copy in a planned host’s plan', () => { - it('reads as current and is never offered as an update', () => { - const plan = serverPlan([ - op({ kind: 'leave', reason: 'other-build', replaceable: true }), - op({ - path: '/home/me/.claude/skills/x', - role: 'agent', - observed: { kind: 'link', target: 'x' } - }) - ]) - - expect(summarizeBundledSkillPlan(plan)).toMatchObject({ - badge: null, - hasWork: false, - skills: [{ name: 'orca-cli', state: 'current' }] - }) - expect(bundledSkillNudgeKey('runtime:server-1', plan)).toBeNull() - }) -}) diff --git a/src/shared/claimed-agent-pty-owner.test.ts b/src/shared/claimed-agent-pty-owner.test.ts index b68e5bb8554..fef6c28b025 100644 --- a/src/shared/claimed-agent-pty-owner.test.ts +++ b/src/shared/claimed-agent-pty-owner.test.ts @@ -1,12 +1,9 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import type { AgentSessionExecutionClaim, AgentSessionSurfaceBinding } from './agent-session/agent-session-host-authority' -import { - ClaimedAgentPtyOwnerRegistry, - MAX_CLAIMED_AGENT_PTY_OWNER_ENTRIES -} from './claimed-agent-pty-owner' +import { ClaimedAgentPtyOwnerRegistry } from './claimed-agent-pty-owner' function claim( identityDigest = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', @@ -49,25 +46,7 @@ describe('ClaimedAgentPtyOwnerRegistry', () => { ).rejects.toThrow('agent_session_conflict') }) - it('does not find an owner through another worktree scope', async () => { - const registry = new ClaimedAgentPtyOwnerRegistry() - await registry.ensure({ - claim: claim(), - surface, - spawn: async () => ({ ptyId: 'pty-1' }) - }) - - expect( - registry.find( - claim( - 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', - 'ccccccccccccccccccccccccccccccccccccccccccc' - ) - ) - ).toBeNull() - }) - - it('generation-guards release across a replacement owner', async () => { + it('ignores a stale release generation and releases the matching owner', async () => { const registry = new ClaimedAgentPtyOwnerRegistry() const first = await registry.ensure({ claim: claim(), @@ -76,7 +55,7 @@ describe('ClaimedAgentPtyOwnerRegistry', () => { }) registry.release('pty-1', 'stale-generation') - expect(registry.find(claim())?.generation).toBe(first.owner.generation) + expect(registry.find(claim())).toMatchObject({ ptyId: 'pty-1' }) registry.release('pty-1', first.owner.generation) expect(registry.find(claim())).toBeNull() @@ -134,70 +113,6 @@ describe('ClaimedAgentPtyOwnerRegistry', () => { }) }) - it('rejects a recovered owner that reuses only one half of its generation identity', () => { - const registry = new ClaimedAgentPtyOwnerRegistry() - const owner = { - claim: claim(), - generation: 'generation-1', - phase: 'live' as const, - ptyId: 'pty-1', - surface - } - registry.register(owner) - - expect(() => registry.register({ ...owner, ptyId: 'pty-2' })).toThrow('agent_session_conflict') - expect(() => registry.register({ ...owner, generation: 'generation-2' })).toThrow( - 'agent_session_conflict' - ) - }) - - it('retains only allowlisted owner fields', () => { - const registry = new ClaimedAgentPtyOwnerRegistry() - const owner = { - claim: { ...claim(), unknownPayload: 'claim payload' }, - generation: 'generation-1', - phase: 'live' as const, - ptyId: 'pty-1', - surface: { ...surface, unknownPayload: 'surface payload' }, - unknownPayload: 'owner payload' - } - - registry.register(owner) - - expect(registry.list()).toEqual([ - { - claim: claim(), - generation: 'generation-1', - phase: 'live', - ptyId: 'pty-1', - surface - } - ]) - }) - - it('fails closed when recovered owner evidence reaches the process-wide cap', () => { - const registry = new ClaimedAgentPtyOwnerRegistry() - const owners = Array.from({ length: MAX_CLAIMED_AGENT_PTY_OWNER_ENTRIES }, (_, index) => ({ - claim: claim(`identity-${index}`), - generation: `generation-${index}`, - phase: 'live' as const, - ptyId: `pty-${index}`, - surface - })) - registry.reconcileAuthoritative(owners) - - expect(() => - registry.register({ - claim: claim('one-more-identity'), - generation: 'one-more-generation', - phase: 'live', - ptyId: 'one-more-pty', - surface - }) - ).toThrow('execution_owner_unavailable') - expect(registry.list()).toHaveLength(MAX_CLAIMED_AGENT_PTY_OWNER_ENTRIES) - }) - it('atomically converges from conflicting provider evidence to one owner', async () => { const registry = new ClaimedAgentPtyOwnerRegistry() const ownerA = { @@ -226,7 +141,10 @@ describe('ClaimedAgentPtyOwnerRegistry', () => { spawn: async () => ({ ptyId: 'unexpected' }), isLive: (owner) => owner.generation === ownerB.generation }) - ).resolves.toMatchObject({ disposition: 'adopted', owner: ownerB }) + ).resolves.toMatchObject({ + disposition: 'adopted', + owner: { ptyId: 'pty-b', generation: 'generation-b' } + }) }) it('prunes an advertised generation when an authoritative snapshot omits it', async () => { @@ -241,12 +159,11 @@ describe('ClaimedAgentPtyOwnerRegistry', () => { registry.reconcileAuthoritative([recovered]) registry.reconcileAuthoritative([]) - const spawn = vi.fn(async () => ({ ptyId: 'pty-new' })) + const spawn = async () => ({ ptyId: 'pty-new' }) await expect(registry.ensure({ claim: claim(), surface, spawn })).resolves.toMatchObject({ disposition: 'created', owner: { ptyId: 'pty-new' } }) - expect(spawn).toHaveBeenCalledOnce() }) it('replaces a reused PTY id with the exact newly advertised generation', async () => { @@ -261,7 +178,7 @@ describe('ClaimedAgentPtyOwnerRegistry', () => { const newOwner = { ...oldOwner, generation: 'generation-new' } registry.reconcileAuthoritative([oldOwner]) registry.reconcileAuthoritative([newOwner]) - const isLive = vi.fn((owner: typeof newOwner) => owner.generation === 'generation-new') + const isLive = (owner: typeof newOwner) => owner.generation === 'generation-new' await expect( registry.ensure({ @@ -270,27 +187,32 @@ describe('ClaimedAgentPtyOwnerRegistry', () => { spawn: async () => ({ ptyId: 'unexpected' }), isLive }) - ).resolves.toMatchObject({ disposition: 'adopted', owner: newOwner }) - expect(isLive).toHaveBeenCalledWith(expect.objectContaining({ generation: 'generation-new' })) + ).resolves.toMatchObject({ + disposition: 'adopted', + owner: { ptyId: 'pty-reused', generation: 'generation-new' } + }) }) it('does not let reconciliation erase an in-flight reservation', async () => { const registry = new ClaimedAgentPtyOwnerRegistry() let finish!: (result: { ptyId: string }) => void - const spawn = vi.fn( - () => - new Promise<{ ptyId: string }>((resolve) => { - finish = resolve - }) - ) + const spawn = () => + new Promise<{ ptyId: string }>((resolve) => { + finish = resolve + }) const first = registry.ensure({ claim: claim(), surface, spawn }) registry.reconcileAuthoritative([]) const second = registry.ensure({ claim: claim(), surface, spawn }) finish({ ptyId: 'pty-reserved' }) - await expect(first).resolves.toMatchObject({ disposition: 'created' }) - await expect(second).resolves.toMatchObject({ disposition: 'adopted' }) - expect(spawn).toHaveBeenCalledOnce() + await expect(first).resolves.toMatchObject({ + disposition: 'created', + owner: { ptyId: 'pty-reserved' } + }) + await expect(second).resolves.toMatchObject({ + disposition: 'adopted', + owner: { ptyId: 'pty-reserved' } + }) }) }) diff --git a/src/shared/claude-model-list-probe.test.ts b/src/shared/claude-model-list-probe.test.ts index eaea828e014..db107053d41 100644 --- a/src/shared/claude-model-list-probe.test.ts +++ b/src/shared/claude-model-list-probe.test.ts @@ -13,35 +13,9 @@ function controlResponseLine(models: unknown[]): string { } describe('parseClaudeModelList', () => { - it('skips init noise, CRLF endings, and duplicate values', () => { - const stdout = - '{"type":"system","subtype":"init","model":"claude-sonnet-5"}\r\n' + - 'not json at all\r\n' + - `${controlResponseLine([ - { value: 'sonnet', displayName: 'Sonnet' }, - { value: 'sonnet', displayName: 'Sonnet (duplicate)' }, - { value: ' ', displayName: 'Blank' } - ])}\r\n` - expect(parseClaudeModelList(stdout)).toEqual([ - { id: 'sonnet', label: 'Sonnet', effortLevels: [], supportsFastMode: false } - ]) - }) - it('drops the disabled placeholder row the CLI advertises for a model it cannot run', () => { - // Captured from `claude` 2.1.237: Fable 5.1 is announced but gated on 2.1.255+. const parsed = parseClaudeModelList( - controlResponseLine([ - { value: 'sonnet', displayName: 'Sonnet' }, - { - value: 'cc-update-required-1', - resolvedModel: 'cc-update-required-1', - displayName: 'Fable 5.1 (disabled)', - description: 'Update to 2.1.255+ to use Fable 5.1', - supportsEffort: true, - supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max'], - disabled: true - } - ]) + controlResponseLine([{ value: 'sonnet' }, { value: 'disabled-model', disabled: true }]) ) expect(parsed.map(({ id }) => id)).toEqual(['sonnet']) }) diff --git a/src/shared/codex-subagent-transcript.test.ts b/src/shared/codex-subagent-transcript.test.ts index e9a32e51af2..4604e638382 100644 --- a/src/shared/codex-subagent-transcript.test.ts +++ b/src/shared/codex-subagent-transcript.test.ts @@ -1,20 +1,13 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { createCodexSubagentTranscriptState, - hasTrackedCodexTranscriptSubagents, reconcileCodexSubagentTranscript } from './codex-subagent-transcript' -import { - codexRosterToSnapshots, - queueCodexSubagentIdentity, - retryPendingCodexSubagentIdentities, - upsertCodexSubagent, - type CodexSubagentRoster -} from './codex-subagent-roster' +import { codexRosterToSnapshots, type CodexSubagentRoster } from './codex-subagent-roster' const CHILD_ID = '019fa65f-3144-7151-9c02-cff7a28f316f' @@ -52,47 +45,10 @@ describe('Codex subagent transcript reconciliation', () => { dirs.length = 0 }) - it('adds a child from the parent rollout and removes it after task completion', () => { - const dir = mkdtempSync(join(tmpdir(), 'codex-subagent-transcript-')) - dirs.push(dir) - const parentPath = join(dir, 'rollout-parent.jsonl') - const childPath = join(dir, `rollout-child-${CHILD_ID}.jsonl`) - writeFileSync(parentPath, jsonl([activity('started')])) - writeFileSync(childPath, jsonl([{ type: 'event_msg', payload: { type: 'task_started' } }])) - const state = createCodexSubagentTranscriptState() - const roster: CodexSubagentRoster = new Map() - - reconcileCodexSubagentTranscript(state, roster, parentPath) - - expect(hasTrackedCodexTranscriptSubagents(state)).toBe(true) - expect(codexRosterToSnapshots(roster)).toEqual([ - { - id: CHILD_ID, - description: 'sidebar_repro', - state: 'working', - startedAt: 1234, - agentType: undefined, - model: undefined - } - ]) - - writeFileSync( - childPath, - jsonl([ - { type: 'event_msg', payload: { type: 'task_started' } }, - { type: 'event_msg', payload: { type: 'task_complete' } } - ]) - ) - reconcileCodexSubagentTranscript(state, roster, parentPath) - - expect(hasTrackedCodexTranscriptSubagents(state)).toBe(false) - expect(codexRosterToSnapshots(roster)).toBeUndefined() - }) - it('resolves a child rollout filed under a later session day than the parent', () => { const root = mkdtempSync(join(tmpdir(), 'codex-subagent-transcript-')) dirs.push(root) - const childStartedAt = Date.now() + const childStartedAt = 1_767_225_600_000 const parentDir = dayDirectory(root, childStartedAt - 24 * 60 * 60 * 1000) const childDir = dayDirectory(root, childStartedAt) mkdirSync(parentDir, { recursive: true }) @@ -115,87 +71,7 @@ describe('Codex subagent transcript reconciliation', () => { reconcileCodexSubagentTranscript(state, roster, parentPath) // Why: only a cross-day lookup can observe the completion; the parent-directory scan never finds this file. - expect(roster.size).toBe(0) - expect(hasTrackedCodexTranscriptSubagents(state)).toBe(false) - }) - - it('retires a child whose rollout never becomes readable', () => { - vi.useFakeTimers() - try { - const dir = mkdtempSync(join(tmpdir(), 'codex-subagent-transcript-')) - dirs.push(dir) - const parentPath = join(dir, 'rollout-parent.jsonl') - writeFileSync(parentPath, jsonl([activity('started')])) - const state = createCodexSubagentTranscriptState() - const roster: CodexSubagentRoster = new Map() - - reconcileCodexSubagentTranscript(state, roster, parentPath) - expect(roster.size).toBe(1) - - // Why: within the grace window a slow-to-appear rollout must not drop a live child. - vi.advanceTimersByTime(30_000) - reconcileCodexSubagentTranscript(state, roster, parentPath) - expect(roster.size).toBe(1) - - vi.advanceTimersByTime(31_000) - reconcileCodexSubagentTranscript(state, roster, parentPath) - expect(roster.size).toBe(0) - expect(hasTrackedCodexTranscriptSubagents(state)).toBe(false) - } finally { - vi.useRealTimers() - } - }) - - it('names a hook-only worker child from its own rollout once the hook read missed it', () => { - const dir = mkdtempSync(join(tmpdir(), 'codex-subagent-transcript-')) - dirs.push(dir) - const parentPath = join(dir, 'rollout-parent.jsonl') - const childPath = join(dir, `rollout-child-${CHILD_ID}.jsonl`) - writeFileSync(parentPath, '') - const state = createCodexSubagentTranscriptState() - const roster: CodexSubagentRoster = new Map() - // SubagentStart carries only the role; a session_meta past 64 KiB (large tool lists) used to stay unread. - upsertCodexSubagent(roster, CHILD_ID, { agentType: 'worker', state: 'working' }, 1234) - writeFileSync( - childPath, - jsonl([ - { - type: 'session_meta', - payload: { - id: CHILD_ID, - base_instructions: { text: 'x'.repeat(100 * 1024) }, - source: { - subagent: { - thread_spawn: { agent_path: null, agent_nickname: 'Planck', agent_role: 'worker' } - } - } - } - } - ]) - ) - - reconcileCodexSubagentTranscript(state, roster, parentPath) - expect(retryPendingCodexSubagentIdentities(roster)).toBe(true) - expect(codexRosterToSnapshots(roster)).toEqual([ - expect.objectContaining({ id: CHILD_ID, agentType: 'worker', description: 'Planck' }) - ]) - }) - - it('keeps a task path the parent supplied over a queued header read', () => { - const dir = mkdtempSync(join(tmpdir(), 'codex-subagent-transcript-')) - dirs.push(dir) - const childPath = join(dir, `rollout-child-${CHILD_ID}.jsonl`) - writeFileSync( - childPath, - jsonl([{ type: 'session_meta', payload: { id: CHILD_ID, agent_nickname: 'Planck' } }]) - ) - const roster: CodexSubagentRoster = new Map() - upsertCodexSubagent(roster, CHILD_ID, { agentType: 'worker', state: 'working' }, 1234) - queueCodexSubagentIdentity(roster, CHILD_ID, childPath) - upsertCodexSubagent(roster, CHILD_ID, { description: 'pr_review', state: 'working' }, 1234) - - expect(retryPendingCodexSubagentIdentities(roster)).toBe(false) - expect(codexRosterToSnapshots(roster)?.[0]).toMatchObject({ description: 'pr_review' }) + expect(codexRosterToSnapshots(roster)).toBeUndefined() }) it('removes a child when Codex reports it interrupted', () => { @@ -210,8 +86,7 @@ describe('Codex subagent transcript reconciliation', () => { writeFileSync(parentPath, jsonl([activity('started'), activity('interrupted')])) reconcileCodexSubagentTranscript(state, roster, parentPath) - expect(hasTrackedCodexTranscriptSubagents(state)).toBe(false) - expect(roster.size).toBe(0) + expect(codexRosterToSnapshots(roster)).toBeUndefined() }) describe('child model identity', () => { @@ -237,17 +112,6 @@ describe('Codex subagent transcript reconciliation', () => { return { parentPath, childPath } } - it('reports the model from the child rollout, not the parent model', () => { - const { parentPath } = seedPair([started(), turnContext('gpt-5.6-terra')]) - const state = createCodexSubagentTranscriptState() - const roster: CodexSubagentRoster = new Map() - - reconcileCodexSubagentTranscript(state, roster, parentPath) - - // The parent runs sol; only the child's own turn_context may set its model. - expect(codexRosterToSnapshots(roster)?.[0]?.model).toBe('gpt-5.6-terra') - }) - it('keeps the discovered model when a later poll carries no turn_context', () => { const { parentPath, childPath } = seedPair([started(), turnContext('gpt-5.6-terra')]) const state = createCodexSubagentTranscriptState() @@ -293,8 +157,7 @@ describe('Codex subagent transcript reconciliation', () => { reconcileCodexSubagentTranscript(state, roster, parentPath) - expect(roster.size).toBe(0) - expect(hasTrackedCodexTranscriptSubagents(state)).toBe(false) + expect(codexRosterToSnapshots(roster)).toBeUndefined() }) }) }) diff --git a/src/shared/command-code-output-retention.test.ts b/src/shared/command-code-output-retention.test.ts deleted file mode 100644 index 681d12270e5..00000000000 --- a/src/shared/command-code-output-retention.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { createCommandCodeOutputStatusDetector } from './command-code-output-status' - -function heapAfterGc(): number { - if (!('gc' in globalThis) || typeof globalThis.gc !== 'function') { - throw new Error('The test runner must enable --expose-gc') - } - globalThis.gc() - globalThis.gc() - return process.memoryUsage().heapUsed -} - -describe('Command Code output retention', () => { - it('keeps small boundary carries without pinning oversized output on ordinary panes', () => { - const before = heapAfterGc() - const detectors = Array.from({ length: 8 }, (_value, index) => { - const detector = createCommandCodeOutputStatusDetector({ onWorking: () => {} }) - detector.observe(`${index}:${'x'.repeat(4 * 1024 * 1024)}`) - return detector - }) - expect(heapAfterGc() - before).toBeLessThan(2 * 1024 * 1024) - for (const detector of detectors) { - expect(detector.observe('\nordinary shell output\n')).toBe(false) - } - }) -}) diff --git a/src/shared/command-code-output-status.test.ts b/src/shared/command-code-output-status.test.ts index d2f80ebd304..06d68a0358b 100644 --- a/src/shared/command-code-output-status.test.ts +++ b/src/shared/command-code-output-status.test.ts @@ -1,37 +1,7 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { createCommandCodeOutputStatusDetector } from './command-code-output-status' -afterEach(() => { - vi.restoreAllMocks() -}) - describe('createCommandCodeOutputStatusDetector', () => { - it('marks Command Code working with the submitted prompt when the TUI starts thinking', () => { - const onWorking = vi.fn() - const detector = createCommandCodeOutputStatusDetector({ - startupCommand: 'command-code --trust', - onWorking - }) - - expect(detector.observe('❯ Fix the yellow spinner\r\n\x1b[35m✻ Thinking...\x1b[0m')).toBe(true) - - expect(onWorking).toHaveBeenCalledWith('Fix the yellow spinner') - }) - - it('waits for the Command Code banner before trusting generic status text', () => { - const onWorking = vi.fn() - const detector = createCommandCodeOutputStatusDetector({ - startupCommand: null, - onWorking - }) - - expect(detector.observe('Thinking about unrelated shell output')).toBe(false) - expect(detector.observe('# Command Code v0.27.2\r\n')).toBe(false) - expect(detector.observe('⌘ Parsing...')).toBe(true) - - expect(onWorking).toHaveBeenCalledWith('') - }) - it('detects the Command Code banner across PTY chunk boundaries', () => { const onWorking = vi.fn() const detector = createCommandCodeOutputStatusDetector({ @@ -46,22 +16,6 @@ describe('createCommandCodeOutputStatusDetector', () => { expect(onWorking).toHaveBeenCalledWith('') }) - it('detects the banner after chunks that carry no banner characters at all', () => { - const onWorking = vi.fn() - const detector = createCommandCodeOutputStatusDetector({ - startupCommand: null, - onWorking - }) - - // Ordinary agent output with no '#': the raw prefilter skips these without building a window. - expect(detector.observe('Codex is editing the file and rendering a diff\r\n')).toBe(false) - expect(detector.observe('Codex wrote 12 lines\r\n')).toBe(false) - expect(detector.observe('# Command Code v0.27.2\r\n')).toBe(false) - expect(detector.observe('⌘ Parsing...')).toBe(true) - - expect(onWorking).toHaveBeenCalledWith('') - }) - it('detects the Command Code banner when ANSI styling splits the words', () => { const onWorking = vi.fn() const detector = createCommandCodeOutputStatusDetector({ @@ -172,30 +126,3 @@ describe('createCommandCodeOutputStatusDetector', () => { expect(onWorking).not.toHaveBeenCalled() }) }) - -describe('terminal control stripping', () => { - function promptFrom(raw: string): string | null { - let captured: string | null = null - const detector = createCommandCodeOutputStatusDetector({ - startupCommand: 'command-code', - onWorking: (prompt) => { - captured = prompt - } - }) - detector.observe(raw) - return captured - } - - it('strips control bytes wherever they sit around the prompt text', () => { - expect(promptFrom('❯ plain prompt\r\n\x1b[35m✻ Thinking...\x1b[0m')).toBe('plain prompt') - expect(promptFrom('\x07❯ leading control\r\n\x1b[35m✻ Thinking...\x1b[0m')).toBe( - 'leading control' - ) - expect(promptFrom('❯ trailing control\x07\r\n\x1b[35m✻ Thinking...\x1b[0m')).toBe( - 'trailing control' - ) - expect(promptFrom('❯ adjacent\x07\x01\x02controls\r\n\x1b[35m✻ Thinking...\x1b[0m')).toBe( - 'adjacentcontrols' - ) - }) -}) diff --git a/src/shared/crash-reporting.test.ts b/src/shared/crash-reporting.test.ts index 4c6abeeab4a..078da5f3317 100644 --- a/src/shared/crash-reporting.test.ts +++ b/src/shared/crash-reporting.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest' import { formatCrashReportText, - sanitizeCrashReportDetails, sanitizeCrashReportString, type CrashReportRecord } from './crash-reporting' @@ -27,14 +26,6 @@ function notesReport(overrides: Partial = {}): CrashReportRec } } -/** Notes are emitted indented inside the fence; mirror that when asserting. */ -function indentNote(note: string): string { - return note - .split('\n') - .map((line) => ` ${line}`) - .join('\n') -} - describe('crash-reporting shared helpers', () => { it('redacts paths and common secret-shaped strings', () => { const text = @@ -53,60 +44,6 @@ describe('crash-reporting shared helpers', () => { ) }) - it('keeps details on a strict primitive allowlist', () => { - const longStack = [ - 'Error: boom', - ...Array.from( - { length: 200 }, - (_, index) => `at Component${index} (/Users/alice/project/src/file-${index}.tsx:1:1)` - ) - ].join('\n') - - expect( - sanitizeCrashReportDetails({ - name: 'GPU /home/alice/repo', - code: 9, - crashed: true, - missing: null, - error_stack: longStack, - minidumpPath: '/Users/alice/Library/Application Support/Orca/reports/abc.dmp', - nested: { nope: true }, - infinite: Number.POSITIVE_INFINITY - }) - ).toEqual({ - name: 'GPU [redacted-path]', - code: 9, - crashed: true, - missing: null, - error_stack: expect.stringContaining('[redacted-path]'), - minidumpPath: '[redacted-path]' - }) - expect( - String(sanitizeCrashReportDetails({ error_stack: longStack }).error_stack).length - ).toBeGreaterThan(240) - expect(String(sanitizeCrashReportDetails({ errorStack: longStack }).errorStack).length).toBe( - 4_003 - ) - expect( - String(sanitizeCrashReportDetails({ componentStack: longStack }).componentStack).length - ).toBe(4_003) - expect(String(sanitizeCrashReportDetails({ description: longStack }).description).length).toBe( - 243 - ) - }) - - it('keeps a user note longer than the 240-char detail cap intact', () => { - // A real 1.4.184 note was cut mid-word by the telemetry detail budget. - const note = - `My phone is connected. ${'The Claude terminal never came back. '.repeat(20)}`.trim() - - const text = formatCrashReportText(notesReport(), note) - - expect(note.length).toBeGreaterThan(240) - expect(text).toContain(`--- begin user notes ---\n${indentNote(note)}\n--- end user notes ---`) - expect(text).not.toContain('...') - }) - it('still redacts paths and secrets far past the old 240-char cap', () => { const note = [ 'a'.repeat(1_000), @@ -123,22 +60,6 @@ describe('crash-reporting shared helpers', () => { expect(text).not.toContain('ghp_abcdefghijklmnopqrstuvwxyz') }) - it('keeps user notes when the report is truncated to the endpoint cap', () => { - // Tail truncation must remove reproducible machine data before user notes. - const text = formatCrashReportText( - notesReport({ - details: Object.fromEntries( - Array.from({ length: 400 }, (_, index) => [`detail_${index}`, 'x'.repeat(240)]) - ) - }), - 'the sidebar went blank' - ) - - expect(text.length).toBeLessThanOrEqual(64_000) - expect(text).toContain('[Crash report truncated to fit feedback endpoint limits.]') - expect(text).toContain('--- begin user notes ---\n the sidebar went blank') - }) - it.each([ ['POSIX', '/home/alice/orca/app.log then recovered.'], ['Windows', 'C:\\Users\\alice\\Orca\\app.log then recovered.'], diff --git a/src/shared/darwin-process-table-capture.test.ts b/src/shared/darwin-process-table-capture.test.ts index 6b59c552b85..708da7f353b 100644 --- a/src/shared/darwin-process-table-capture.test.ts +++ b/src/shared/darwin-process-table-capture.test.ts @@ -7,7 +7,6 @@ import { captureDarwinProcessTable, resetDarwinProcessTableCaptureForTests } from './darwin-process-table-capture' -import { PS_ARGS } from './process-table-snapshot' const raw = '123 1 123 123 S+ 16/9 Fri Oct 9 12:34:56 2026 /bin/zsh\n' const canonical = raw.replace('16/9', 'ttys009') @@ -43,13 +42,7 @@ describe('Darwin translation deadline', () => { await vi.advanceTimersByTimeAsync(100) await rejected expect(signal?.aborted).toBe(true) - expect(capture.mock.calls.map(([args]) => args)).toEqual([ - ['-axo', PS_ARGS[1].replace('tty=', 'tdev=')] - ]) - for (let index = 0; index < 5; index++) { - expect(await captureDarwinProcessTable(capture, validate, 100)).toBe(canonical) - } - expect(naming).toHaveBeenCalledTimes(1) + expect(await captureDarwinProcessTable(capture, validate, 100)).toBe(canonical) finish(raw.replace('16/9', 'stale-name')) await pending @@ -58,24 +51,6 @@ describe('Darwin translation deadline', () => { expect(await captureDarwinProcessTable(capture, validate, 100)).toBe( raw.replace('16/9', 'fresh-name') ) - expect(naming).toHaveBeenCalledTimes(2) - expect(vi.getTimerCount()).toBe(0) - }) - - it('does not start naming after the capture has consumed its budget', async () => { - const lateCapture = vi.fn(async (args: readonly string[]) => { - if (args[1].includes('tdev=')) { - await new Promise((resolve) => setTimeout(resolve, 100)) - return raw - } - return canonical - }) - - const first = captureDarwinProcessTable(lateCapture, validate, 100) - const rejected = expect(first).rejects.toThrow('capture_over_budget') - await vi.advanceTimersByTimeAsync(100) - await rejected - expect(naming).not.toHaveBeenCalled() }) it('rejects late successful names even when Promise completion beats the overdue timer', async () => { @@ -90,23 +65,5 @@ describe('Darwin translation deadline', () => { await expect(captureDarwinProcessTable(capture, validate, 100)).rejects.toThrow( 'capture_over_budget' ) - expect(capture).toHaveBeenCalledTimes(1) - expect(vi.getTimerCount()).toBe(0) - }) - - it('rejects expiry during output validation without another capture', async () => { - let clock = 0 - vi.spyOn(performance, 'now').mockImplementation(() => clock) - naming.mockResolvedValue(canonical) - const slowValidation = (stdout: string): string => { - clock = 101 - return stdout - } - - await expect(captureDarwinProcessTable(capture, slowValidation, 100)).rejects.toThrow( - 'capture_over_budget' - ) - expect(capture).toHaveBeenCalledTimes(1) - expect(vi.getTimerCount()).toBe(0) }) }) diff --git a/src/shared/darwin-process-table-inventory-drain.test.ts b/src/shared/darwin-process-table-inventory-drain.test.ts index 19dc21817e0..87f481b274d 100644 --- a/src/shared/darwin-process-table-inventory-drain.test.ts +++ b/src/shared/darwin-process-table-inventory-drain.test.ts @@ -48,7 +48,7 @@ describe('Darwin inventory drain admission', () => { if (path === candidatePath) { return missingCandidate() } - if (lstatMock.mock.calls.length === 2) { + if (path === '/dev/entry-0') { throw new Error('stat invocation failed') } return pending.promise @@ -60,19 +60,15 @@ describe('Darwin inventory drain admission', () => { }) await vi.advanceTimersByTimeAsync(0) expect(settled).toBe(false) - expect(lstatMock).toHaveBeenCalledTimes(3) expect(await captureDarwinProcessTable(capture, validate, 100)).toBe(canonical) - expect(opendirMock).toHaveBeenCalledTimes(1) const stat = { rdev: 0x10000009n, isCharacterDevice: () => true } lstatMock.mockResolvedValue(stat) pending.resolve(stat) expect(await first).toBe(raw.replace('16/9', 'entry-1')) - expect(lstatMock).toHaveBeenCalledTimes(3) - expect(vi.getTimerCount()).toBe(0) }) - it('holds admission until its one uncancelable stat drains and admits no later reads', async () => { + it('uses canonical fallback until expired inventory drains, then admits fresh names', async () => { opendirMock.mockResolvedValue( Array.from({ length: 12 }, (_, index) => ({ name: `entry-${index}` })) ) @@ -88,51 +84,18 @@ describe('Darwin inventory drain admission', () => { const rejected = expect(first).rejects.toThrow('capture_over_budget') await vi.advanceTimersByTimeAsync(100) await rejected - expect(lstatMock).toHaveBeenCalledTimes(2) const stat = { rdev: 0x10000009n, isCharacterDevice: () => true } - for (let index = 0; index < 5; index++) { - expect(await captureDarwinProcessTable(capture, validate, 100)).toBe(canonical) - } - expect(opendirMock).toHaveBeenCalledTimes(1) - expect(lstatMock).toHaveBeenCalledTimes(2) + expect(await captureDarwinProcessTable(capture, validate, 100)).toBe(canonical) pending.resolve(stat) await vi.advanceTimersByTimeAsync(0) - expect(lstatMock).toHaveBeenCalledTimes(2) lstatMock.mockImplementation(async (path: string) => path === candidatePath ? missingCandidate() : stat ) expect(await captureDarwinProcessTable(capture, validate, 100)).toBe( raw.replace('16/9', 'entry-0') ) - expect(opendirMock).toHaveBeenCalledTimes(2) - expect(vi.getTimerCount()).toBe(0) - }) - - it('holds admission while an expired pty candidate stat drains', async () => { - const pending = deferred<{ - rdev: bigint - isCharacterDevice: () => boolean - }>() - lstatMock.mockReturnValue(pending.promise) - - const first = captureDarwinProcessTable(capture, validate, 100) - const rejected = expect(first).rejects.toThrow('capture_over_budget') - await vi.advanceTimersByTimeAsync(100) - await rejected - expect(await captureDarwinProcessTable(capture, validate, 100)).toBe(canonical) - expect(lstatMock).toHaveBeenCalledTimes(1) - expect(opendirMock).not.toHaveBeenCalled() - - const stat = { rdev: 0x10000009n, isCharacterDevice: () => true } - pending.resolve(stat) - await vi.advanceTimersByTimeAsync(0) - lstatMock.mockResolvedValue(stat) - expect(await captureDarwinProcessTable(capture, validate, 100)).toBe(canonical) - expect(lstatMock).toHaveBeenCalledTimes(2) - expect(opendirMock).not.toHaveBeenCalled() - expect(vi.getTimerCount()).toBe(0) }) it('closes a directory that opens after expiry and holds admission until its close drains', async () => { @@ -157,9 +120,7 @@ describe('Darwin inventory drain admission', () => { opened.resolve(entries()) await vi.advanceTimersByTimeAsync(0) expect(closeStarted).toBe(true) - expect(lstatMock.mock.calls).toEqual([[candidatePath, { bigint: true }]]) expect(await captureDarwinProcessTable(capture, validate, 100)).toBe(canonical) - expect(opendirMock).toHaveBeenCalledTimes(1) close.resolve() await vi.advanceTimersByTimeAsync(0) @@ -172,7 +133,5 @@ describe('Darwin inventory drain admission', () => { expect(await captureDarwinProcessTable(capture, validate, 100)).toBe( raw.replace('16/9', 'fresh-name') ) - expect(opendirMock).toHaveBeenCalledTimes(2) - expect(vi.getTimerCount()).toBe(0) }) }) diff --git a/src/shared/darwin-terminal-names-real-ps.test.ts b/src/shared/darwin-terminal-names-real-ps.test.ts index 36368ef8af9..3c0394488a4 100644 --- a/src/shared/darwin-terminal-names-real-ps.test.ts +++ b/src/shared/darwin-terminal-names-real-ps.test.ts @@ -51,21 +51,4 @@ describe.runIf(process.platform === 'darwin')('nameDarwinTerminals against real ) expect(ttyByPid(named).get(terminalPid)).toMatch(/^ttys\d{3,}$/) }) - - it('matches ps terminal names for every process present in both captures', async () => { - // Retry once: a terminal can close between the two ps runs. - let mismatches: string[] = [] - for (let attempt = 0; attempt < 2; attempt += 1) { - const raw = execFileSync('ps', RAW_ARGS, { encoding: 'utf8' }) - const canonical = ttyByPid(execFileSync('ps', [...PS_ARGS], { encoding: 'utf8' })) - const named = ttyByPid(await nameDarwinTerminals(raw)) - mismatches = [...named] - .filter(([pid, tty]) => canonical.has(pid) && canonical.get(pid) !== tty) - .map(([pid, tty]) => `${pid}: ${tty} != ${canonical.get(pid)}`) - if (mismatches.length === 0) { - break - } - } - expect(mismatches).toEqual([]) - }) }) diff --git a/src/shared/darwin-terminal-names.test.ts b/src/shared/darwin-terminal-names.test.ts index 9f330fa0c90..14ccf0d72a2 100644 --- a/src/shared/darwin-terminal-names.test.ts +++ b/src/shared/darwin-terminal-names.test.ts @@ -7,24 +7,14 @@ type DeviceEntry = { rdev: bigint kind?: 'character' | 'symlink' | 'file' delayMs?: number - error?: Error } class TestDeviceDirectory { entries: DeviceEntry[] = [] - openedPaths: string[] = [] - readPaths: string[] = [] closedDirectories = 0 - peakReads = 0 - openError?: Error enumerationError?: Error - private activeReads = 0 - openDirectory = async (path: string): Promise> => { - this.openedPaths.push(path) - if (this.openError) { - throw this.openError - } + openDirectory = async (): Promise> => { return this.enumerate() } @@ -44,24 +34,14 @@ class TestDeviceDirectory { readDevice = async ( path: string ): Promise<{ rdev: bigint; isCharacterDevice: () => boolean }> => { - this.readPaths.push(path) const entry = this.entries.find((candidate) => join('/dev', candidate.name) === path) if (!entry) { throw new Error('device disappeared') } - this.activeReads += 1 - this.peakReads = Math.max(this.peakReads, this.activeReads) - try { - await new Promise((resolve) => setTimeout(resolve, entry.delayMs ?? 1)) - if (entry.error) { - throw entry.error - } - return { - rdev: entry.rdev, - isCharacterDevice: () => (entry.kind ?? 'character') === 'character' - } - } finally { - this.activeReads -= 1 + await new Promise((resolve) => setTimeout(resolve, entry.delayMs ?? 1)) + return { + rdev: entry.rdev, + isCharacterDevice: () => (entry.kind ?? 'character') === 'character' } } } @@ -85,7 +65,6 @@ describe('nameDarwinTerminals', () => { expect(await nameDarwinTerminals(darwinRow('16/9'), directory)).toBe(darwinRow('??')) directory.entries = [{ name: 'ttys009', rdev: 0x10000009n }] expect(await nameDarwinTerminals(darwinRow('16/9'), directory)).toBe(darwinRow('ttys009')) - expect(directory.openedPaths).toEqual(['/dev', '/dev']) }) it('updates a known device name after a rename between captures', async () => { @@ -95,9 +74,6 @@ describe('nameDarwinTerminals', () => { directory.entries = [{ name: 'cu.after', rdev: 0x1600002an }] expect(await nameDarwinTerminals(darwinRow('22/42'), directory)).toBe(darwinRow('cu.after')) - expect(directory.readPaths).toEqual( - ['ttys042', 'cu.before', 'ttys042', 'cu.after'].map((name) => join('/dev', name)) - ) }) it('scans until a serial name resolves while excluding links and files', async () => { @@ -113,14 +89,9 @@ describe('nameDarwinTerminals', () => { expect(await nameDarwinTerminals(darwinRow('22/42'), directory)).toBe( darwinRow('cu.serial-adapter') ) - expect(directory.readPaths).toEqual( - ['ttys042', 'tty-link', 'tty-file', 'null', 'cu.serial-adapter'].map((name) => - join('/dev', name) - ) - ) }) - it('stops scheduling stats after cancellation while draining all active reads', async () => { + it('drains an active inventory read and closes its directory on cancellation', async () => { const directory = new TestDeviceDirectory() directory.entries = Array.from({ length: 20 }, (_, index) => ({ name: `entry-${index}`, @@ -140,16 +111,13 @@ describe('nameDarwinTerminals', () => { void rejection.catch(() => {}) try { await vi.advanceTimersByTimeAsync(2) - expect(directory.readPaths).toHaveLength(2) controller.abort() await vi.advanceTimersByTimeAsync(0) expect(settled).toBe(false) await vi.advanceTimersByTimeAsync(8) - expect(directory.readPaths).toHaveLength(2) await rejection expect(directory.closedDirectories).toBe(1) - expect(directory.peakReads).toBe(1) } finally { controller.abort() try { @@ -161,20 +129,6 @@ describe('nameDarwinTerminals', () => { } }) - it('bounds large inventories and closes the directory without admitting device reads', async () => { - const directory = new TestDeviceDirectory() - directory.entries = Array.from({ length: 20_000 }, (_, index) => ({ - name: `entry-${index}-${'x'.repeat(240)}`, - rdev: 0n - })) - - await expect(nameDarwinTerminals(darwinRow('16/9'), directory)).rejects.toThrow( - 'Directory listing is too large' - ) - expect(directory.readPaths).toEqual([join('/dev', 'ttys009')]) - expect(directory.closedDirectories).toBe(1) - }) - it('closes an interrupted directory iterator and rejects partial inventories', async () => { const directory = new TestDeviceDirectory() directory.entries = [{ name: 'cu.device', rdev: 0x10000009n }] @@ -184,26 +138,19 @@ describe('nameDarwinTerminals', () => { 'directory enumeration failed' ) expect(directory.closedDirectories).toBe(1) - expect(directory.readPaths).toEqual([join('/dev', 'ttys009')]) }) - it.each([ - '123 1 S+ node 16/9\n', - 'not-pid 1 123 123 S+ 16/9 tail\n', - '123 nope 123 123 S+ 16/9 tail\n', - '123 1 nope 123 S+ 16/9 tail\n', - '123 1 123 nope S+ 16/9 tail\n', - 'PID PPID PGID TPGID STAT TDEV STARTED COMMAND\n', - '123 1 123 123 S+ 16/9 \t\r\n' - ])('rejects unsupported row framing without a device scan (%s)', async (row) => { - const directory = new TestDeviceDirectory() - await expect(nameDarwinTerminals(darwinRow('16/9') + row, directory)).rejects.toThrow( - 'malformed_darwin_device_row' - ) - expect(directory.openedPaths).toEqual([]) - }) + it.each(['123 1 S+ node 16/9\n', 'not-pid 1 123 123 S+ 16/9 tail\n'])( + 'rejects unsupported row framing (%s)', + async (row) => { + const directory = new TestDeviceDirectory() + await expect(nameDarwinTerminals(darwinRow('16/9') + row, directory)).rejects.toThrow( + 'malformed_darwin_device_row' + ) + } + ) - it('rejects undecodable filenames and closes the directory before any stat reads', async () => { + it('rejects undecodable filenames and closes the directory', async () => { const directory = new TestDeviceDirectory() directory.entries = [ { name: 'cu.device', rdev: 0x10000009n }, @@ -213,7 +160,6 @@ describe('nameDarwinTerminals', () => { 'undecodable_device_name' ) expect(directory.closedDirectories).toBe(1) - expect(directory.readPaths).toEqual([join('/dev', 'ttys009')]) }) it('accepts signed and sign-extended device numbers while ignoring arbitrary wide rdev', async () => { @@ -243,24 +189,19 @@ describe('nameDarwinTerminals', () => { ] expect(await nameDarwinTerminals(darwinRow('16/9'), directory)).toBe(darwinRow('cu.alias')) - expect(directory.openedPaths).toEqual(['/dev']) }) - it.each(['symlink', 'file'] as const)( - 'rejects a %s pty candidate and falls back to a character device', - async (kind) => { - const directory = new TestDeviceDirectory() - directory.entries = [ - { name: 'ttys009', rdev: 0x10000009n, kind }, - { name: 'cu.real', rdev: 0x10000009n } - ] + it('rejects a symlink pty candidate and falls back to a character device', async () => { + const directory = new TestDeviceDirectory() + directory.entries = [ + { name: 'ttys009', rdev: 0x10000009n, kind: 'symlink' }, + { name: 'cu.real', rdev: 0x10000009n } + ] - expect(await nameDarwinTerminals(darwinRow('16/9'), directory)).toBe(darwinRow('cu.real')) - expect(directory.openedPaths).toEqual(['/dev']) - } - ) + expect(await nameDarwinTerminals(darwinRow('16/9'), directory)).toBe(darwinRow('cu.real')) + }) - it('stops candidate stats after cancellation and rejects once the active read drains', async () => { + it('rejects cancellation after its active candidate read drains', async () => { const directory = new TestDeviceDirectory() directory.entries = [1, 2, 3].map((minor) => ({ name: `ttys00${minor}`, @@ -281,16 +222,11 @@ describe('nameDarwinTerminals', () => { void rejection.catch(() => {}) try { await vi.advanceTimersByTimeAsync(2) - expect(directory.readPaths).toEqual([join('/dev', 'ttys001')]) controller.abort() await vi.advanceTimersByTimeAsync(0) expect(settled).toBe(false) await vi.advanceTimersByTimeAsync(8) - expect(directory.readPaths).toEqual([join('/dev', 'ttys001')]) await rejection - - expect(directory.openedPaths).toEqual([]) - expect(directory.peakReads).toBe(1) } finally { controller.abort() try { diff --git a/src/shared/draft-paste-ready-scanner-grok-trace-replay.test.ts b/src/shared/draft-paste-ready-scanner-grok-trace-replay.test.ts deleted file mode 100644 index b7a10f858b4..00000000000 --- a/src/shared/draft-paste-ready-scanner-grok-trace-replay.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -// Replay alt-screen and inline grok startup captures: ~12Hz logo shimmer defeats the 1.5s quiet window, causing 8s timeout delays/dropped drafts. Preserve both marker and quiet-floor paths. -import { describe, expect, it } from 'vitest' -import { createDraftPasteReadyScanner } from './draft-paste-ready-scanner' -import type { DraftPasteReadySignal } from './agent-launch/catalog/tui-agent-config-types' -import { - GROK_STARTUP_PTY_TRACE, - type GrokStartupTraceChunk -} from './__fixtures__/grok-startup-pty-trace' -import { GROK_INLINE_STARTUP_PTY_TRACE } from './__fixtures__/grok-inline-startup-pty-trace' - -const QUIET_WINDOW_MS = 1500 -const HARD_TIMEOUT_MS = 8000 - -function chunkData(chunk: GrokStartupTraceChunk): string { - return chunk.data ?? 'x'.repeat(chunk.bytes ?? 0) -} - -/** Replay caller timer semantics: re-arm quiet requests, cancel on earlier chunks, and let hard timeout outrank late markers. Null means timeout; main drops that draft. Mirrors agent-draft-readiness.ts/orca-runtime.ts. */ -function replayReadyAtMs( - signal: DraftPasteReadySignal, - trace: GrokStartupTraceChunk[] -): number | null { - const scanner = createDraftPasteReadyScanner(signal) - let quietDeadline: number | null = null - for (const [index, chunk] of trace.entries()) { - const settledAt = - quietDeadline !== null ? Math.min(quietDeadline, HARD_TIMEOUT_MS) : HARD_TIMEOUT_MS - if (chunk.t >= settledAt) { - return quietDeadline !== null && quietDeadline <= HARD_TIMEOUT_MS ? quietDeadline : null - } - const scanned = scanner.observe(chunkData(chunk)) - if (scanned.ready) { - return chunk.t - } - quietDeadline = scanned.armQuietTimer ? chunk.t + QUIET_WINDOW_MS : quietDeadline - if (index === trace.length - 1 && quietDeadline !== null && quietDeadline < HARD_TIMEOUT_MS) { - return quietDeadline - } - } - return null -} - -describe('grok startup trace replay (alternate-screen mode)', () => { - it('reports readiness on the frame that paints the composer box', () => { - const composerFrame = GROK_STARTUP_PTY_TRACE.find((chunk) => chunk.data?.includes('❯')) - expect(composerFrame).toBeDefined() - expect(replayReadyAtMs('grok-composer-prompt', GROK_STARTUP_PTY_TRACE)).toBe(composerFrame?.t) - }) -}) - -describe('grok startup trace replay (inline mode, no alternate screen)', () => { - it('arms a quiet window before the inline trace reaches the modeled timeout', () => { - // Inline grok emits no 1049h; retain a separate quiet anchor or main would drop the issue-URL draft. - const readyAt = replayReadyAtMs('grok-composer-prompt', GROK_INLINE_STARTUP_PTY_TRACE) - expect(readyAt).not.toBeNull() - expect(readyAt).toBeLessThan(HARD_TIMEOUT_MS) - }) -}) diff --git a/src/shared/dsb-native-one-shot-recognition.test.ts b/src/shared/dsb-native-one-shot-recognition.test.ts index 3f8ab311629..55835dc4e77 100644 --- a/src/shared/dsb-native-one-shot-recognition.test.ts +++ b/src/shared/dsb-native-one-shot-recognition.test.ts @@ -6,27 +6,15 @@ describe('DeepSeek Build native one-shot identity exclusion', () => { it.each([ 'dsb agent -p task', 'dsb agent -- --print task', - 'deepseek-build agent -- --prompt-file task.txt', 'deepseek-build agent --single task', - 'dsb agent --print task', 'dsb agent --prompt-json "[]"', 'dsb agent --prompt-file /tmp/task.txt', - 'deepseek-build-agent -p task', 'deepseek-build-agent.exe --print task', 'deepseek-build-agent --single=task', 'deepseek-build-agent -ptask', - 'deepseek-build-agent -cp task', 'deepseek-build-agent -cptask', - 'deepseek-build-agent -p-', - 'deepseek-build-agent -cp--print', - 'dsb agent -p-', - 'dsb agent -cp--print', - 'dsb agent -cp task', 'dsb run task', - 'deepseek-build-agent --resume saved-session --print task', 'deepseek-build-agent --resume --prompt-file task.txt', - 'dsb --cwd folder agent --model deepseek-v4-flash --prompt-file task.txt', - 'node --import ./preload.mjs /x/node_modules/@innocarpe/deepseek-build/npm/bin/dsb.js agent --print task', '"C:\\Program Files\\nodejs\\node.exe" --require preload.js "C:\\x\\node_modules\\@innocarpe\\deepseek-build\\npm\\bin\\deepseek-build.js" agent --prompt-json=[]' ])('does not claim interactive ownership for %s', (line) => { expect(recognizeAgentProcessFromCommandLine(line)).toBeNull() @@ -37,24 +25,12 @@ describe('DeepSeek Build native one-shot identity exclusion', () => { it.each([ 'dsb agent "explain --print and -p"', - 'deepseek-build-agent -- "--print"', 'dsb agent -- -- "--print"', 'deepseek-build-agent --model=--print', - 'deepseek-build-agent --rules "use --prompt-file"', 'deepseek-build-agent --system-prompt "run"', - 'dsb agent --model run', 'dsb agent --resume saved-session', - 'deepseek-build-agent run', - 'dsb agent run', 'deepseek-build-agent --leader-socket run', - 'deepseek-build-agent -mp', - 'deepseek-build-agent -rp', - 'deepseek-build-agent -sp', - 'deepseek-build-agent -wp', - 'deepseek-build-agent -cmp', - 'deepseek-build-agent -crp', - 'deepseek-build-agent -csp', - 'deepseek-build-agent -cwp' + 'deepseek-build-agent -cmp' ])('keeps interactive prompts and option values interactive: %s', (line) => { expect(recognizeAgentProcessFromCommandLine(line)?.agent).toBe('dsb') }) diff --git a/src/shared/dsh-real-cli.test.ts b/src/shared/dsh-real-cli.test.ts deleted file mode 100644 index dc0c21a4974..00000000000 --- a/src/shared/dsh-real-cli.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' -import { runProcess } from '@orca/process-host' -import { recognizeAgentProcessFromCommandLine } from './agent-status/process/agent-process-recognition' - -const binary = process.env.ORCA_REAL_DSH_CLI - -// No model call: published profile configuration and help establish the launcher contract. -describe.skipIf(!binary)('published DSH 0.2 CLI', () => { - it('keeps positional and explicit headless profiles outside interactive recognition', async () => { - if (!binary) { - throw new Error('Set ORCA_REAL_DSH_CLI to the official dsh executable') - } - const root = await mkdtemp(join(tmpdir(), 'orca-dsh-cli-')) - const env = { ...process.env, DSH_HOME: root, DEEPSEEK_API_KEY: '' } - try { - const version = await runProcess({ program: binary, args: ['--version'], cwd: root, env }) - expect(version.code).toBe(0) - expect(version.stdout.trim()).toMatch(/^0\.2\./) - for (const profile of ['headless', 'sdk', 'sdk-minimal', 'acp']) { - for (const args of [[profile], ['--profile', profile]]) { - const result = await runProcess({ - program: binary, - args: [...args, '--dump-default-config'], - cwd: root, - env, - timeoutMs: 30_000 - }) - expect(result.code).toBe(0) - expect(result.stdout).toContain('@deepseek-ai/dsh-') - const command = `"${binary}" ${args.join(' ')}` - expect(recognizeAgentProcessFromCommandLine(command)).toBeNull() - expect( - recognizeAgentProcessFromCommandLine(command, { includeHeadlessOneShot: true })?.agent - ).toBe('dsh') - } - } - const help = await runProcess({ - program: binary, - args: ['headless', '--help'], - cwd: root, - env, - timeoutMs: 30_000 - }) - expect(help.code).toBe(0) - expect(help.stdout).toContain('Answer one task and exit') - } finally { - await rm(root, { recursive: true, force: true }) - } - }, 120_000) -}) From ac019ef8dfb3f1a8fe683bf6c4f65ca68fae301b Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 13 Nov 2026 05:53:13 -0800 Subject: [PATCH 26/55] test: remove low-value relay assignment coverage (#42031) Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- ...ignment-dead-reserve-cell-postgres.test.ts | 58 -------- ...ignment-deployment-status-postgres.test.ts | 23 --- .../assignment-headroom-scope.test.ts | 62 ++------ .../assignment-home-cell-unavailable.test.ts | 9 -- .../assignment-inventory-snapshot.test.ts | 108 -------------- ...isolated-cell-replacement-postgres.test.ts | 5 +- .../src/assignment/assignment-lease.test.ts | 138 +----------------- .../dead-cell-evacuation-precheck.test.ts | 20 --- .../public-assignment-circuit-breaker.test.ts | 49 ------- .../public-assignment-reconnect-lane.test.ts | 20 --- .../idle-regional-rehome-store.test.ts | 14 -- .../idle-regional-rehome-window.test.ts | 19 --- 12 files changed, 16 insertions(+), 509 deletions(-) delete mode 100644 cloud/apps/relay/src/assignment/assignment-inventory-snapshot.test.ts delete mode 100644 cloud/apps/relay/src/assignment/public-admission/public-assignment-circuit-breaker.test.ts diff --git a/cloud/apps/relay/src/assignment/assignment-dead-reserve-cell-postgres.test.ts b/cloud/apps/relay/src/assignment/assignment-dead-reserve-cell-postgres.test.ts index 04c62668610..b69c04743c9 100644 --- a/cloud/apps/relay/src/assignment/assignment-dead-reserve-cell-postgres.test.ts +++ b/cloud/apps/relay/src/assignment/assignment-dead-reserve-cell-postgres.test.ts @@ -169,7 +169,6 @@ describePostgres('a host leaving a dead reserve cell, on PostgreSQL', () => { const ledger = new ReserveLedgerWriter(database, { now: () => now, log: () => undefined }) for (const epoch of [6, 7]) ledger.enqueue({ userId: USER, relayHostId: host, cellId: dead.id, epoch }) await ledger.flush() - expect(ledger.pending()).toBe(0) expect(await rowOf(host)).toEqual({ cell_id: live.id, assignment_epoch: 7 }) }) @@ -181,63 +180,6 @@ describePostgres('a host leaving a dead reserve cell, on PostgreSQL', () => { await expect(hostAssign(host, true, null)).rejects.toThrow('relay_home_cell_unavailable') }) - it('a withheld verdict (fleet or region guard) keeps the fence', async () => { - const host = nextHost() - await ledgerRow(host, 3) - await store.setCellAdmitMode(dead.id, 'reserve') - await killDeadCell() - store = new RelayAssignmentStore(database, () => now, { - requireLiveCells: true, - heartbeatTtlMs: HEARTBEAT_TTL_MS, - deadCellVerdict: () => ({ - dead: false, - withheld: 'region', - fleetFailing: 6, - fleetPolled: 34, - regionFailing: 6, - regionPolled: 6 - }) - }) - await expect(hostAssign(host)).rejects.toThrow('relay_home_cell_unavailable') - }) - - // Only a heartbeat that stopped (cause 'unheard') is death; polls failing for 45 s add nothing else. - it('a cell heartbeating but not ready keeps its hosts, however long its polls fail', async () => { - const host = nextHost() - await ledgerRow(host, 3) - await store.setCellAdmitMode(dead.id, 'reserve') - now += HEARTBEAT_TTL_MS + 15_000 - await heartbeat(dead, false) - await heartbeat(live) - await expect(hostAssign(host)).rejects.toThrow('relay_home_cell_unavailable') - expect(await rowOf(host)).toEqual({ cell_id: dead.id, assignment_epoch: 3 }) - }) - - it('drain then death keeps the fence: a disabled cell is draining, not unheard', async () => { - const host = nextHost() - await ledgerRow(host, 3) - await store.setCellAdmitMode(dead.id, 'reserve') - await database.query(`UPDATE relay_cells SET enabled = 0 WHERE cell_id = ?`, [dead.id]) - try { - await killDeadCell() - await expect(hostAssign(host)).rejects.toThrow('relay_home_cell_unavailable') - expect(await rowOf(host)).toEqual({ cell_id: dead.id, assignment_epoch: 3 }) - } finally { - await database.query(`UPDATE relay_cells SET enabled = 1 WHERE cell_id = ?`, [dead.id]) - } - }) - - it('LB drain: heartbeat fresh while polls fail, so the host is pinned to its cell', async () => { - const host = nextHost() - await ledgerRow(host, 3) - await store.setCellAdmitMode(dead.id, 'reserve') - now += 10_000 - await heartbeat(dead) - await heartbeat(live) - expect(await hostAssign(host)).toMatchObject({ cellId: dead.id, assignmentEpoch: 3 }) - expect(await rowOf(host)).toEqual({ cell_id: dead.id, assignment_epoch: 3 }) - }) - it('DB outage or director freeze: heartbeat stale but polls answered (no verdict), so 503', async () => { const host = nextHost() await ledgerRow(host, 3) diff --git a/cloud/apps/relay/src/assignment/assignment-deployment-status-postgres.test.ts b/cloud/apps/relay/src/assignment/assignment-deployment-status-postgres.test.ts index ad45a84cba9..407573810d2 100644 --- a/cloud/apps/relay/src/assignment/assignment-deployment-status-postgres.test.ts +++ b/cloud/apps/relay/src/assignment/assignment-deployment-status-postgres.test.ts @@ -84,27 +84,4 @@ describePostgres('PostgreSQL deployment status', () => { restartBlockingReservedRequests: -1 }) }) - - it('reads the same counts through the reserve-aware status, and none for a reserve-mode cell', async () => { - const store = new RelayAssignmentStore(database!, () => 100) - const reserveCell = { ...cell, id: `${cell.id}-reserve`, url: 'https://deployment-status-reserve.example.com' } - await store.reconcileCells([cell, reserveCell]) - await store.assign({ userId: 'aware-user', relayHostId: 'd1e2f3a4b5c6' }) - expect(await store.reserveAwareCellDeploymentStatus(cell.id)).toEqual({ - admitMode: 'db', - status: await store.cellDeploymentStatus(cell.id) - }) - - await store.setCellAdmitMode(reserveCell.id, 'reserve') - await expect(store.cellDeploymentStatus(reserveCell.id)).rejects.toThrow('cell_in_reserve_mode') - const reserve = await store.reserveAwareCellDeploymentStatus(reserveCell.id) - expect(reserve).toMatchObject({ - admitMode: 'reserve', - status: { cellId: reserveCell.id, cellUrl: reserveCell.url, enabled: true, capacityRequests: 20 } - }) - for (const field of ['assignments', 'activityLeases', 'reservedRequests', 'restartBlockingReservedRequests']) { - expect(reserve.status).not.toHaveProperty(field) - } - await expect(store.reserveAwareCellDeploymentStatus('missing-cell')).rejects.toThrow('cell_not_found') - }) }) diff --git a/cloud/apps/relay/src/assignment/assignment-headroom-scope.test.ts b/cloud/apps/relay/src/assignment/assignment-headroom-scope.test.ts index d7ba52e9682..7f2beb8c1db 100644 --- a/cloud/apps/relay/src/assignment/assignment-headroom-scope.test.ts +++ b/cloud/apps/relay/src/assignment/assignment-headroom-scope.test.ts @@ -3,42 +3,27 @@ import { RelayAssignmentStore } from './assignment-store.js' import { openInMemoryRelayDatabase, type RelayDatabase } from '../database/database.js' const NOW = 100_000 -const CELL_COUNT = 32 const identity = { userId: 'headroom-user', relayHostId: 'headroomhost0001' } -function observeHeadroom(database: RelayDatabase, rowCounts: number[]): RelayDatabase { - return { - dialect: database.dialect, - async query(sql, params) { - const rows = await database.query(sql, params) - if (sql.includes('FROM relay_cell_connection_limits limits')) rowCounts.push(rows.length) - return rows - }, - queryLocked: (sql, params, options) => database.queryLocked(sql, params, options), - transaction: (operation, options) => - database.transaction((transaction) => operation(observeHeadroom(transaction, rowCounts)), options), - close: () => database.close() - } -} - -describe('assignment headroom query scope', () => { +describe('assignment headroom admission', () => { let database: RelayDatabase | undefined afterEach(async () => await database?.close()) async function setup() { database = await openInMemoryRelayDatabase() - const rowCounts: number[] = [] - const store = new RelayAssignmentStore(observeHeadroom(database, rowCounts), () => NOW, { + const store = new RelayAssignmentStore(database, () => NOW, { requireLiveCells: true, heartbeatTtlMs: 45_000 }) - const cells = Array.from({ length: CELL_COUNT }, (_, index) => ({ - id: `cell-${String(index).padStart(2, '0')}`, - url: `https://cell-${index}.example.com`, - capacityRequests: 1_000, - connectionHardCap: 600 as const, - connectionUnobservedBound: 50 - })) + const cells = [ + { + id: 'cell-00', + url: 'https://cell-0.example.com', + capacityRequests: 1_000, + connectionHardCap: 600 as const, + connectionUnobservedBound: 50 + } + ] await store.reconcileCells(cells) for (const cell of cells) { await store.recordCellHeartbeat({ @@ -69,29 +54,9 @@ describe('assignment headroom query scope', () => { kind: 'splice', cellId: 'cell-00' }) - rowCounts.length = 0 - return { store, rowCounts, database } + return { store, database } } - it('reads one cell for a sticky assignment even when the capped fleet grows', async () => { - const { store, rowCounts } = await setup() - await expect(store.assign(identity)).resolves.toMatchObject({ - ...identity, - cellId: 'cell-00', - assignmentEpoch: 1 - }) - console.info('sticky headroom rows', { fleetCells: CELL_COUNT, rowCounts }) - expect(rowCounts).toEqual([1]) - }) - - it('still considers the full fleet for a new placement', async () => { - const { store, rowCounts } = await setup() - await expect( - store.assign({ userId: 'new-user', relayHostId: 'newheadroomhost1' }) - ).resolves.toMatchObject({ cellId: 'cell-01', assignmentEpoch: 1 }) - expect(rowCounts).toEqual([CELL_COUNT]) - }) - it.each([ ['missing snapshot', `DELETE FROM relay_cell_connection_snapshots WHERE cell_id = 'cell-00'`], ['expired snapshot', `UPDATE relay_cell_connection_snapshots SET snapshot_at = ${NOW - 45_000} WHERE cell_id = 'cell-00'`], @@ -104,10 +69,9 @@ describe('assignment headroom query scope', () => { }) it('preserves admission for cells without a connection limit', async () => { - const { store, rowCounts, database } = await setup() + const { store, database } = await setup() await database.query(`DELETE FROM relay_cell_connection_limits WHERE cell_id = 'cell-00'`) await expect(store.assign(identity)).resolves.toMatchObject({ cellId: 'cell-00' }) - expect(rowCounts).toEqual([0]) }) it('counts outstanding reservations at the admission boundary', async () => { diff --git a/cloud/apps/relay/src/assignment/assignment-home-cell-unavailable.test.ts b/cloud/apps/relay/src/assignment/assignment-home-cell-unavailable.test.ts index 45cf0bf704c..3227d64e82b 100644 --- a/cloud/apps/relay/src/assignment/assignment-home-cell-unavailable.test.ts +++ b/cloud/apps/relay/src/assignment/assignment-home-cell-unavailable.test.ts @@ -106,15 +106,6 @@ describe('home cell unavailable', () => { expect(homeCellError(await assignFailure(store)).unavailableCause).toBe('draining') }) - it('still reports capacity exhaustion when the fleet has no headroom', async () => { - const { store } = await setup([{ ...FENCED_CELL, capacityRequests: 1 }]) - await store.assign(IDENTITY) - - await expect( - store.assign({ userId: 'user-2', relayHostId: 'host000000000002' }) - ).rejects.toThrow('relay_capacity_exhausted') - }) - it('rehomes instead of rejecting when the unavailable cell needs no fence', async () => { const home: RelayCellConfig = { id: 'home', diff --git a/cloud/apps/relay/src/assignment/assignment-inventory-snapshot.test.ts b/cloud/apps/relay/src/assignment/assignment-inventory-snapshot.test.ts deleted file mode 100644 index 006c7346dd8..00000000000 --- a/cloud/apps/relay/src/assignment/assignment-inventory-snapshot.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - formatAssignmentInventorySnapshot, - readAssignmentInventorySnapshot -} from './assignment-inventory-snapshot.js' -import { openInMemoryRelayDatabase } from '../database/database.js' - -describe('assignment inventory snapshot', () => { - it('reports per-cell counters, lease backlog, and reservation debt', async () => { - const database = await openInMemoryRelayDatabase() - const now = 1_000_000 - await database.query( - `INSERT INTO relay_cells - (cell_id, cell_url, enabled, capacity_requests, reserved_requests, - observed_requests, last_heartbeat_at, updated_at) - VALUES ('cell-a', 'https://a.example.test', 1, 4000, 3999, 5, ?, ?)`, - [now, now] - ) - await database.query( - `INSERT INTO relay_cell_admission (cell_id, admission_state, updated_at) - VALUES ('cell-a', 'general', ?)`, - [now] - ) - await database.query( - `INSERT INTO relay_cell_runtime - (cell_id, cell_url, cell_incarnation, started_at, ready, observed_requests, - last_heartbeat_at, updated_at) - VALUES ('cell-a', 'https://a.example.test', 'inc-1', ?, 1, 5, ?, ?)`, - [now - 60_000, now - 10_000, now] - ) - await database.query( - `INSERT INTO relay_assignment_activity_leases - (user_id, relay_host_id, activity_id, activity_kind, cell_id, request_units, - expires_at, updated_at) - VALUES - ('user-1', 'host-1', 'control:1', 'control', 'cell-a', 1, ?, ?), - ('user-1', 'host-2', 'control:1', 'control', 'cell-a', 3, ?, ?)`, - [now - 1, now, now + 90_000, now] - ) - await database.query( - `INSERT INTO relay_control_connection_reservations - (reservation_id, idempotency_key, user_id, relay_host_id, assignment_epoch, - cell_id, state, created_at, timeout_at, updated_at) - VALUES - ('r1', 'k1', 'user-1', 'host-1', 1, 'cell-a', 'late-arrival-debt', ?, ?, ?), - ('r2', 'k2', 'user-1', 'host-2', 1, 'cell-a', 'reserved', ?, ?, ?), - ('r3', 'k3', 'user-1', 'host-3', 1, 'cell-a', 'claimed', ?, ?, ?), - ('r4', 'k4', 'user-1', 'host-4', 1, 'cell-a', 'released', ?, ?, ?)`, - [now, now, now, now, now, now, now, now, now, now, now, now] - ) - - const snapshot = await readAssignmentInventorySnapshot(database, now) - - expect(snapshot.cells).toEqual([ - { - cellId: 'cell-a', - region: 'us-central1', - admissionState: 'general', - enabled: true, - capacityRequests: 4000, - reservedRequests: 3999, - runtimeReady: true, - heartbeatAgeMs: 10_000 - } - ]) - expect(snapshot.activityLeases).toEqual({ total: 2, expired: 1, requestUnits: 4 }) - expect(snapshot.connectionReservations).toEqual({ outstanding: 3, lateArrivalDebt: 1 }) - expect(snapshot.regionalRehomes).toEqual({ - active: 0, - awaitingReceipt: 0, - targetRegistered: 0, - completedLast24Hours: 0, - abortedLast24Hours: 0, - hostNotArrivedLast24Hours: 0, - oldestActiveAgeMs: null - }) - - const lines = formatAssignmentInventorySnapshot(snapshot) - expect(lines).toHaveLength(3) - expect(lines[0]).toContain('cellId=cell-a') - expect(lines[0]).toContain('reserved=3999') - expect(lines[1]).toContain('expiredLeases=1') - expect(lines[1]).toContain('lateArrivalDebt=1') - expect(lines[2]).toContain('active=0') - }) - - it('reports cells missing runtime and admission rows without failing', async () => { - const database = await openInMemoryRelayDatabase() - await database.query( - `INSERT INTO relay_cells - (cell_id, cell_url, enabled, capacity_requests, reserved_requests, - observed_requests, last_heartbeat_at, updated_at) - VALUES ('cell-b', 'https://b.example.test', 0, 4000, 0, 0, 0, 0)` - ) - - const snapshot = await readAssignmentInventorySnapshot(database, 5_000) - - expect(snapshot.cells[0]).toMatchObject({ - cellId: 'cell-b', - admissionState: 'unset', - enabled: false, - runtimeReady: null, - heartbeatAgeMs: null - }) - expect(snapshot.activityLeases).toEqual({ total: 0, expired: 0, requestUnits: 0 }) - expect(snapshot.regionalRehomes.active).toBe(0) - }) -}) diff --git a/cloud/apps/relay/src/assignment/assignment-isolated-cell-replacement-postgres.test.ts b/cloud/apps/relay/src/assignment/assignment-isolated-cell-replacement-postgres.test.ts index 8e533d9468b..e5ef361f92d 100644 --- a/cloud/apps/relay/src/assignment/assignment-isolated-cell-replacement-postgres.test.ts +++ b/cloud/apps/relay/src/assignment/assignment-isolated-cell-replacement-postgres.test.ts @@ -302,7 +302,7 @@ describePostgres('PostgreSQL re-placement off a cell isolated for a roll', () => ).toBe(targetBaseline + 1) }, 30_000) - it('never waits on the assignment row while its retry holds the pinned cell row', async () => { + it('refuses the assignment row when its retry holds the pinned cell row', async () => { await resetFleet() const identity = hostIdentity(923) const first = await stores[0]!.assign(identity, 'us-central1') @@ -324,10 +324,7 @@ describePostgres('PostgreSQL re-placement off a cell isolated for a roll', () => [identity.userId, identity.relayHostId] ) await releaseCell() - const cellFreedAt = performance.now() expect(await regrant).toBeInstanceOf(RelayAssignmentRowBusyError) - // NOWAIT once the cell row is held: nowhere near the 1s bounded wait. - expect(performance.now() - cellFreedAt).toBeLessThan(300) } finally { await releaseAssignment?.() await releaseCell() diff --git a/cloud/apps/relay/src/assignment/assignment-lease.test.ts b/cloud/apps/relay/src/assignment/assignment-lease.test.ts index 96e2807eac8..2beb0b6bbba 100644 --- a/cloud/apps/relay/src/assignment/assignment-lease.test.ts +++ b/cloud/apps/relay/src/assignment/assignment-lease.test.ts @@ -1,22 +1,10 @@ -import { createHash } from 'node:crypto' -import { EventEmitter } from 'node:events' -import { RELAY_CLOSE_CODE } from '@orca-cloud/relay-contract/close-codes' import { SignJWT } from 'jose' -import nacl from 'tweetnacl' -import { describe, expect, it, vi } from 'vitest' -import type WebSocket from 'ws' +import { describe, expect, it } from 'vitest' import { ASSIGNMENT_LEASE_AUDIENCE, assignmentLeaseKeyId, classifyAssignmentLease } from './assignment-lease.js' -import { AssignmentLeaseShadow } from './assignment-lease-shadow.js' -import type { RelayAssignmentStore } from './assignment-store.js' -import type { RelayConfig } from '../config.js' -import type { RelayCredentialStore } from '../credentials/credential-store.js' -import { HostSessionRegistry } from '../connections/host-session-registry.js' -import type { RelayTokenClaims } from '../authentication/relay-token-verifier.js' -import { ProcessQueuedByteBudget } from '../connections/splice-forwarder.js' const KEY = new TextEncoder().encode('0123456789abcdef0123456789abcdef') const OTHER_KEY = new TextEncoder().encode('fedcba9876543210fedcba9876543210') @@ -30,7 +18,6 @@ async function lease( cellId?: string assignmentEpoch?: number sub?: string - relayHostId?: string expiresAt?: string | number } = {} ): Promise { @@ -40,7 +27,7 @@ async function lease( cellId: overrides.cellId ?? CELL_ID, cellUrl: 'https://c7.relay.example.test', assignmentEpoch: overrides.assignmentEpoch ?? 4, - relayHostId: overrides.relayHostId ?? HOST.relayHostId + relayHostId: HOST.relayHostId }) .setProtectedHeader(kid === null ? { alg: 'HS256' } : { alg: 'HS256', kid }) .setIssuer('https://relay.example.test') @@ -80,124 +67,3 @@ describe('assignment lease classification', () => { expect(await classify(await lease({ assignmentEpoch: 5 }))).toBe('epoch-ahead') }) }) - -class FakeSocket extends EventEmitter { - readonly OPEN = 1 - readonly CLOSED = 3 - readyState = this.OPEN - readonly send = vi.fn() - readonly close = vi.fn((code?: number) => { - this.readyState = this.CLOSED - this.emit('close', code, Buffer.from('')) - }) -} - -describe('host hello with the lease shadow on', () => { - async function hello(input: { withLease: boolean; databaseSays: boolean }) { - const keyPair = nacl.box.keyPair() - const relayHostId = createHash('sha256') - .update(keyPair.publicKey) - .digest('base64url') - .slice(0, 16) - const identity: RelayTokenClaims = { - sub: HOST.userId, - prof: 'profile-1', - relayHostId, - purpose: 'host-control', - exp: 4_102_444_800 - } - const recorded: string[] = [] - const shadow = new AssignmentLeaseShadow({ enabled: () => true, key: KEY, cellId: CELL_ID }) - const record = shadow.record.bind(shadow) - vi.spyOn(shadow, 'record').mockImplementation((check, dbValid, dbReadMs) => { - void check.then((leaseClass) => recorded.push(`${leaseClass}:${dbValid}`)) - record(check, dbValid, dbReadMs) - }) - const verifyCellAssignment = vi.fn(async () => input.databaseSays) - const registry = new HostSessionRegistry( - config(), - vi.fn(), - {} as RelayCredentialStore, - { verifyCellAssignment } as unknown as RelayAssignmentStore, - new ProcessQueuedByteBudget(), - { - recordAuth: vi.fn(), - recordForwardedBytes: vi.fn(), - recordHttp: vi.fn(), - recordReconnect: vi.fn(), - recordSql: vi.fn() - }, - Date.now, - Math.random, - 'incarnation-1', - shadow - ) - const socket = new FakeSocket() - const hostLease = input.withLease ? await lease({ relayHostId }) : undefined - registry.acceptControl( - socket as unknown as WebSocket, - identity, - undefined, - undefined, - hostLease - ) - socket.emit( - 'message', - Buffer.from( - JSON.stringify({ - type: 'host-hello', - v: 1, - relayHostId, - assignmentEpoch: 4, - hostPublicKeyB64: Buffer.from(keyPair.publicKey).toString('base64'), - appVersion: '1.4.200' - }) - ), - false - ) - await vi.waitFor(() => expect(recorded).toHaveLength(1)) - return { socket } - } - - it('still refuses a host the database refuses, whatever its lease says', async () => { - const { socket } = await hello({ withLease: true, databaseSays: false }) - expect(socket.close).toHaveBeenCalledWith(RELAY_CLOSE_CODE.WRONG_CELL, 'wrong assignment epoch') - }) - - it('still admits a host the database admits, with no lease at all', async () => { - const { socket } = await hello({ - withLease: false, - databaseSays: true - }) - expect(socket.close).not.toHaveBeenCalled() - expect(String(socket.send.mock.calls[0]?.[0])).toContain('"type":"host-challenge"') - }) -}) - -function config(): RelayConfig { - return { - port: 8080, - publicUrl: 'https://c7.relay.example.test', - cellUrl: 'https://c7.relay.example.test', - authIssuer: 'https://auth.example.test', - authAudience: 'orca-relay', - jwksUrl: 'https://auth.example.test/jwks', - assignmentSigningKey: KEY, - role: 'cell', - cellId: CELL_ID, - cells: [], - adminAudience: 'https://relay.example.test/v1/admin/drain', - deployServiceAccount: 'deploy@example.test', - runtimeServiceAccount: 'relay-cell@example.test', - adminJwksUrl: 'https://auth.example.test/jwks', - databasePoolMax: 10, - publicAssignmentsEnabled: true, - publicAssignmentConcurrency: 2, - publicAssignmentQueueMax: 128, - publicAssignmentWaitMs: 4_000, - publicResolveConcurrency: 1, - publicResolveWaitMs: 5_000, - publicAssignmentRetryAfterSeconds: 5, - dataDir: './data' - } -} diff --git a/cloud/apps/relay/src/assignment/migrations/dead-cell-evacuation-precheck.test.ts b/cloud/apps/relay/src/assignment/migrations/dead-cell-evacuation-precheck.test.ts index 7a186562d18..4a1a2aff115 100644 --- a/cloud/apps/relay/src/assignment/migrations/dead-cell-evacuation-precheck.test.ts +++ b/cloud/apps/relay/src/assignment/migrations/dead-cell-evacuation-precheck.test.ts @@ -80,26 +80,6 @@ describe.each(dialects)('dead-cell evacuation pre-check (%s)', (_dialect, open) }) } - it('keeps hosts on an unfenced dead existing-only cell', async () => { - let now = 100 - const { store } = await setup(() => now) - for (const cell of CELLS) await heartbeat(store, cell) - const identity = { userId: 'user-a', relayHostId: 'host000000000001' } - await store.setCellEnabled('cell-b', false) - expect(await store.assign(identity)).toMatchObject({ cellId: 'cell-a' }) - await store.setCellEnabled('cell-b', true) - await store.setCellEnabled('cell-a', false) - now += 45_001 - await heartbeat(store, CELLS[1]!) - - expect(await store.evacuateDeadCells()).toBe(0) - expect( - await database!.query(`SELECT cell_id FROM relay_assignments WHERE user_id = ?`, [ - identity.userId - ]) - ).toEqual([{ cell_id: 'cell-a' }]) - }) - it('still evacuates hosts from a dead uncapped cell that admits', async () => { let now = 100 const { store } = await setup(() => now) diff --git a/cloud/apps/relay/src/assignment/public-admission/public-assignment-circuit-breaker.test.ts b/cloud/apps/relay/src/assignment/public-admission/public-assignment-circuit-breaker.test.ts deleted file mode 100644 index 5117e5bbe79..00000000000 --- a/cloud/apps/relay/src/assignment/public-admission/public-assignment-circuit-breaker.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { createRelayApp } from '../../app.js' -import type { RelayConfig } from '../../config.js' - -describe('public assignment circuit breaker', () => { - it('rejects public assignment routes while disabled', async () => { - const app = createRelayApp(config(), { - store: {} as never, - assignments: {} as never, - drain: vi.fn(), - ready: vi.fn(async () => true) - }) - - for (const path of ['/v1/assign', '/v1/resolve']) { - const response = await app.request(path, { method: 'POST' }) - expect(response.status).toBe(503) - expect(response.headers.get('retry-after')).toBe('5') - expect(await response.json()).toEqual({ error: 'assignments_temporarily_unavailable' }) - } - }) -}) - -function config(): RelayConfig { - return { - port: 8080, - publicUrl: 'https://relay.example.test', - cellUrl: 'https://relay.example.test', - authIssuer: 'https://auth.example.test', - authAudience: 'orca-relay', - jwksUrl: 'https://auth.example.test/jwks', - assignmentSigningKey: new TextEncoder().encode('assignment-key-with-at-least-32-bytes'), - role: 'director', - cellId: 'director', - cells: [], - adminAudience: 'https://relay.example.test/v1/admin/drain', - deployServiceAccount: 'deploy@example.test', - runtimeServiceAccount: 'runtime@example.test', - adminJwksUrl: 'https://auth.example.test/jwks', - databasePoolMax: 3, - publicAssignmentsEnabled: false, - publicAssignmentConcurrency: 2, - publicAssignmentQueueMax: 128, - publicAssignmentWaitMs: 4_000, - publicResolveConcurrency: 1, - publicResolveWaitMs: 5_000, - publicAssignmentRetryAfterSeconds: 5, - dataDir: './data' - } -} diff --git a/cloud/apps/relay/src/assignment/public-admission/public-assignment-reconnect-lane.test.ts b/cloud/apps/relay/src/assignment/public-admission/public-assignment-reconnect-lane.test.ts index 06b85e55356..1a2b795252a 100644 --- a/cloud/apps/relay/src/assignment/public-admission/public-assignment-reconnect-lane.test.ts +++ b/cloud/apps/relay/src/assignment/public-admission/public-assignment-reconnect-lane.test.ts @@ -64,26 +64,6 @@ describe('public assignment reconnect lane', () => { expect((await second).status).toBe(200) }) - it('rate-limits repeat fast-lane attempts with the short retry-after', async () => { - const host = 'rrrrrrrrrrrrrrrr' - const assign = vi.fn(async () => assignment('cell-r', host)) - const resolve = vi.fn(async () => assignment('cell-r', host)) - const app = createRelayApp(config(), { - store: {} as never, - assignments: { assign, resolve } as never, - drain: vi.fn(), - ready: vi.fn(async () => true) - }) - - expect( - (await app.request('/v1/assign', assignmentRequest(host, { reconnect: true }))).status - ).toBe(200) - const repeat = await app.request('/v1/assign', assignmentRequest(host, { reconnect: true })) - - expect(repeat.status).toBe(503) - expect(repeat.headers.get('retry-after')).toBe('2') - }) - it('rejects on the fast lane when the verification probe fails transiently', async () => { const host = 'tttttttttttttttt' const assign = vi.fn() diff --git a/cloud/apps/relay/src/assignment/regional-rehome/idle-regional-rehome-store.test.ts b/cloud/apps/relay/src/assignment/regional-rehome/idle-regional-rehome-store.test.ts index 395bb453998..18a95333bbb 100644 --- a/cloud/apps/relay/src/assignment/regional-rehome/idle-regional-rehome-store.test.ts +++ b/cloud/apps/relay/src/assignment/regional-rehome/idle-regional-rehome-store.test.ts @@ -322,20 +322,6 @@ describe('constrained idle regional assignment transaction', () => { expect(await database.query('SELECT * FROM relay_region_rehome_attempts')).toEqual([]) }) - it('selects read-only with stable identity and the control generation, not probe generation', async () => { - const { store, safety, database } = await setup() - const before = await database.query('SELECT * FROM relay_assignments') - const candidates = await store.selectIdleRegionalRehomeCandidates(safety) - expect(candidates).toHaveLength(1) - expect(candidates[0]).toMatchObject({ - sourceGeneration: 7, - sourceCellId: 'source', - targetCellId: 'target' - }) - expect(await store.selectIdleRegionalRehomeCandidates(safety)).toEqual(candidates) - expect(await database.query('SELECT * FROM relay_assignments')).toEqual(before) - expect(await database.query('SELECT * FROM relay_region_rehome_attempts')).toEqual([]) - }) }) function holdStatement(database: RelayDatabase, fragment: string) { diff --git a/cloud/apps/relay/src/assignment/regional-rehome/idle-regional-rehome-window.test.ts b/cloud/apps/relay/src/assignment/regional-rehome/idle-regional-rehome-window.test.ts index a53521e721d..6c4c559f06f 100644 --- a/cloud/apps/relay/src/assignment/regional-rehome/idle-regional-rehome-window.test.ts +++ b/cloud/apps/relay/src/assignment/regional-rehome/idle-regional-rehome-window.test.ts @@ -239,23 +239,4 @@ describe('idle regional rehome candidate window', () => { expect(await store.selectIdleRegionalRehomeCandidates(safety)).toHaveLength(3) }) - it('excludes a host outside the cohort', async () => { - const { store, database } = await setup() - await seedHost(store, identity) - const now = 100_000_000 + 86_400_000 - const safety = { - observedAt: now, - sqlFailures: 0, - reconnects: 0, - controlActivityRecoveryFailures: 0, - databasePoolWaiting: 0, - databasePoolWaitersMax: 0, - databasePoolWaitMsMax: 0 - } - await database.query('UPDATE relay_region_decisions SET cohort_bucket = 40') - const narrow = new RelayAssignmentStore(database, () => now, { regionalRehomeCohortPercent: 40 }) - expect(await narrow.selectIdleRegionalRehomeCandidates(safety)).toEqual([]) - const wide = new RelayAssignmentStore(database, () => now, { regionalRehomeCohortPercent: 41 }) - expect(await wide.selectIdleRegionalRehomeCandidates(safety)).toHaveLength(3) - }) }) From c2deee70df750dff910418d2996a037117cb50d6 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 13 Nov 2026 05:54:19 -0800 Subject: [PATCH 27/55] refactor(mobile): organize platform adapters by capability (#42030) --- .../mobile-web-app-external-link-seam.mjs | 2 +- .../mobile-web-app-haptics-seam.mjs | 8 +- .../mobile-web-app-media-picker-seam.mjs | 2 +- ...bile-web-app-text-input-font-size-seam.mjs | 2 +- .../mobile-web-app-page-grant-call-sites.mjs | 8 +- ...le-web-app-composer-sizing-render.test.mjs | 2 +- mobile/app/mobile-onboarding.tsx | 2 +- mobile/app/pair-confirm.tsx | 2 +- mobile/app/pair-scan.tsx | 2 +- .../MobileAgentSessionHistoryPanel.tsx | 2 +- mobile/src/agents/MobileAgentListRows.tsx | 2 +- .../src/agents/use-agent-list-row-actions.tsx | 2 +- .../automations/AutomationManager.test.tsx | 4 +- mobile/src/automations/automation-styles.ts | 2 +- .../pane-view/MobileBrowserLoadProgress.tsx | 2 +- .../pane-view/mobile-browser-pane-styles.ts | 2 +- mobile/src/components/AgentLaunchNotice.tsx | 2 +- mobile/src/components/AgentSpinner.tsx | 2 +- mobile/src/components/AgentStateDot.tsx | 2 +- mobile/src/components/AnchoredDetailCard.tsx | 2 +- mobile/src/components/AutoHeight.tsx | 2 +- mobile/src/components/DisclosureChevron.tsx | 2 +- mobile/src/components/DragReorderList.tsx | 2 +- mobile/src/components/DragReorderRow.tsx | 2 +- mobile/src/components/LoadingSpinner.tsx | 2 +- .../components/MobileDictationSetupSheet.tsx | 2 +- .../src/components/MobileDiffReviewLines.tsx | 2 +- .../components/MobileRichMarkdownEditor.tsx | 2 +- .../src/components/MobileSegmentedControl.tsx | 2 +- .../src/components/MobileSourceCopyButton.tsx | 2 +- mobile/src/components/MobileSwitch.tsx | 4 +- mobile/src/components/ProtocolBlockScreen.tsx | 2 +- mobile/src/components/WorktreeListRow.tsx | 2 +- .../bottom-drawer-back-while-hiding.test.tsx | 6 +- .../bottom-drawer/bottom-drawer-constants.ts | 2 +- .../bottom-drawer/mounted-bottom-drawer.tsx | 5 +- .../bottom-drawer/option-pager.test.tsx | 123 ---------- .../components/composer-input/MessageBox.tsx | 2 +- ...drag-reorder-test-harness.test-fixture.tsx | 5 +- mobile/src/components/list/ListGroup.tsx | 2 +- .../src/components/markdown-link-handler.ts | 2 +- .../src/components/mobile-markdown-inline.tsx | 2 +- mobile/src/components/mobile-pr-url.ts | 2 +- .../components/nav-bar/BottomSearchBar.tsx | 4 +- mobile/src/components/nav-bar/NavBar.tsx | 2 +- .../src/components/nav-bar/PullDownMenu.tsx | 2 +- .../pr-sidebar/MobileLinkPrForm.tsx | 2 +- .../components/pr-sidebar/PRCommentCard.tsx | 2 +- .../pr-sidebar/PrCheckFailureRow.tsx | 6 +- .../components/pr-sidebar/PrChecksScreen.tsx | 2 +- .../pr-sidebar/PrCommentsScreen.tsx | 2 +- .../components/pr-sidebar/PrCreateSheet.tsx | 2 +- .../src/components/pr-sidebar/PrThreadRow.tsx | 2 +- .../components/pr-sidebar/PrThreadScreen.tsx | 4 +- .../components/pr-sidebar/pr-tab-styles.ts | 2 +- .../components/pr-sidebar/use-pr-tab-menu.ts | 4 +- .../rich-markdown/document-style.ts | 2 +- .../components/use-detail-card-scope.web.ts | 2 +- .../use-new-workspace-create-submit.ts | 2 +- mobile/src/dictation/MobileDictationMeter.tsx | 2 +- .../src/dictation/mobile-dictation-host.tsx | 4 +- .../use-mobile-dictation-announcements.ts | 2 +- .../use-mobile-dictation-cancel-drop.ts | 3 +- .../dictation/use-mobile-dictation-haptics.ts | 2 +- .../MobileChatAttachmentPreview.tsx | 2 +- .../attachments/mobile-agent-image-fit.ts | 2 +- .../attachments/mobile-file-source-picker.ts | 2 +- .../attachments/mobile-image-attachment.ts | 5 +- .../attachments/mobile-image-source-picker.ts | 9 +- .../use-mobile-image-attachment.ts | 4 +- .../use-mobile-session-attachments.ts | 4 +- .../use-mobile-session-image-attachments.ts | 2 +- .../explorer/MobileFileExplorerPanel.test.ts | 6 +- ...mobile-file-explorer-row-actions-sheet.tsx | 4 +- .../explorer/mobile-file-explorer-row.tsx | 2 +- mobile/src/files/media/MobilePdfPreview.tsx | 4 +- .../use-mobile-file-tap-handlers.ts | 2 +- .../use-mobile-session-file-actions.ts | 2 +- .../use-mobile-session-markdown-actions.ts | 4 +- .../MobileFilePreviewEditDraft.tsx | 6 +- mobile/src/home/MobileHomeScreen.tsx | 2 +- .../home/tabs/MobileHomeAutomationsTab.tsx | 2 +- mobile/src/home/tabs/MobileHomeFab.tsx | 2 +- mobile/src/home/tabs/MobileHomeTabBar.tsx | 2 +- mobile/src/home/tabs/MobileHomeUsageTab.tsx | 2 +- .../src/hooks/mobile-dictation-audio-chunk.ts | 2 +- .../mobile-dictation-background.test.tsx | 29 +-- .../hooks/mobile-dictation-capture-open.ts | 2 +- .../hooks/mobile-dictation-device-engine.ts | 4 +- .../src/hooks/mobile-dictation-engine-run.ts | 4 +- mobile/src/hooks/mobile-dictation-finish.ts | 2 +- .../src/hooks/mobile-dictation-held-audio.ts | 2 +- mobile/src/hooks/mobile-dictation-meter.ts | 2 +- .../hooks/mobile-dictation-on-device.test.tsx | 65 +---- .../src/hooks/mobile-dictation-recording.ts | 2 +- .../src/hooks/mobile-dictation-retry.test.tsx | 4 +- .../mobile-dictation-send-window.test.tsx | 38 +-- .../hooks/mobile-dictation-stop-tail.test.tsx | 30 +-- .../use-mobile-dictation-interruptions.ts | 2 +- mobile/src/hooks/use-mobile-dictation.ts | 4 +- mobile/src/hooks/use-phone-dictation-ready.ts | 2 +- .../src/host-screen/host-screen-overlays.tsx | 2 +- .../use-host-worktree-delete.ts | 2 +- mobile/src/layout/side-rail.tsx | 2 +- .../mobile-web-shell/DownloadProgressBar.tsx | 2 +- .../mobile-web-shell/MobileWebShellScreen.tsx | 6 +- .../bridge/bridge-audio-verbs.test.ts | 112 +-------- mobile/src/mobile-web-shell/page-haptics.ts | 2 +- .../new-workspace/NewWorkspacePageScreen.tsx | 2 +- .../NotificationOnboardingPreview.tsx | 2 +- mobile/src/pairing/pairing-visuals.tsx | 2 +- .../accessibility-announcer.ts | 0 .../accessibility-announcer.web.ts | 2 +- .../native-accessibility-announce.ts | 2 +- .../{ => accessibility}/use-reduced-motion.ts | 0 .../dictation-capture-compile-fence.ts | 4 +- .../dictation-capture-contract.ts | 0 .../{ => audio-capture}/dictation-capture.ts | 2 +- .../dictation-capture.web.ts | 11 +- .../microphone-screen-lock.ts | 0 .../native-audio-device.ts | 2 +- .../native-audio-session-ownership.test.ts | 2 +- .../{ => audio-capture}/native-audio.ts | 10 +- .../{ => clipboard}/clipboard.test.tsx | 59 +---- .../src/platform/{ => clipboard}/clipboard.ts | 0 .../platform/{ => clipboard}/clipboard.web.ts | 4 +- .../{ => clipboard}/native-clipboard.ts | 2 +- mobile/src/platform/external-link.test.ts | 49 ---- mobile/src/platform/external-link.web.test.ts | 54 ----- .../{ => external-links}/external-link.ts | 0 .../external-links/external-link.web.test.ts | 25 ++ .../{ => external-links}/external-link.web.ts | 2 +- .../global-timer-receiver-test-fakes.ts | 32 --- mobile/src/platform/{ => haptics}/haptics.ts | 0 .../src/platform/{ => haptics}/haptics.web.ts | 2 +- mobile/src/platform/host-os.web.ts | 2 - .../image-actions-contract.ts | 2 +- .../{ => image-actions}/image-actions.ts | 0 .../{ => image-actions}/image-actions.web.ts | 12 +- .../native-image-actions.ts | 16 +- .../src/platform/keyboard-occlusion.test.tsx | 141 ----------- .../keyboard-close-blur.web.test.ts | 34 --- .../{ => keyboard}/keyboard-close-blur.web.ts | 2 +- .../{ => keyboard}/keyboard-occlusion.ts | 4 +- .../{ => keyboard}/keyboard-occlusion.web.ts | 0 .../keyboard-persisting-taps.ts | 0 .../{ => keyboard}/primary-modifier-key.ts | 2 +- .../src/platform/media-picker-test-shell.ts | 119 --------- .../media-picker-contract.ts | 2 +- .../{ => media-picking}/media-picker.ts | 7 +- .../{ => media-picking}/media-picker.web.ts | 6 +- .../mobile-picked-file-name.ts | 0 .../native-media-device.ts | 4 +- .../native-media-stage-cleanup.test.ts | 2 +- .../{ => media-picking}/native-media.test.ts | 228 +----------------- .../{ => media-picking}/native-media.ts | 16 +- .../platform/microphone-screen-lock.test.ts | 99 -------- .../src/platform/native-media-device.test.ts | 60 ----- .../{ => pdf-viewing}/native-pdf-view.ts | 20 +- .../{ => pdf-viewing}/pdf-opener-contract.ts | 0 .../platform/{ => pdf-viewing}/pdf-opener.ts | 0 .../{ => pdf-viewing}/pdf-opener.web.ts | 6 +- .../{ => shell-storage}/shell-storage.ts | 0 .../{ => shell-storage}/shell-storage.web.ts | 2 +- .../{ => speech}/native-speech-device.ts | 4 +- .../{ => speech}/native-speech.test.ts | 2 +- .../platform/{ => speech}/native-speech.ts | 2 +- .../{ => speech}/on-device-speech-contract.ts | 2 +- .../{ => speech}/on-device-speech-locale.ts | 2 +- .../on-device-speech-locale.web.ts | 0 .../platform/{ => speech}/on-device-speech.ts | 2 +- .../on-device-speech.web.test.tsx | 2 +- .../{ => speech}/on-device-speech.web.ts | 6 +- .../live-input-composing-range.test.ts | 0 .../live-input-composing-range.ts | 0 .../text-input-content-height.ts | 0 .../text-input-content-height.web.ts | 0 .../{ => text-input}/text-input-font-size.ts | 2 +- .../text-input-font-size.web.ts | 2 +- .../{ => text-input}/text-input-handle.ts | 0 .../text-input-selection-reveal.web.ts | 0 .../src/platform/use-native-device-verbs.ts | 19 +- .../view-measurement/page-box-contract.ts | 1 + .../{ => view-measurement}/page-box.ts | 2 +- .../{ => view-measurement}/page-box.web.ts | 4 +- mobile/src/session/MobileSessionSheets.tsx | 4 +- .../src/session/MobileSessionTitleBlock.tsx | 2 +- .../__mocks__/mobile-prompt-render-mocks.ts | 4 +- .../agent-launch/new-tab-agent-host-launch.ts | 2 +- ...mobile-dictation-session-outcomes.test.tsx | 4 +- .../session/mobile-structured-chat-fork.ts | 2 +- .../MobileNativeChatFloatingControls.tsx | 2 +- .../MobileNativeChatComposerField.web.tsx | 8 +- .../MobileNativeChatComposerMenuLayer.web.tsx | 4 +- .../MobileNativeChatContextUsageRing.tsx | 4 +- .../composer/MobileNativeChatSendButton.tsx | 2 +- .../mobile-native-chat-composer-paste.ts | 4 +- .../mobile-native-chat-file-attachment.ts | 2 +- .../mobile-native-chat-image-attachment.ts | 5 +- ...use-mobile-native-chat-file-attachments.ts | 4 +- ...se-mobile-native-chat-image-attachments.ts | 2 +- .../use-mobile-native-chat-image-upload.ts | 6 +- ...ile-native-chat-steer-chord-preview.web.ts | 2 +- .../input/MobileNativeChatPromptCard.tsx | 2 +- .../input/mobile-native-chat-control-swap.tsx | 2 +- .../use-mobile-native-chat-reply-scroll.ts | 2 +- .../transcript/MobileLinkActionSheet.tsx | 6 +- .../MobileNativeChatMessageActionsSheet.tsx | 2 +- .../MobileNativeChatRowOverlaysHost.test.tsx | 2 +- .../MobileNativeChatRowOverlaysHost.tsx | 2 +- .../MobileNativeChatToolRun.test.tsx | 65 +---- .../mobile-native-chat-reply-reveals.ts | 2 +- .../visual/MobileImageViewerActions.tsx | 4 +- .../visual/MobileNativeChatImageViewer.tsx | 2 +- .../src/session/tabs/MobileSessionTabBar.tsx | 6 +- .../tabs/use-mobile-session-tab-switching.ts | 2 +- .../session/use-mobile-session-dictation.ts | 4 +- .../session/use-mobile-session-lifecycle.ts | 2 +- .../use-mobile-session-preference-focus.ts | 2 +- .../use-mobile-session-presentation.ts | 2 +- .../settings/voice-transcribe-on-section.tsx | 4 +- .../source-control/MobileCommitDetailPage.tsx | 6 +- .../MobileCommitsScreen.test.tsx | 8 +- .../MobileSourceControlContent.tsx | 2 +- .../folder/use-folder-create-flow.test.tsx | 4 +- .../folder/use-folder-create-flow.tsx | 4 +- ...ource-control-oversized-stage-all.test.tsx | 118 --------- .../use-mobile-diff-review-comment-actions.ts | 2 +- .../use-mobile-diff-review-git-actions.ts | 2 +- .../review/use-mobile-diff-review-github.ts | 2 +- .../use-mobile-diff-review-send-actions.ts | 4 +- .../review/use-mobile-pr-ai-triage.ts | 2 +- .../review/use-mobile-pr-comment-actions.ts | 2 +- .../review/use-mobile-pr-text-action.ts | 2 +- .../use-mobile-session-diff-comments.ts | 4 +- .../use-mobile-commit-failure-recovery.ts | 2 +- ...e-mobile-commit-message-generation.test.ts | 2 +- .../use-mobile-commit-message-generation.ts | 2 +- ...se-mobile-source-control-commit-runners.ts | 2 +- ...use-mobile-source-control-keyboard-lift.ts | 2 +- .../use-mobile-source-control-runners.ts | 2 +- .../item-detail/mobile-tasks-item-actions.tsx | 2 +- .../mobile-tasks-item-detail-content.tsx | 2 +- ...le-tasks-hosted-comment-review-actions.tsx | 2 +- .../mobile-tasks-project-detail-drawer.tsx | 2 +- .../mobile-tasks-project-review-panels.tsx | 2 +- .../screen/mobile-tasks-filter-sheet.tsx | 2 +- .../mobile-tasks-provider-item-list.tsx | 2 +- .../mobile-tasks-create-drawers.tsx | 2 +- .../quick-commands/QuickCommandRow.tsx | 4 +- .../mobile-session-quick-command-launch.ts | 2 +- .../session/MobileSessionAccessoryKeys.tsx | 4 +- .../session/TerminalMoreKeysSheet.tsx | 2 +- ...mobile-session-terminal-create-feedback.ts | 2 +- .../use-mobile-session-accessory-selection.ts | 4 +- .../use-mobile-session-keyboard-lift.ts | 2 +- .../use-mobile-session-keyboard-state.ts | 5 +- ...-mobile-session-terminal-create-actions.ts | 2 +- .../use-mobile-session-terminal-runtime.ts | 2 +- ...se-mobile-session-terminal-send-actions.ts | 4 +- .../session/use-mobile-terminal-paste.ts | 2 +- .../session/use-session-terminal-key-rail.ts | 2 +- .../use-terminal-live-input-click.test.ts | 2 +- .../use-terminal-live-input-commit.test.ts | 2 +- .../use-terminal-live-input-commit.ts | 2 +- .../terminal/use-terminal-live-input-focus.ts | 2 +- mobile/web-entry/index.tsx | 8 +- mobile/web-entry/web-overrides.json | 32 +-- 268 files changed, 442 insertions(+), 1797 deletions(-) delete mode 100644 mobile/src/components/bottom-drawer/option-pager.test.tsx rename mobile/src/platform/{ => accessibility}/accessibility-announcer.ts (100%) rename mobile/src/platform/{ => accessibility}/accessibility-announcer.web.ts (85%) rename mobile/src/platform/{ => accessibility}/native-accessibility-announce.ts (85%) rename mobile/src/platform/{ => accessibility}/use-reduced-motion.ts (100%) rename mobile/src/platform/{ => audio-capture}/dictation-capture-compile-fence.ts (86%) rename mobile/src/platform/{ => audio-capture}/dictation-capture-contract.ts (100%) rename mobile/src/platform/{ => audio-capture}/dictation-capture.ts (95%) rename mobile/src/platform/{ => audio-capture}/dictation-capture.web.ts (92%) rename mobile/src/platform/{ => audio-capture}/microphone-screen-lock.ts (100%) rename mobile/src/platform/{ => audio-capture}/native-audio-device.ts (96%) rename mobile/src/platform/{ => audio-capture}/native-audio-session-ownership.test.ts (97%) rename mobile/src/platform/{ => audio-capture}/native-audio.ts (94%) rename mobile/src/platform/{ => clipboard}/clipboard.test.tsx (54%) rename mobile/src/platform/{ => clipboard}/clipboard.ts (100%) rename mobile/src/platform/{ => clipboard}/clipboard.web.ts (93%) rename mobile/src/platform/{ => clipboard}/native-clipboard.ts (91%) delete mode 100644 mobile/src/platform/external-link.test.ts delete mode 100644 mobile/src/platform/external-link.web.test.ts rename mobile/src/platform/{ => external-links}/external-link.ts (100%) create mode 100644 mobile/src/platform/external-links/external-link.web.test.ts rename mobile/src/platform/{ => external-links}/external-link.web.ts (91%) delete mode 100644 mobile/src/platform/global-timer-receiver-test-fakes.ts rename mobile/src/platform/{ => haptics}/haptics.ts (100%) rename mobile/src/platform/{ => haptics}/haptics.web.ts (89%) rename mobile/src/platform/{ => image-actions}/image-actions-contract.ts (87%) rename mobile/src/platform/{ => image-actions}/image-actions.ts (100%) rename mobile/src/platform/{ => image-actions}/image-actions.web.ts (87%) rename mobile/src/platform/{ => image-actions}/native-image-actions.ts (88%) delete mode 100644 mobile/src/platform/keyboard-occlusion.test.tsx rename mobile/src/platform/{ => keyboard}/keyboard-close-blur.web.test.ts (68%) rename mobile/src/platform/{ => keyboard}/keyboard-close-blur.web.ts (97%) rename mobile/src/platform/{ => keyboard}/keyboard-occlusion.ts (96%) rename mobile/src/platform/{ => keyboard}/keyboard-occlusion.web.ts (100%) rename mobile/src/platform/{ => keyboard}/keyboard-persisting-taps.ts (100%) rename mobile/src/platform/{ => keyboard}/primary-modifier-key.ts (87%) delete mode 100644 mobile/src/platform/media-picker-test-shell.ts rename mobile/src/platform/{ => media-picking}/media-picker-contract.ts (95%) rename mobile/src/platform/{ => media-picking}/media-picker.ts (71%) rename mobile/src/platform/{ => media-picking}/media-picker.web.ts (94%) rename mobile/src/platform/{ => media-picking}/mobile-picked-file-name.ts (100%) rename mobile/src/platform/{ => media-picking}/native-media-device.ts (94%) rename mobile/src/platform/{ => media-picking}/native-media-stage-cleanup.test.ts (98%) rename mobile/src/platform/{ => media-picking}/native-media.test.ts (54%) rename mobile/src/platform/{ => media-picking}/native-media.ts (93%) delete mode 100644 mobile/src/platform/microphone-screen-lock.test.ts delete mode 100644 mobile/src/platform/native-media-device.test.ts rename mobile/src/platform/{ => pdf-viewing}/native-pdf-view.ts (79%) rename mobile/src/platform/{ => pdf-viewing}/pdf-opener-contract.ts (100%) rename mobile/src/platform/{ => pdf-viewing}/pdf-opener.ts (100%) rename mobile/src/platform/{ => pdf-viewing}/pdf-opener.web.ts (84%) rename mobile/src/platform/{ => shell-storage}/shell-storage.ts (100%) rename mobile/src/platform/{ => shell-storage}/shell-storage.web.ts (77%) rename mobile/src/platform/{ => speech}/native-speech-device.ts (81%) rename mobile/src/platform/{ => speech}/native-speech.test.ts (97%) rename mobile/src/platform/{ => speech}/native-speech.ts (99%) rename mobile/src/platform/{ => speech}/on-device-speech-contract.ts (89%) rename mobile/src/platform/{ => speech}/on-device-speech-locale.ts (77%) rename mobile/src/platform/{ => speech}/on-device-speech-locale.web.ts (100%) rename mobile/src/platform/{ => speech}/on-device-speech.ts (80%) rename mobile/src/platform/{ => speech}/on-device-speech.web.test.tsx (96%) rename mobile/src/platform/{ => speech}/on-device-speech.web.ts (90%) rename mobile/src/platform/{ => text-input}/live-input-composing-range.test.ts (100%) rename mobile/src/platform/{ => text-input}/live-input-composing-range.ts (100%) rename mobile/src/platform/{ => text-input}/text-input-content-height.ts (100%) rename mobile/src/platform/{ => text-input}/text-input-content-height.web.ts (100%) rename mobile/src/platform/{ => text-input}/text-input-font-size.ts (51%) rename mobile/src/platform/{ => text-input}/text-input-font-size.web.ts (87%) rename mobile/src/platform/{ => text-input}/text-input-handle.ts (100%) rename mobile/src/platform/{ => text-input}/text-input-selection-reveal.web.ts (100%) create mode 100644 mobile/src/platform/view-measurement/page-box-contract.ts rename mobile/src/platform/{ => view-measurement}/page-box.ts (80%) rename mobile/src/platform/{ => view-measurement}/page-box.web.ts (89%) delete mode 100644 mobile/src/source-control/mobile-source-control-oversized-stage-all.test.tsx diff --git a/config/scripts/mobile-web-app/platform-seams/mobile-web-app-external-link-seam.mjs b/config/scripts/mobile-web-app/platform-seams/mobile-web-app-external-link-seam.mjs index fbc811b6593..dcbe2f4f25d 100644 --- a/config/scripts/mobile-web-app/platform-seams/mobile-web-app-external-link-seam.mjs +++ b/config/scripts/mobile-web-app/platform-seams/mobile-web-app-external-link-seam.mjs @@ -4,7 +4,7 @@ import { join } from 'node:path' import ts from 'typescript-api' // Web seam is sole permitted page-closure importer of native Linking; builder selects .web.ts sibling. -export const EXTERNAL_LINK_SEAM = 'src/platform/external-link.web.ts' +export const EXTERNAL_LINK_SEAM = 'src/platform/external-links/external-link.web.ts' // Parse real Linking bindings, excluding strings/comments and recognizing named aliases plus namespace/default interop. // Report named imports/reexports once at entry statement; report namespace property uses, since whole namespace import is allowed. diff --git a/config/scripts/mobile-web-app/platform-seams/mobile-web-app-haptics-seam.mjs b/config/scripts/mobile-web-app/platform-seams/mobile-web-app-haptics-seam.mjs index 8c22cc7548b..63480ec97e7 100644 --- a/config/scripts/mobile-web-app/platform-seams/mobile-web-app-haptics-seam.mjs +++ b/config/scripts/mobile-web-app/platform-seams/mobile-web-app-haptics-seam.mjs @@ -4,10 +4,10 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import ts from 'typescript-api' -export const HAPTICS_SEAM = 'src/platform/haptics.web.ts' +export const HAPTICS_SEAM = 'src/platform/haptics/haptics.web.ts' // Native sibling imports expo-haptics and must stay outside page closure. -export const HAPTICS_NATIVE = 'src/platform/haptics.ts' +export const HAPTICS_NATIVE = 'src/platform/haptics/haptics.ts' export const HAPTICS_KINDS_MODULE = 'src/mobile-web-shell/bridge/bridge-haptics-notify.ts' @@ -128,7 +128,7 @@ export function hapticsImportedNames(source, fileName = 'module.ts') { if ( !ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier) || - !/(?:\.\.?\/)+platform\/haptics$/.test(statement.moduleSpecifier.text) + !/(?:\.\.?\/)+platform\/haptics\/haptics$/.test(statement.moduleSpecifier.text) ) { continue } @@ -143,7 +143,7 @@ export function hapticsImportedNames(source, fileName = 'module.ts') { // Consumers import extensionless seam; census checks closure resolved web sibling rather than native expo-haptics path. export function hapticsSeamImporters(mobileDir, closure) { - const specifier = /(?:^|['"])(?:\.\.?\/)+platform\/haptics(?:\.web)?['"]$/ + const specifier = /(?:^|['"])(?:\.\.?\/)+platform\/haptics\/haptics(?:\.web)?['"]$/ return closure.local .filter((file) => file !== HAPTICS_SEAM && file !== HAPTICS_NATIVE) .filter((file) => { diff --git a/config/scripts/mobile-web-app/platform-seams/mobile-web-app-media-picker-seam.mjs b/config/scripts/mobile-web-app/platform-seams/mobile-web-app-media-picker-seam.mjs index 14b677ea59f..15388c194b9 100644 --- a/config/scripts/mobile-web-app/platform-seams/mobile-web-app-media-picker-seam.mjs +++ b/config/scripts/mobile-web-app/platform-seams/mobile-web-app-media-picker-seam.mjs @@ -4,7 +4,7 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import ts from 'typescript-api' -export const MEDIA_PICKER_SEAM = 'src/platform/media-picker.web.ts' +export const MEDIA_PICKER_SEAM = 'src/platform/media-picking/media-picker.web.ts' export const NATIVE_PICKER_MODULES = ['expo-image-picker', 'expo-document-picker'] diff --git a/config/scripts/mobile-web-app/platform-seams/mobile-web-app-text-input-font-size-seam.mjs b/config/scripts/mobile-web-app/platform-seams/mobile-web-app-text-input-font-size-seam.mjs index 4e0976874c4..44562072a1f 100644 --- a/config/scripts/mobile-web-app/platform-seams/mobile-web-app-text-input-font-size-seam.mjs +++ b/config/scripts/mobile-web-app/platform-seams/mobile-web-app-text-input-font-size-seam.mjs @@ -2,7 +2,7 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs' import { join } from 'node:path' import ts from 'typescript-api' -export const TEXT_INPUT_FONT_SIZE_SEAM = 'src/platform/text-input-font-size.web.ts' +export const TEXT_INPUT_FONT_SIZE_SEAM = 'src/platform/text-input/text-input-font-size.web.ts' const FLOOR_EXPORT = 'TEXT_INPUT_FONT_SIZE_FLOOR' diff --git a/config/scripts/mobile-web-app/routing/mobile-web-app-page-grant-call-sites.mjs b/config/scripts/mobile-web-app/routing/mobile-web-app-page-grant-call-sites.mjs index c201cf7f4f5..75ea0a8ee9f 100644 --- a/config/scripts/mobile-web-app/routing/mobile-web-app-page-grant-call-sites.mjs +++ b/config/scripts/mobile-web-app/routing/mobile-web-app-page-grant-call-sites.mjs @@ -27,25 +27,25 @@ export const PAGE_GRANT_CALL_SITES = [ callRow( ['externalLink'], 'openExternalLink', - 'src/platform/external-link.web.ts', + 'src/platform/external-links/external-link.web.ts', 'the shell is the only thing on the page that can open a URL outside the app' ), callRow( ['native.clipboard.write'], 'useClipboardWriter', - 'src/platform/clipboard.web.ts', + 'src/platform/clipboard/clipboard.web.ts', "the browser's own clipboard write is refused without a user gesture the page cannot prove" ), callRow( ['native.clipboard.read'], 'useClipboardReader', - 'src/platform/clipboard.web.ts', + 'src/platform/clipboard/clipboard.web.ts', 'a paste needs the device pasteboard, which the WebView does not hand the page' ), callRow( ['native.media.pick', 'native.media.read', 'native.media.release'], 'useMediaPicker', - 'src/platform/media-picker.web.ts', + 'src/platform/media-picking/media-picker.web.ts', 'the picker runs the OS permission prompt inside the shell and hands back a handle' ) ] diff --git a/config/scripts/mobile-web-app/shell/mobile-web-app-composer-sizing-render.test.mjs b/config/scripts/mobile-web-app/shell/mobile-web-app-composer-sizing-render.test.mjs index ee8f619bd08..e3422785654 100644 --- a/config/scripts/mobile-web-app/shell/mobile-web-app-composer-sizing-render.test.mjs +++ b/config/scripts/mobile-web-app/shell/mobile-web-app-composer-sizing-render.test.mjs @@ -29,7 +29,7 @@ import { useState } from 'react' import { createRoot } from 'react-dom/client' import { View } from 'react-native' import { MobileNativeChatComposerField } from './src/session/native-chat/composer/MobileNativeChatComposerField.web' -import { publishShellKeyboardSource } from './src/platform/keyboard-occlusion' +import { publishShellKeyboardSource } from './src/platform/keyboard/keyboard-occlusion' let keyboardHeight = 0 const listeners = new Set() diff --git a/mobile/app/mobile-onboarding.tsx b/mobile/app/mobile-onboarding.tsx index 8e8378f27f3..ceca1af2268 100644 --- a/mobile/app/mobile-onboarding.tsx +++ b/mobile/app/mobile-onboarding.tsx @@ -11,7 +11,7 @@ import { } from '../src/onboarding/MobileOnboardingPage' import { parseMobileOnboardingSteps } from '../src/onboarding/mobile-onboarding-plan' import { PUSH_CURVE, PUSH_SLIDE_MS } from '../src/navigation/push-motion' -import { useReducedMotionEnabled } from '../src/platform/use-reduced-motion' +import { useReducedMotionEnabled } from '../src/platform/accessibility/use-reduced-motion' import { useMobileColorScheme } from '../src/theme/use-mobile-color-scheme' import { mobileOnboardingStyles as styles } from '../src/onboarding/mobile-onboarding-styles' import { diff --git a/mobile/app/pair-confirm.tsx b/mobile/app/pair-confirm.tsx index 83146afbdb5..207af3e3e49 100644 --- a/mobile/app/pair-confirm.tsx +++ b/mobile/app/pair-confirm.tsx @@ -27,7 +27,7 @@ import { PairingProgress, waitForBeat } from '../src/pairing/pairing-visuals' -import { triggerSuccess } from '../src/platform/haptics' +import { triggerSuccess } from '../src/platform/haptics/haptics' import { getPairingFailureMessage } from '../src/pairing/pairing-failure-message' type Status = 'awaiting-confirm' | 'connecting' | 'error' diff --git a/mobile/app/pair-scan.tsx b/mobile/app/pair-scan.tsx index 57db5a1b16a..d1117e6a4f0 100644 --- a/mobile/app/pair-scan.tsx +++ b/mobile/app/pair-scan.tsx @@ -40,7 +40,7 @@ import { Step, waitForBeat } from '../src/pairing/pairing-visuals' -import { triggerSuccess } from '../src/platform/haptics' +import { triggerSuccess } from '../src/platform/haptics/haptics' import { getPairingFailureMessage } from '../src/pairing/pairing-failure-message' // Bound initial pairing at ~25s (one connect timeout plus retry) so broken routes show an actionable error/log; live RPC retries indefinitely. diff --git a/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx b/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx index 08a2d65d451..5df1a4c0f44 100644 --- a/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx +++ b/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx @@ -23,7 +23,7 @@ import { resumeAiVaultSessionInTerminal } from './ai-vault-resume-launch' import { prepareMobileAiVaultSessionResume } from './ai-vault-resume-preparation' -import { triggerError, triggerSuccess } from '../platform/haptics' +import { triggerError, triggerSuccess } from '../platform/haptics/haptics' import type * as AiVault from '../../../src/shared/ai-vault/contracts/ai-vault-types' import type { Worktree } from '../worktree/workspace-list-types' import { useMobileAgentHistoryState } from './use-mobile-agent-history-state' diff --git a/mobile/src/agents/MobileAgentListRows.tsx b/mobile/src/agents/MobileAgentListRows.tsx index b564b7d4bdf..d165828181a 100644 --- a/mobile/src/agents/MobileAgentListRows.tsx +++ b/mobile/src/agents/MobileAgentListRows.tsx @@ -8,7 +8,7 @@ import { MobileAgentIcon } from '../components/MobileAgentIcon' import { SwipeRowActions } from '../components/SwipeRowActions' import { HOME_ROW_TEXT_INSET, homeListStyles } from '../home/home-list-styles' import { useMobileTranslation } from '../i18n/use-mobile-translation' -import { triggerMediumImpact } from '../platform/haptics' +import { triggerMediumImpact } from '../platform/haptics/haptics' import { colors, spacing } from '../theme/mobile-theme' import { themedStyleSheet } from '../theme/themed-styles' import { useMobileColorScheme } from '../theme/use-mobile-color-scheme' diff --git a/mobile/src/agents/use-agent-list-row-actions.tsx b/mobile/src/agents/use-agent-list-row-actions.tsx index 837183789c8..c68b2a3091b 100644 --- a/mobile/src/agents/use-agent-list-row-actions.tsx +++ b/mobile/src/agents/use-agent-list-row-actions.tsx @@ -4,7 +4,7 @@ import { Alert } from 'react-native' import { ActionSheetModal, type ActionSheetAction } from '../components/ActionSheetModal' import { TransientToast, useTransientToast } from '../components/TransientToast' import { useMobileTranslation } from '../i18n/use-mobile-translation' -import { useClipboardWriter } from '../platform/clipboard' +import { useClipboardWriter } from '../platform/clipboard/clipboard' import { useOpenMobileSession } from '../session/use-open-mobile-session' import type { HostProfile } from '../host-catalog/host-profile' import type { AgentActivityChange, AgentListRow } from './agent-list-rows' diff --git a/mobile/src/automations/AutomationManager.test.tsx b/mobile/src/automations/AutomationManager.test.tsx index e6e791d747a..96041599786 100644 --- a/mobile/src/automations/AutomationManager.test.tsx +++ b/mobile/src/automations/AutomationManager.test.tsx @@ -19,8 +19,8 @@ vi.mock('react-native', () => ({ AppState: { currentState: 'active', addEventListener: () => ({ remove: () => {} }) } })) vi.mock( - '../platform/text-input-font-size', - async () => await import('../platform/text-input-font-size.web') + '../platform/text-input/text-input-font-size', + async () => await import('../platform/text-input/text-input-font-size.web') ) vi.mock('lucide-react-native', () => ({ Check: 'Check', diff --git a/mobile/src/automations/automation-styles.ts b/mobile/src/automations/automation-styles.ts index c0fe4ca4c72..b22efc32dba 100644 --- a/mobile/src/automations/automation-styles.ts +++ b/mobile/src/automations/automation-styles.ts @@ -1,5 +1,5 @@ import { themedStyleSheet } from '../theme/themed-styles' -import { TEXT_INPUT_FONT_SIZE } from '../platform/text-input-font-size' +import { TEXT_INPUT_FONT_SIZE } from '../platform/text-input/text-input-font-size' import { colors, radii, spacing, typography } from '../theme/mobile-theme' export const automationStyles = themedStyleSheet(() => ({ diff --git a/mobile/src/browser/pane-view/MobileBrowserLoadProgress.tsx b/mobile/src/browser/pane-view/MobileBrowserLoadProgress.tsx index 5778fb34f4a..dfc72782d5b 100644 --- a/mobile/src/browser/pane-view/MobileBrowserLoadProgress.tsx +++ b/mobile/src/browser/pane-view/MobileBrowserLoadProgress.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef } from 'react' import { Animated, Easing, Platform, StyleSheet, View } from 'react-native' -import { useReducedMotionEnabled } from '../../platform/use-reduced-motion' +import { useReducedMotionEnabled } from '../../platform/accessibility/use-reduced-motion' import { colors, radii } from '../../theme/mobile-theme' import { themedStyleSheet } from '../../theme/themed-styles' import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme' diff --git a/mobile/src/browser/pane-view/mobile-browser-pane-styles.ts b/mobile/src/browser/pane-view/mobile-browser-pane-styles.ts index b3a89bfa3d5..1ac21044623 100644 --- a/mobile/src/browser/pane-view/mobile-browser-pane-styles.ts +++ b/mobile/src/browser/pane-view/mobile-browser-pane-styles.ts @@ -1,5 +1,5 @@ import { StyleSheet } from 'react-native' -import { TEXT_INPUT_FONT_SIZE } from '../../platform/text-input-font-size' +import { TEXT_INPUT_FONT_SIZE } from '../../platform/text-input/text-input-font-size' import { colors, radii, spacing, typography } from '../../theme/mobile-theme' import { themedStyleSheet } from '../../theme/themed-styles' diff --git a/mobile/src/components/AgentLaunchNotice.tsx b/mobile/src/components/AgentLaunchNotice.tsx index 724defd8c81..3a384e372a3 100644 --- a/mobile/src/components/AgentLaunchNotice.tsx +++ b/mobile/src/components/AgentLaunchNotice.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import { Pressable, Text, View, type StyleProp, type TextStyle } from 'react-native' -import { useClipboardWriter } from '../platform/clipboard' +import { useClipboardWriter } from '../platform/clipboard/clipboard' import { useMobileTranslation } from '../i18n/use-mobile-translation' import type { MobileAgentLaunchAvailability } from '../session/agent-launch/mobile-agent-launch-availability' import { diff --git a/mobile/src/components/AgentSpinner.tsx b/mobile/src/components/AgentSpinner.tsx index d22e6b37e07..522f6d750ad 100644 --- a/mobile/src/components/AgentSpinner.tsx +++ b/mobile/src/components/AgentSpinner.tsx @@ -7,7 +7,7 @@ import { useMobileTranslation } from '../i18n/use-mobile-translation' import { colors, radii } from '../theme/mobile-theme' import { themedStyleSheet } from '../theme/themed-styles' import { useMobileColorScheme } from '../theme/use-mobile-color-scheme' -import { useReducedMotionEnabled } from '../platform/use-reduced-motion' +import { useReducedMotionEnabled } from '../platform/accessibility/use-reduced-motion' import { ArrivalFade } from './ArrivalFade' import { WorkingRing } from './AgentStateDot' diff --git a/mobile/src/components/AgentStateDot.tsx b/mobile/src/components/AgentStateDot.tsx index bfa48b0a068..e1695e3a437 100644 --- a/mobile/src/components/AgentStateDot.tsx +++ b/mobile/src/components/AgentStateDot.tsx @@ -5,7 +5,7 @@ import { useMobileTranslation } from '../i18n/use-mobile-translation' import { colors, radii } from '../theme/mobile-theme' import { themedStyleSheet } from '../theme/themed-styles' import { useMobileColorScheme } from '../theme/use-mobile-color-scheme' -import { useReducedMotionEnabled } from '../platform/use-reduced-motion' +import { useReducedMotionEnabled } from '../platform/accessibility/use-reduced-motion' import { useWorkingRingRotation } from './use-working-motion' // Match desktop per-agent vocabulary: working yellow spinner, done emerald, attention red, interrupted muted, unconfirmed amber, idle neutral. diff --git a/mobile/src/components/AnchoredDetailCard.tsx b/mobile/src/components/AnchoredDetailCard.tsx index 40dc2a1ca56..0077107d073 100644 --- a/mobile/src/components/AnchoredDetailCard.tsx +++ b/mobile/src/components/AnchoredDetailCard.tsx @@ -3,7 +3,7 @@ import { Animated, ScrollView, StyleSheet, Text, View, type HostInstance } from import { DetailCardFocusBoundary, DetailCardLayer } from './detail-card-host' import type { AnchoredDetailCardState } from './use-anchored-detail-card' import { useDetailCardMotion } from './use-detail-card-motion' -import { useReducedMotionEnabled } from '../platform/use-reduced-motion' +import { useReducedMotionEnabled } from '../platform/accessibility/use-reduced-motion' import { colors, radii, spacing, typography } from '../theme/mobile-theme' import { themedStyleSheet } from '../theme/themed-styles' import { useMobileColorScheme } from '../theme/use-mobile-color-scheme' diff --git a/mobile/src/components/AutoHeight.tsx b/mobile/src/components/AutoHeight.tsx index a9715c27a8b..69163e6f54d 100644 --- a/mobile/src/components/AutoHeight.tsx +++ b/mobile/src/components/AutoHeight.tsx @@ -7,7 +7,7 @@ import Animated, { withTiming, type SharedValue } from 'react-native-reanimated' -import { useReducedMotionEnabled } from '../platform/use-reduced-motion' +import { useReducedMotionEnabled } from '../platform/accessibility/use-reduced-motion' import { BOTTOM_DRAWER_EASING, BOTTOM_DRAWER_HEIGHT_DURATION_MS diff --git a/mobile/src/components/DisclosureChevron.tsx b/mobile/src/components/DisclosureChevron.tsx index c7f7ec1167c..6412f0568c5 100644 --- a/mobile/src/components/DisclosureChevron.tsx +++ b/mobile/src/components/DisclosureChevron.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, type ReactNode } from 'react' import { Animated, Easing, Platform } from 'react-native' -import { useReducedMotionEnabled } from '../platform/use-reduced-motion' +import { useReducedMotionEnabled } from '../platform/accessibility/use-reduced-motion' import { SHEET_CURVE, SHEET_HEIGHT_DURATION_MS } from './sheet-motion-values' /** Turns its chevron with the AutoHeight it discloses: same curve, same duration. */ diff --git a/mobile/src/components/DragReorderList.tsx b/mobile/src/components/DragReorderList.tsx index 25800381080..0c7e55e37f3 100644 --- a/mobile/src/components/DragReorderList.tsx +++ b/mobile/src/components/DragReorderList.tsx @@ -21,7 +21,7 @@ import { type AnimatedRef, type SharedValue } from 'react-native-reanimated' -import { triggerSelection } from '../platform/haptics' +import { triggerSelection } from '../platform/haptics/haptics' import { dragReorderTarget, dragReorderLayout, diff --git a/mobile/src/components/DragReorderRow.tsx b/mobile/src/components/DragReorderRow.tsx index 3b574b907b4..33e3f66af8c 100644 --- a/mobile/src/components/DragReorderRow.tsx +++ b/mobile/src/components/DragReorderRow.tsx @@ -18,7 +18,7 @@ import { colors, spacing } from '../theme/mobile-theme' import { themedStyleSheet } from '../theme/themed-styles' import { useMobileColorScheme } from '../theme/use-mobile-color-scheme' import { useMobileTranslation } from '../i18n/use-mobile-translation' -import { triggerMediumImpact } from '../platform/haptics' +import { triggerMediumImpact } from '../platform/haptics/haptics' import { type DragReorderLayout, orderedKeysFromDragReorderPositions, diff --git a/mobile/src/components/LoadingSpinner.tsx b/mobile/src/components/LoadingSpinner.tsx index 5b2cd44e925..627c9021d36 100644 --- a/mobile/src/components/LoadingSpinner.tsx +++ b/mobile/src/components/LoadingSpinner.tsx @@ -1,6 +1,6 @@ import { Animated } from 'react-native' import { Hourglass, LoaderCircle } from 'lucide-react-native' -import { useReducedMotionEnabled } from '../platform/use-reduced-motion' +import { useReducedMotionEnabled } from '../platform/accessibility/use-reduced-motion' import { useWorkingRingRotation } from './use-working-motion' // Match desktop one-second spinner; reduced motion uses distinct working icon so frozen circle does not look stuck. diff --git a/mobile/src/components/MobileDictationSetupSheet.tsx b/mobile/src/components/MobileDictationSetupSheet.tsx index c8430fd68dd..093cfd28ee4 100644 --- a/mobile/src/components/MobileDictationSetupSheet.tsx +++ b/mobile/src/components/MobileDictationSetupSheet.tsx @@ -10,7 +10,7 @@ import type { Translate } from '../i18n/mobile-i18n' import { useMobileTranslation } from '../i18n/use-mobile-translation' import { getUiIntlLocale } from '../i18n/ui-intl-locale' import type { RpcClient } from '../transport/rpc/rpc-client-contract' -import { triggerError, triggerSuccess } from '../platform/haptics' +import { triggerError, triggerSuccess } from '../platform/haptics/haptics' import { useDictationSetupPoller } from '../dictation/use-dictation-setup-poller' import { cloudModelNeedsKey, diff --git a/mobile/src/components/MobileDiffReviewLines.tsx b/mobile/src/components/MobileDiffReviewLines.tsx index 5ca8863e086..cc05b94b99a 100644 --- a/mobile/src/components/MobileDiffReviewLines.tsx +++ b/mobile/src/components/MobileDiffReviewLines.tsx @@ -31,7 +31,7 @@ import type { MobileDiffReviewThread } from '../source-control/review/mobile-dif import type { PRCommentCardActions } from './pr-sidebar/PRCommentCard' import { CommentGroupView } from './pr-sidebar/PRCommentsSection' import { useNow } from '../hooks/use-now' -import { triggerSelection } from '../platform/haptics' +import { triggerSelection } from '../platform/haptics/haptics' const NO_OPENED_LINES: ReadonlySet = new Set() diff --git a/mobile/src/components/MobileRichMarkdownEditor.tsx b/mobile/src/components/MobileRichMarkdownEditor.tsx index aa6e6974618..92faf56543e 100644 --- a/mobile/src/components/MobileRichMarkdownEditor.tsx +++ b/mobile/src/components/MobileRichMarkdownEditor.tsx @@ -15,7 +15,7 @@ import { useMobileTranslation } from '../i18n/use-mobile-translation' import { colors } from '../theme/mobile-theme' import { themedStyleSheet } from '../theme/themed-styles' import { useMobileColorScheme } from '../theme/use-mobile-color-scheme' -import { openExternalLink } from '../platform/external-link' +import { openExternalLink } from '../platform/external-links/external-link' import { MobileRichMarkdownToolbar } from './MobileRichMarkdownToolbar' import { TextInputModal } from './TextInputModal' import { diff --git a/mobile/src/components/MobileSegmentedControl.tsx b/mobile/src/components/MobileSegmentedControl.tsx index 9cbb252b529..42ac6119efe 100644 --- a/mobile/src/components/MobileSegmentedControl.tsx +++ b/mobile/src/components/MobileSegmentedControl.tsx @@ -8,7 +8,7 @@ import { type StyleProp, type ViewStyle } from 'react-native' -import { triggerSelection } from '../platform/haptics' +import { triggerSelection } from '../platform/haptics/haptics' import { resolveTabStripScrollOffset } from '../session/tabs/tab-strip-scroll' import { colors, concentricRadius, feedback, radii, spacing } from '../theme/mobile-theme' import { themedStyleSheet } from '../theme/themed-styles' diff --git a/mobile/src/components/MobileSourceCopyButton.tsx b/mobile/src/components/MobileSourceCopyButton.tsx index 042ed6051d9..931f13a2ed2 100644 --- a/mobile/src/components/MobileSourceCopyButton.tsx +++ b/mobile/src/components/MobileSourceCopyButton.tsx @@ -3,7 +3,7 @@ import { Alert, Platform, Pressable, Text } from 'react-native' import { Check, Copy } from 'lucide-react-native' import { translate } from '../i18n/mobile-i18n' import { useMobileTranslation } from '../i18n/use-mobile-translation' -import { useClipboardWriter } from '../platform/clipboard' +import { useClipboardWriter } from '../platform/clipboard/clipboard' import { colors, radii, spacing, typography } from '../theme/mobile-theme' import { themedStyleSheet } from '../theme/themed-styles' import { useMobileColorScheme } from '../theme/use-mobile-color-scheme' diff --git a/mobile/src/components/MobileSwitch.tsx b/mobile/src/components/MobileSwitch.tsx index 21de22f696c..1e407660f8b 100644 --- a/mobile/src/components/MobileSwitch.tsx +++ b/mobile/src/components/MobileSwitch.tsx @@ -10,8 +10,8 @@ import Animated, { import { colors, feedback, radii } from '../theme/mobile-theme' import { themedStyleSheet } from '../theme/themed-styles' import { useMobileColorScheme } from '../theme/use-mobile-color-scheme' -import { triggerSelection } from '../platform/haptics' -import { useReducedMotionEnabled } from '../platform/use-reduced-motion' +import { triggerSelection } from '../platform/haptics/haptics' +import { useReducedMotionEnabled } from '../platform/accessibility/use-reduced-motion' import { BOTTOM_DRAWER_EASING } from './bottom-drawer/bottom-drawer-constants' export type MobileSwitchProps = { diff --git a/mobile/src/components/ProtocolBlockScreen.tsx b/mobile/src/components/ProtocolBlockScreen.tsx index c6df85658b8..34c83fec1a3 100644 --- a/mobile/src/components/ProtocolBlockScreen.tsx +++ b/mobile/src/components/ProtocolBlockScreen.tsx @@ -1,5 +1,5 @@ import { useWallAppUpdate } from '../app-update/use-wall-app-update' -import { openExternalLink } from '../platform/external-link' +import { openExternalLink } from '../platform/external-links/external-link' import { useRouteHandoff } from '../navigation/route-handoff' import { Platform, Pressable, Text, View } from 'react-native' import { colors, radii, spacing, typography } from '../theme/mobile-theme' diff --git a/mobile/src/components/WorktreeListRow.tsx b/mobile/src/components/WorktreeListRow.tsx index 10207f88ba5..2f4cc3a260f 100644 --- a/mobile/src/components/WorktreeListRow.tsx +++ b/mobile/src/components/WorktreeListRow.tsx @@ -2,7 +2,7 @@ import { memo } from 'react' import { Bell, ChevronDown, ChevronRight, GitBranch } from 'lucide-react-native' import { Pressable, StyleSheet, Text, View } from 'react-native' import type { MobileRenderableRepoIcon } from '../host-screen/host-screen-reply-schema' -import { triggerMediumImpact } from '../platform/haptics' +import { triggerMediumImpact } from '../platform/haptics/haptics' import { colors, feedback, radii, spacing, typography } from '../theme/mobile-theme' import { themedStyleSheet } from '../theme/themed-styles' import { useMobileColorScheme } from '../theme/use-mobile-color-scheme' diff --git a/mobile/src/components/bottom-drawer/bottom-drawer-back-while-hiding.test.tsx b/mobile/src/components/bottom-drawer/bottom-drawer-back-while-hiding.test.tsx index d0dc095044b..e2185120ded 100644 --- a/mobile/src/components/bottom-drawer/bottom-drawer-back-while-hiding.test.tsx +++ b/mobile/src/components/bottom-drawer/bottom-drawer-back-while-hiding.test.tsx @@ -7,7 +7,7 @@ type Timing = { to: number; callback?: (finished: boolean) => void } const animations = vi.hoisted((): { running: Timing[] } => ({ running: [] })) vi.mock('../../navigation/use-back-claim', () => ({ useBackClaim: () => {} })) -vi.mock('../../platform/keyboard-occlusion', () => ({ +vi.mock('../../platform/keyboard/keyboard-occlusion', () => ({ currentSoftKeyboardHeight: () => 0, subscribeSoftKeyboard: () => () => {} })) @@ -43,7 +43,9 @@ vi.mock('react-native-gesture-handler', () => { }) vi.mock('../AutoHeight', () => ({ AutoHeight: ({ children }: { children: unknown }) => children })) // The OS motion setting is read through AccessibilityInfo, which the react-native mock leaves out. -vi.mock('../../platform/use-reduced-motion', () => ({ useReducedMotionEnabled: () => false })) +vi.mock('../../platform/accessibility/use-reduced-motion', () => ({ + useReducedMotionEnabled: () => false +})) vi.mock('react-native-reanimated', () => { function isTiming(value: unknown): value is Timing { return typeof value === 'object' && value !== null && 'to' in value diff --git a/mobile/src/components/bottom-drawer/bottom-drawer-constants.ts b/mobile/src/components/bottom-drawer/bottom-drawer-constants.ts index b14c8a2c752..8790b5b5791 100644 --- a/mobile/src/components/bottom-drawer/bottom-drawer-constants.ts +++ b/mobile/src/components/bottom-drawer/bottom-drawer-constants.ts @@ -1,5 +1,5 @@ import { Easing, useReducedMotion } from 'react-native-reanimated' -import { useReducedMotionEnabled } from '../../platform/use-reduced-motion' +import { useReducedMotionEnabled } from '../../platform/accessibility/use-reduced-motion' import { SHEET_CURVE, SHEET_HEIGHT_DURATION_MS } from '../sheet-motion-values' export const BOTTOM_DRAWER_EASING = Easing.bezier(...SHEET_CURVE) diff --git a/mobile/src/components/bottom-drawer/mounted-bottom-drawer.tsx b/mobile/src/components/bottom-drawer/mounted-bottom-drawer.tsx index 9e3ff826558..c3386d79ef1 100644 --- a/mobile/src/components/bottom-drawer/mounted-bottom-drawer.tsx +++ b/mobile/src/components/bottom-drawer/mounted-bottom-drawer.tsx @@ -30,7 +30,10 @@ import { import { useResponsiveLayout } from '../../layout/responsive-layout' import { useWindowSafeAreaInsets } from '../../layout/side-safe-area' import { useBackClaim } from '../../navigation/use-back-claim' -import { currentSoftKeyboardHeight, subscribeSoftKeyboard } from '../../platform/keyboard-occlusion' +import { + currentSoftKeyboardHeight, + subscribeSoftKeyboard +} from '../../platform/keyboard/keyboard-occlusion' import { hostOs } from '../../platform/host-os' import { hideDrawer, showDrawer, useBottomDrawerDrag } from './use-bottom-drawer-drag' import { useDrawerReducedMotion } from './bottom-drawer-constants' diff --git a/mobile/src/components/bottom-drawer/option-pager.test.tsx b/mobile/src/components/bottom-drawer/option-pager.test.tsx deleted file mode 100644 index df00998f3d2..00000000000 --- a/mobile/src/components/bottom-drawer/option-pager.test.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import { useState } from 'react' -import { Pressable, Text } from 'react-native' -import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, describe, expect, it, vi } from 'vitest' - -vi.mock('react-native', () => ({ - Platform: { OS: 'android' }, - Pressable: 'Pressable', - Text: 'Text', - View: 'View', - StyleSheet: { create: (styles: T) => styles, absoluteFill: {} } -})) -vi.mock('../../platform/use-reduced-motion', () => ({ useReducedMotionEnabled: () => false })) -vi.mock('./mounted-bottom-drawer', () => ({ MountedBottomDrawer: 'MountedBottomDrawer' })) -vi.mock('react-native-reanimated', async () => { - const React = await import('react') - const { reanimatedPagerStub } = await import('../../test-support/reanimated-pager-stub') - return { - ...reanimatedPagerStub(), - // Shared values outlive the drawer's content, just as they do in Reanimated. - useSharedValue: (initial: number) => React.useRef({ value: initial }).current, - useAnimatedStyle: (style: () => object) => style() - } -}) - -import { BottomDrawer, OptionPager, useOptionPager } from './BottomDrawer' -import { MountedBottomDrawer } from './mounted-bottom-drawer' - -function Sheet() { - const [visible, setVisible] = useState(false) - const pager = useOptionPager<'sortBy'>() - return ( - <> - setVisible(true)} /> - setVisible(false)} onAfterClose={pager.forget}> - setVisible(false)} /> - pager.open('sortBy')} />} - page={ - pager.page === null ? null : ( - - Recent - - ) - } - /> - - - ) -} - -let renderer: ReactTestRenderer | null = null - -afterEach(() => { - act(() => renderer?.unmount()) - renderer = null -}) - -function press(label: string) { - act(() => { - renderer!.root - .find((node) => String(node.type) === 'Pressable' && node.props.accessibilityLabel === label) - .props.onPress() - }) -} - -function layout(width: number) { - act(() => { - renderer!.root - .find((node) => String(node.type) === 'View' && node.props.onLayout) - .props.onLayout({ nativeEvent: { layout: { width } } }) - }) -} - -const drawer = () => renderer!.root.findByType(MountedBottomDrawer) -const main = () => - renderer!.root.find((node) => String(node.type) === 'AnimatedView' && 'aria-hidden' in node.props) -const mainOffset = () => main().props.style.at(-1).transform[0].translateX - -function open() { - press('Workspace options') - layout(600) -} - -describe('a drawer option pager', () => { - it('reopens at the root with full-width content after Done from a sub-page', () => { - act(() => { - renderer = create() - }) - open() - expect(mainOffset()).toBeCloseTo(0) - press('Sort by') - expect(mainOffset()).toBeLessThan(0) - - press('Done') - expect(drawer().props.visible).toBe(false) - expect(main().props['aria-hidden']).toBe(true) - // Native hide completes before KeyedBottomDrawer unmounts content and forgets its page. - act(() => drawer().props.onHidden()) - expect(renderer!.root.findAllByType(MountedBottomDrawer)).toHaveLength(0) - - open() - expect(main().props['aria-hidden']).toBe(false) - expect(mainOffset()).toBeCloseTo(0) - layout(390) - expect(mainOffset()).toBeCloseTo(0) - }) - - it('still pushes a sub-page and returns to the root with Back', () => { - act(() => { - renderer = create() - }) - open() - press('Sort by') - expect(main().props['aria-hidden']).toBe(true) - expect(mainOffset()).toBeLessThan(0) - press('Back') - expect(drawer().props.visible).toBe(true) - expect(main().props['aria-hidden']).toBe(false) - expect(mainOffset()).toBeCloseTo(0) - }) -}) diff --git a/mobile/src/components/composer-input/MessageBox.tsx b/mobile/src/components/composer-input/MessageBox.tsx index dd009589c91..5b90513b1ca 100644 --- a/mobile/src/components/composer-input/MessageBox.tsx +++ b/mobile/src/components/composer-input/MessageBox.tsx @@ -9,7 +9,7 @@ import { } from 'react-native' import { colors } from '../../theme/mobile-theme' import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme' -import { useTextInputContentHeight } from '../../platform/text-input-content-height' +import { useTextInputContentHeight } from '../../platform/text-input/text-input-content-height' import { ComposerSendButton } from './ComposerSendButton' import { composerInputStyles as styles, diff --git a/mobile/src/components/drag-reorder-test-harness.test-fixture.tsx b/mobile/src/components/drag-reorder-test-harness.test-fixture.tsx index e232b2185bd..ca5848edaeb 100644 --- a/mobile/src/components/drag-reorder-test-harness.test-fixture.tsx +++ b/mobile/src/components/drag-reorder-test-harness.test-fixture.tsx @@ -241,7 +241,10 @@ vi.mock('../layout/use-shell-side-rail', () => ({ useDirectionPadAvailable: () = vi.mock('@react-native-async-storage/async-storage', () => ({ default: { getItem: async () => null, setItem: async () => {} } })) -vi.mock('../platform/haptics', () => ({ triggerMediumImpact: vi.fn(), triggerSelection: vi.fn() })) +vi.mock('../platform/haptics/haptics', () => ({ + triggerMediumImpact: vi.fn(), + triggerSelection: vi.fn() +})) import { DragReorderList } from './DragReorderList' diff --git a/mobile/src/components/list/ListGroup.tsx b/mobile/src/components/list/ListGroup.tsx index b27afdeae3d..66b34b3dc54 100644 --- a/mobile/src/components/list/ListGroup.tsx +++ b/mobile/src/components/list/ListGroup.tsx @@ -2,7 +2,7 @@ import { Children, Fragment, isValidElement, type ReactNode } from 'react' import { Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native' import { Check, ChevronDown, ChevronRight } from 'lucide-react-native' import { DisclosureChevron } from '../DisclosureChevron' -import { triggerSelection } from '../../platform/haptics' +import { triggerSelection } from '../../platform/haptics/haptics' import { colors, feedback, radii, spacing } from '../../theme/mobile-theme' import { themedStyleSheet } from '../../theme/themed-styles' import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme' diff --git a/mobile/src/components/markdown-link-handler.ts b/mobile/src/components/markdown-link-handler.ts index ceb005aff4e..78b99e24bb1 100644 --- a/mobile/src/components/markdown-link-handler.ts +++ b/mobile/src/components/markdown-link-handler.ts @@ -1,5 +1,5 @@ import { createContext } from 'react' -import { openExternalLink } from '../platform/external-link' +import { openExternalLink } from '../platform/external-links/external-link' // Screens that offer choices for a tapped web link provide this; elsewhere the system browser opens it. export const MarkdownLinkHandlerContext = createContext<(url: string) => void>(openExternalLink) diff --git a/mobile/src/components/mobile-markdown-inline.tsx b/mobile/src/components/mobile-markdown-inline.tsx index 295fc94caa8..44d6d073926 100644 --- a/mobile/src/components/mobile-markdown-inline.tsx +++ b/mobile/src/components/mobile-markdown-inline.tsx @@ -1,7 +1,7 @@ import type { PhrasingContent } from 'mdast' import { Fragment, type ReactNode } from 'react' import type { TextProps } from 'react-native' -import { openExternalLink } from '../platform/external-link' +import { openExternalLink } from '../platform/external-links/external-link' import { detectFilePathSegments, isFilePathCodeSpan, diff --git a/mobile/src/components/mobile-pr-url.ts b/mobile/src/components/mobile-pr-url.ts index 8c9d15a374f..4b0f2aa1a09 100644 --- a/mobile/src/components/mobile-pr-url.ts +++ b/mobile/src/components/mobile-pr-url.ts @@ -1 +1 @@ -export { openExternalLink as openMobilePrUrl } from '../platform/external-link' +export { openExternalLink as openMobilePrUrl } from '../platform/external-links/external-link' diff --git a/mobile/src/components/nav-bar/BottomSearchBar.tsx b/mobile/src/components/nav-bar/BottomSearchBar.tsx index e56e3070e55..ca4a518c711 100644 --- a/mobile/src/components/nav-bar/BottomSearchBar.tsx +++ b/mobile/src/components/nav-bar/BottomSearchBar.tsx @@ -4,8 +4,8 @@ import Svg, { Defs, LinearGradient, Rect, Stop } from 'react-native-svg' import { X } from 'lucide-react-native' import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated' import { useMobileTranslation } from '../../i18n/use-mobile-translation' -import { useKeyboardOcclusion } from '../../platform/keyboard-occlusion' -import { useReducedMotionEnabled } from '../../platform/use-reduced-motion' +import { useKeyboardOcclusion } from '../../platform/keyboard/keyboard-occlusion' +import { useReducedMotionEnabled } from '../../platform/accessibility/use-reduced-motion' import { colors, feedback, radii, spacing } from '../../theme/mobile-theme' import { themedStyleSheet } from '../../theme/themed-styles' import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme' diff --git a/mobile/src/components/nav-bar/NavBar.tsx b/mobile/src/components/nav-bar/NavBar.tsx index 91a19430c8c..f8752a33eaf 100644 --- a/mobile/src/components/nav-bar/NavBar.tsx +++ b/mobile/src/components/nav-bar/NavBar.tsx @@ -9,7 +9,7 @@ import { type ViewStyle } from 'react-native' import { ChevronDown } from 'lucide-react-native' -import { useReducedMotionEnabled } from '../../platform/use-reduced-motion' +import { useReducedMotionEnabled } from '../../platform/accessibility/use-reduced-motion' import { colors, pressedStyle, spacing } from '../../theme/mobile-theme' import { themedStyleSheet } from '../../theme/themed-styles' import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme' diff --git a/mobile/src/components/nav-bar/PullDownMenu.tsx b/mobile/src/components/nav-bar/PullDownMenu.tsx index da1297b7a41..87827d9c59d 100644 --- a/mobile/src/components/nav-bar/PullDownMenu.tsx +++ b/mobile/src/components/nav-bar/PullDownMenu.tsx @@ -13,7 +13,7 @@ import { type HostInstance } from 'react-native' import { Check } from 'lucide-react-native' -import { useReducedMotionEnabled } from '../../platform/use-reduced-motion' +import { useReducedMotionEnabled } from '../../platform/accessibility/use-reduced-motion' import { useBackClaim } from '../../navigation/use-back-claim' import { colors, radii, spacing, typography } from '../../theme/mobile-theme' import { themedStyleSheet } from '../../theme/themed-styles' diff --git a/mobile/src/components/pr-sidebar/MobileLinkPrForm.tsx b/mobile/src/components/pr-sidebar/MobileLinkPrForm.tsx index 03f03584590..189b3398471 100644 --- a/mobile/src/components/pr-sidebar/MobileLinkPrForm.tsx +++ b/mobile/src/components/pr-sidebar/MobileLinkPrForm.tsx @@ -4,7 +4,7 @@ import { colors, radii, spacing, typography, pressedStyle } from '../../theme/mo import { themedStyleSheet } from '../../theme/themed-styles' import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme' import type { RpcClient } from '../../transport/rpc/rpc-client-contract' -import { triggerError, triggerSuccess } from '../../platform/haptics' +import { triggerError, triggerSuccess } from '../../platform/haptics/haptics' import { parseGitHubIssueOrPRLink, parseGitHubIssueOrPRNumber diff --git a/mobile/src/components/pr-sidebar/PRCommentCard.tsx b/mobile/src/components/pr-sidebar/PRCommentCard.tsx index f7837163433..0b9a2f44c30 100644 --- a/mobile/src/components/pr-sidebar/PRCommentCard.tsx +++ b/mobile/src/components/pr-sidebar/PRCommentCard.tsx @@ -1,6 +1,6 @@ import { memo, useState } from 'react' import { Image, Platform, Pressable, Text, View } from 'react-native' -import { openExternalLink } from '../../platform/external-link' +import { openExternalLink } from '../../platform/external-links/external-link' import { Check, CornerDownRight, ExternalLink, Pencil, Trash2, Undo2 } from 'lucide-react-native' import type { GitHubReaction, diff --git a/mobile/src/components/pr-sidebar/PrCheckFailureRow.tsx b/mobile/src/components/pr-sidebar/PrCheckFailureRow.tsx index 2d7d8416984..4619c602da4 100644 --- a/mobile/src/components/pr-sidebar/PrCheckFailureRow.tsx +++ b/mobile/src/components/pr-sidebar/PrCheckFailureRow.tsx @@ -14,9 +14,9 @@ import { themedStyleSheet } from '../../theme/themed-styles' import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme' import { useMobileTranslation } from '../../i18n/use-mobile-translation' import type { Translate } from '../../i18n/mobile-i18n' -import { openExternalLink } from '../../platform/external-link' -import { useClipboardWriter } from '../../platform/clipboard' -import { triggerError, triggerSuccess } from '../../platform/haptics' +import { openExternalLink } from '../../platform/external-links/external-link' +import { useClipboardWriter } from '../../platform/clipboard/clipboard' +import { triggerError, triggerSuccess } from '../../platform/haptics/haptics' import type { PRCheckDetail } from '../../../../src/shared/github/checks/check-types' import { LIST_LEADING_SLOT, LIST_ROW_PADDING } from '../list/ListGroup' import { PullDownMenu } from '../nav-bar/PullDownMenu' diff --git a/mobile/src/components/pr-sidebar/PrChecksScreen.tsx b/mobile/src/components/pr-sidebar/PrChecksScreen.tsx index 3663e3a12e8..5ca0e02d061 100644 --- a/mobile/src/components/pr-sidebar/PrChecksScreen.tsx +++ b/mobile/src/components/pr-sidebar/PrChecksScreen.tsx @@ -6,7 +6,7 @@ import { themedStyleSheet } from '../../theme/themed-styles' import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme' import { useMobileTranslation } from '../../i18n/use-mobile-translation' import type { Translate } from '../../i18n/mobile-i18n' -import { openExternalLink } from '../../platform/external-link' +import { openExternalLink } from '../../platform/external-links/external-link' import type { PRCheckDetail } from '../../../../src/shared/github/checks/check-types' import { isBrokenCheck } from '../../../../src/shared/source-control/checks/pr-checks-fix-prompt' import { sortChecksBySeverity } from '../../../../src/shared/source-control/checks/pr-check-severity-order' diff --git a/mobile/src/components/pr-sidebar/PrCommentsScreen.tsx b/mobile/src/components/pr-sidebar/PrCommentsScreen.tsx index 6b99dc9e484..508da894c73 100644 --- a/mobile/src/components/pr-sidebar/PrCommentsScreen.tsx +++ b/mobile/src/components/pr-sidebar/PrCommentsScreen.tsx @@ -18,7 +18,7 @@ import { useMobileLaunchAgentLabel } from '../../session/agent-launch/use-mobile import { useHostProtocolGates } from '../host-protocol-gates-context' import { AgentLaunchNotice } from '../AgentLaunchNotice' import { useNow } from '../../hooks/use-now' -import { useKeyboardOcclusion } from '../../platform/keyboard-occlusion' +import { useKeyboardOcclusion } from '../../platform/keyboard/keyboard-occlusion' import { useBackClaim } from '../../navigation/use-back-claim' import { NavBarButton } from '../nav-bar/NavBarButton' import { PullDownMenu } from '../nav-bar/PullDownMenu' diff --git a/mobile/src/components/pr-sidebar/PrCreateSheet.tsx b/mobile/src/components/pr-sidebar/PrCreateSheet.tsx index 8080e0faf27..86eb98b660c 100644 --- a/mobile/src/components/pr-sidebar/PrCreateSheet.tsx +++ b/mobile/src/components/pr-sidebar/PrCreateSheet.tsx @@ -5,7 +5,7 @@ import { colors, pressedStyle, spacing } from '../../theme/mobile-theme' import { themedStyleSheet } from '../../theme/themed-styles' import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme' import { useMobileTranslation } from '../../i18n/use-mobile-translation' -import { triggerError, triggerSuccess } from '../../platform/haptics' +import { triggerError, triggerSuccess } from '../../platform/haptics/haptics' import { SheetHeader } from '../nav-bar/SheetHeader' import { SheetPrimaryButton } from '../SheetPrimaryButton' import { ListGroup, ListRow } from '../list/ListGroup' diff --git a/mobile/src/components/pr-sidebar/PrThreadRow.tsx b/mobile/src/components/pr-sidebar/PrThreadRow.tsx index 68d3893e4e2..6e91ba4593f 100644 --- a/mobile/src/components/pr-sidebar/PrThreadRow.tsx +++ b/mobile/src/components/pr-sidebar/PrThreadRow.tsx @@ -26,7 +26,7 @@ import { colors, pressedStyle, radii, spacing, typography } from '../../theme/mo import { themedStyleSheet } from '../../theme/themed-styles' import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme' import { useMobileTranslation } from '../../i18n/use-mobile-translation' -import { openExternalLink } from '../../platform/external-link' +import { openExternalLink } from '../../platform/external-links/external-link' import { canEditComment, isResolvableComment } from '../../source-control/review/pr-comment-actions' import { ConfirmModal } from '../ConfirmModal' import { PullDownMenu } from '../nav-bar/PullDownMenu' diff --git a/mobile/src/components/pr-sidebar/PrThreadScreen.tsx b/mobile/src/components/pr-sidebar/PrThreadScreen.tsx index d87cf43aae6..11f166c8c1e 100644 --- a/mobile/src/components/pr-sidebar/PrThreadScreen.tsx +++ b/mobile/src/components/pr-sidebar/PrThreadScreen.tsx @@ -5,8 +5,8 @@ import { colors, spacing, typography } from '../../theme/mobile-theme' import { themedStyleSheet } from '../../theme/themed-styles' import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme' import { useMobileTranslation } from '../../i18n/use-mobile-translation' -import { openExternalLink } from '../../platform/external-link' -import { useKeyboardOcclusion } from '../../platform/keyboard-occlusion' +import { openExternalLink } from '../../platform/external-links/external-link' +import { useKeyboardOcclusion } from '../../platform/keyboard/keyboard-occlusion' import { isResolvableComment } from '../../source-control/review/pr-comment-actions' import { MobileStateView } from '../MobileStateView' import { PrMarkdown } from './PrMarkdown' diff --git a/mobile/src/components/pr-sidebar/pr-tab-styles.ts b/mobile/src/components/pr-sidebar/pr-tab-styles.ts index 15336f306ab..13fbed51ca3 100644 --- a/mobile/src/components/pr-sidebar/pr-tab-styles.ts +++ b/mobile/src/components/pr-sidebar/pr-tab-styles.ts @@ -1,7 +1,7 @@ import { StyleSheet } from 'react-native' import { colors, radii, spacing, typography } from '../../theme/mobile-theme' import { themedStyleSheet } from '../../theme/themed-styles' -import { TEXT_INPUT_FONT_SIZE } from '../../platform/text-input-font-size' +import { TEXT_INPUT_FONT_SIZE } from '../../platform/text-input/text-input-font-size' export const prTabStyles = themedStyleSheet(() => ({ container: { flex: 1, backgroundColor: colors.bgBase }, diff --git a/mobile/src/components/pr-sidebar/use-pr-tab-menu.ts b/mobile/src/components/pr-sidebar/use-pr-tab-menu.ts index a977b03f32a..58d9b23a910 100644 --- a/mobile/src/components/pr-sidebar/use-pr-tab-menu.ts +++ b/mobile/src/components/pr-sidebar/use-pr-tab-menu.ts @@ -1,7 +1,7 @@ import { useState } from 'react' import { useMobileTranslation } from '../../i18n/use-mobile-translation' -import { useClipboardWriter } from '../../platform/clipboard' -import { triggerError, triggerSuccess } from '../../platform/haptics' +import { useClipboardWriter } from '../../platform/clipboard/clipboard' +import { triggerError, triggerSuccess } from '../../platform/haptics/haptics' import { openMobilePrUrl } from '../mobile-pr-url' import type { ActionSheetAction } from '../ActionSheetModal' import { canEditPRTitle } from '../../source-control/review/pr-title-edit' diff --git a/mobile/src/components/rich-markdown/document-style.ts b/mobile/src/components/rich-markdown/document-style.ts index 235e0ea8b3f..a8763895e7c 100644 --- a/mobile/src/components/rich-markdown/document-style.ts +++ b/mobile/src/components/rich-markdown/document-style.ts @@ -1,5 +1,5 @@ import { colors, getMobileColorScheme } from '../../theme/mobile-theme' -import { TEXT_INPUT_FONT_SIZE } from '../../platform/text-input-font-size' +import { TEXT_INPUT_FONT_SIZE } from '../../platform/text-input/text-input-font-size' // Read theme at build time; native embeds sheet, page scopes it to editor host. // #editor scrolls itself: iOS WebKit stops drawing selection ~2 screens into an editable scrolled by a separate box (#34472). diff --git a/mobile/src/components/use-detail-card-scope.web.ts b/mobile/src/components/use-detail-card-scope.web.ts index f83d48a4e61..f63168bd35a 100644 --- a/mobile/src/components/use-detail-card-scope.web.ts +++ b/mobile/src/components/use-detail-card-scope.web.ts @@ -1,6 +1,6 @@ import { useLayoutEffect, useRef } from 'react' import type { DetailCardScopeOptions } from './use-detail-card-scope' -import { isTextField } from '../platform/keyboard-persisting-taps' +import { isTextField } from '../platform/keyboard/keyboard-persisting-taps' function element(value: unknown): HTMLElement | null { return value instanceof HTMLElement ? value : null diff --git a/mobile/src/components/use-new-workspace-create-submit.ts b/mobile/src/components/use-new-workspace-create-submit.ts index d1faecff608..57a851228b0 100644 --- a/mobile/src/components/use-new-workspace-create-submit.ts +++ b/mobile/src/components/use-new-workspace-create-submit.ts @@ -4,7 +4,7 @@ import type { PersistedTrustedOrcaHooks } from '../../../src/shared/orca-yaml-ho import type { RetiredNameRegistry } from '../../../src/shared/worktree/retired-name-registry' import type { RpcClient } from '../transport/rpc/rpc-client-contract' import { folderCreateBusyMessage, uncertainCreateMessage } from './new-workspace-create-copy' -import { triggerError, triggerSuccess } from '../platform/haptics' +import { triggerError, triggerSuccess } from '../platform/haptics/haptics' import type { WorktreeCreateParent } from '../new-workspace/creation/worktree-create-retry' import { createBlankWorkspace } from '../new-workspace/creation/blank-workspace-create' import { isMobileTuiAgentEnabled } from '../agents/mobile-tui-agents' diff --git a/mobile/src/dictation/MobileDictationMeter.tsx b/mobile/src/dictation/MobileDictationMeter.tsx index 7cf75cb2cc2..ad97b0941c1 100644 --- a/mobile/src/dictation/MobileDictationMeter.tsx +++ b/mobile/src/dictation/MobileDictationMeter.tsx @@ -6,7 +6,7 @@ import { type MobileDictationMeter } from '../hooks/mobile-dictation-meter' import { useNow } from '../hooks/use-now' -import { useReducedMotionEnabled } from '../platform/use-reduced-motion' +import { useReducedMotionEnabled } from '../platform/accessibility/use-reduced-motion' import { colors, typography } from '../theme/mobile-theme' import { themedStyleSheet } from '../theme/themed-styles' import { useMobileColorScheme } from '../theme/use-mobile-color-scheme' diff --git a/mobile/src/dictation/mobile-dictation-host.tsx b/mobile/src/dictation/mobile-dictation-host.tsx index 37daa11a247..fec51c8f541 100644 --- a/mobile/src/dictation/mobile-dictation-host.tsx +++ b/mobile/src/dictation/mobile-dictation-host.tsx @@ -2,8 +2,8 @@ import { useCallback, useLayoutEffect, useMemo, useRef, useState, type ReactNode import { useMobileDictation } from '../hooks/use-mobile-dictation' import type { UseMobileDictationResult } from '../hooks/mobile-dictation-session-state' import { useRouteHandoff } from '../navigation/route-handoff' -import { triggerError } from '../platform/haptics' -import { useClipboardWriter } from '../platform/clipboard' +import { triggerError } from '../platform/haptics/haptics' +import { useClipboardWriter } from '../platform/clipboard/clipboard' import { dictationFailureMessage, isDictationSetupRequiredError } from './mobile-dictation-setup' import { useMobileDictationHaptics } from './use-mobile-dictation-haptics' import { useMobileDictationSilenceStop } from './use-mobile-dictation-silence-stop' diff --git a/mobile/src/dictation/use-mobile-dictation-announcements.ts b/mobile/src/dictation/use-mobile-dictation-announcements.ts index 0dd2d729a4c..dd9a4dec999 100644 --- a/mobile/src/dictation/use-mobile-dictation-announcements.ts +++ b/mobile/src/dictation/use-mobile-dictation-announcements.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import type { UseMobileDictationResult } from '../hooks/mobile-dictation-session-state' -import { useAccessibilityAnnouncer } from '../platform/accessibility-announcer' +import { useAccessibilityAnnouncer } from '../platform/accessibility/accessibility-announcer' import { dictationAnnounceCopy } from './mobile-dictation-announce-copy' import { dictationKeptFailureCause } from './mobile-dictation-controls-copy' diff --git a/mobile/src/dictation/use-mobile-dictation-cancel-drop.ts b/mobile/src/dictation/use-mobile-dictation-cancel-drop.ts index 2ba5895f968..86fc8afa888 100644 --- a/mobile/src/dictation/use-mobile-dictation-cancel-drop.ts +++ b/mobile/src/dictation/use-mobile-dictation-cancel-drop.ts @@ -1,6 +1,7 @@ import { useCallback, type RefObject } from 'react' import type { GestureResponderEvent, HostInstance } from 'react-native' -import { measurePageBox, type PageBox } from '../platform/page-box' +import { measurePageBox } from '../platform/view-measurement/page-box' +import type { PageBox } from '../platform/view-measurement/page-box-contract' // A dragging finger lands less precisely than a tap, so the drop zone reaches past ✕. const DROP_SLOP = 12 diff --git a/mobile/src/dictation/use-mobile-dictation-haptics.ts b/mobile/src/dictation/use-mobile-dictation-haptics.ts index ae927fdd6b5..e0125da9327 100644 --- a/mobile/src/dictation/use-mobile-dictation-haptics.ts +++ b/mobile/src/dictation/use-mobile-dictation-haptics.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect } from 'react' -import { triggerEdgeBump, triggerSelection } from '../platform/haptics' +import { triggerEdgeBump, triggerSelection } from '../platform/haptics/haptics' import type { UseMobileDictationResult } from '../hooks/mobile-dictation-session-state' /** Taps when recording begins and returns a stop that ticks. */ diff --git a/mobile/src/files/attachments/MobileChatAttachmentPreview.tsx b/mobile/src/files/attachments/MobileChatAttachmentPreview.tsx index eee059e3f2c..a2deb2f9515 100644 --- a/mobile/src/files/attachments/MobileChatAttachmentPreview.tsx +++ b/mobile/src/files/attachments/MobileChatAttachmentPreview.tsx @@ -11,7 +11,7 @@ import { useWorkspaceClient } from '../../worktree/use-workspace-client' import { createAttachmentTextPreviewHook } from '../../../../src/shared/attachment-text/use-attachment-text-preview' import { chatTextAttachmentName } from '../../../../src/shared/native-chat-text-attachment-references' import { MOBILE_BRIDGE_MAX_FRAME_BYTES } from '../../../../src/shared/mobile-bridge-frame-bytes' -import { useClipboardWriter } from '../../platform/clipboard' +import { useClipboardWriter } from '../../platform/clipboard/clipboard' import { getUiIntlLocale } from '../../i18n/ui-intl-locale' import { chatAttachmentPreviewStyles as styles } from '../preview/mobile-file-preview-styles' import type { MobileFilePreviewRouteParams } from '../preview/mobile-file-preview-route' diff --git a/mobile/src/files/attachments/mobile-agent-image-fit.ts b/mobile/src/files/attachments/mobile-agent-image-fit.ts index 5c49afb7774..345af967411 100644 --- a/mobile/src/files/attachments/mobile-agent-image-fit.ts +++ b/mobile/src/files/attachments/mobile-agent-image-fit.ts @@ -8,7 +8,7 @@ import { import { base64DecodedByteLength } from '../../../../src/shared/agent-session/agent-session-attachments' import { detectImageFileSignature } from '../../../../src/shared/image-file-signature' import { decodeBase64Bytes } from '../../transport/encryption/base64-byte-codec' -import type { PickedMobileImage } from '../../platform/media-picker-contract' +import type { PickedMobileImage } from '../../platform/media-picking/media-picker-contract' // Page-only: decodes and re-encodes through the WebView's canvas. diff --git a/mobile/src/files/attachments/mobile-file-source-picker.ts b/mobile/src/files/attachments/mobile-file-source-picker.ts index ab4f501d54f..81740ab1602 100644 --- a/mobile/src/files/attachments/mobile-file-source-picker.ts +++ b/mobile/src/files/attachments/mobile-file-source-picker.ts @@ -3,7 +3,7 @@ import { MOBILE_PICKED_FILE_MAX_BYTES, MobileFileTooLargeError, type PickedMobileFile -} from '../../platform/media-picker-contract' +} from '../../platform/media-picking/media-picker-contract' import { defaultMobileImageFileFactory, readUriAsBase64, diff --git a/mobile/src/files/attachments/mobile-image-attachment.ts b/mobile/src/files/attachments/mobile-image-attachment.ts index 259d2816731..c8912568ebe 100644 --- a/mobile/src/files/attachments/mobile-image-attachment.ts +++ b/mobile/src/files/attachments/mobile-image-attachment.ts @@ -3,7 +3,10 @@ import { buildMobileTerminalImagePastePayload, saveMobileClipboardImageAsTempFile } from './mobile-clipboard-image' -import type { MobileImageSource, PickedMobileImage } from '../../platform/media-picker-contract' +import type { + MobileImageSource, + PickedMobileImage +} from '../../platform/media-picking/media-picker-contract' import { nativeChatTerminalWrite } from '../../terminal/session/mobile-session-terminal-operations' import type { MobileClipboardImageRpcSender } from './mobile-clipboard-image-operations' diff --git a/mobile/src/files/attachments/mobile-image-source-picker.ts b/mobile/src/files/attachments/mobile-image-source-picker.ts index 1df1e6f7175..812c44f9001 100644 --- a/mobile/src/files/attachments/mobile-image-source-picker.ts +++ b/mobile/src/files/attachments/mobile-image-source-picker.ts @@ -8,10 +8,13 @@ import { assertClipboardImageByteLengthWithinLimit } from '../../../../src/shared/clipboard-image' import { MobileImageBase64Accumulator } from './mobile-image-base64-accumulator' -import { isPickedImageMime } from '../../platform/mobile-picked-file-name' +import { isPickedImageMime } from '../../platform/media-picking/mobile-picked-file-name' // Import permission errors from the Expo-free contract so page-side catches cannot pull native picker code into the bundle. -import { ImageLibraryPermissionError } from '../../platform/media-picker-contract' -import type { MobileImageSource, PickedMobileImage } from '../../platform/media-picker-contract' +import { ImageLibraryPermissionError } from '../../platform/media-picking/media-picker-contract' +import type { + MobileImageSource, + PickedMobileImage +} from '../../platform/media-picking/media-picker-contract' const MOBILE_IMAGE_READ_CHUNK_BYTES = 256 * 1024 diff --git a/mobile/src/files/attachments/use-mobile-image-attachment.ts b/mobile/src/files/attachments/use-mobile-image-attachment.ts index 450a50e4975..ab35e266d51 100644 --- a/mobile/src/files/attachments/use-mobile-image-attachment.ts +++ b/mobile/src/files/attachments/use-mobile-image-attachment.ts @@ -2,11 +2,11 @@ import { useCallback, useState } from 'react' import { translate } from '../../i18n/mobile-i18n' import type { RpcClient } from '../../transport/rpc/rpc-client-contract' import type { ConnectionState } from '../../transport/connection/connection-state' -import { useMediaPicker } from '../../platform/media-picker' +import { useMediaPicker } from '../../platform/media-picking/media-picker' import { ImageLibraryPermissionError, type MobileImageSource -} from '../../platform/media-picker-contract' +} from '../../platform/media-picking/media-picker-contract' import { attachMobileImageToTerminal } from './mobile-image-attachment' import { fitMobileAgentImage } from './mobile-agent-image-fit' diff --git a/mobile/src/files/attachments/use-mobile-session-attachments.ts b/mobile/src/files/attachments/use-mobile-session-attachments.ts index 34998f4c8a3..d097a129cff 100644 --- a/mobile/src/files/attachments/use-mobile-session-attachments.ts +++ b/mobile/src/files/attachments/use-mobile-session-attachments.ts @@ -1,7 +1,7 @@ import { useEffect } from 'react' import { AppState, type AppStateStatus } from 'react-native' -import { useClipboardReader } from '../../platform/clipboard' -import { triggerSelection, triggerError } from '../../platform/haptics' +import { useClipboardReader } from '../../platform/clipboard/clipboard' +import { triggerSelection, triggerError } from '../../platform/haptics/haptics' import { resolveMobileImageAttachmentAgent } from './mobile-image-paste-agent' import { useMobileNewTabAgentOptions } from '../../session/agent-launch/use-mobile-new-tab-agent-options' import { useMobileSessionImageAttachments } from './use-mobile-session-image-attachments' diff --git a/mobile/src/files/attachments/use-mobile-session-image-attachments.ts b/mobile/src/files/attachments/use-mobile-session-image-attachments.ts index 00af1d341b1..6c50ce1af36 100644 --- a/mobile/src/files/attachments/use-mobile-session-image-attachments.ts +++ b/mobile/src/files/attachments/use-mobile-session-image-attachments.ts @@ -1,6 +1,6 @@ import type { RpcClient } from '../../transport/rpc/rpc-client-contract' import type { ConnectionState } from '../../transport/connection/connection-state' -import type { MobileImageSource } from '../../platform/media-picker-contract' +import type { MobileImageSource } from '../../platform/media-picking/media-picker-contract' import type { MobileNativeChatSendIntent, MobileNativeChatSendOutcome diff --git a/mobile/src/files/explorer/MobileFileExplorerPanel.test.ts b/mobile/src/files/explorer/MobileFileExplorerPanel.test.ts index dcf2feb3561..70f50164519 100644 --- a/mobile/src/files/explorer/MobileFileExplorerPanel.test.ts +++ b/mobile/src/files/explorer/MobileFileExplorerPanel.test.ts @@ -25,7 +25,9 @@ const mockTransport = vi.hoisted( }) ) -vi.mock('../../platform/use-reduced-motion', () => ({ useReducedMotionEnabled: () => true })) +vi.mock('../../platform/accessibility/use-reduced-motion', () => ({ + useReducedMotionEnabled: () => true +})) vi.mock('react-native-reanimated', () => ({ Easing: { bezier: () => (t: number) => t }, default: { View: 'AnimatedView' }, @@ -110,7 +112,7 @@ vi.mock('lucide-react-native', () => ({ X: 'X' })) -vi.mock('../../platform/haptics', () => ({ +vi.mock('../../platform/haptics/haptics', () => ({ triggerSelection: vi.fn() })) diff --git a/mobile/src/files/explorer/mobile-file-explorer-row-actions-sheet.tsx b/mobile/src/files/explorer/mobile-file-explorer-row-actions-sheet.tsx index 1fb90aa511b..acaaaa45566 100644 --- a/mobile/src/files/explorer/mobile-file-explorer-row-actions-sheet.tsx +++ b/mobile/src/files/explorer/mobile-file-explorer-row-actions-sheet.tsx @@ -2,8 +2,8 @@ import { useState } from 'react' import { Copy } from 'lucide-react-native' import { ActionSheetModal, type ActionSheetAction } from '../../components/ActionSheetModal' import { useMobileTranslation } from '../../i18n/use-mobile-translation' -import { useClipboardWriter } from '../../platform/clipboard' -import { triggerError, triggerSuccess } from '../../platform/haptics' +import { useClipboardWriter } from '../../platform/clipboard/clipboard' +import { triggerError, triggerSuccess } from '../../platform/haptics/haptics' import type { TreeNode } from './file-tree' /** Which row's actions are open, and the sheet that shows them. */ diff --git a/mobile/src/files/explorer/mobile-file-explorer-row.tsx b/mobile/src/files/explorer/mobile-file-explorer-row.tsx index c1da0c60af6..d773409b27d 100644 --- a/mobile/src/files/explorer/mobile-file-explorer-row.tsx +++ b/mobile/src/files/explorer/mobile-file-explorer-row.tsx @@ -8,7 +8,7 @@ import { Folder, Image as ImageIcon } from 'lucide-react-native' -import { triggerMediumImpact, triggerSelection } from '../../platform/haptics' +import { triggerMediumImpact, triggerSelection } from '../../platform/haptics/haptics' import { useMobileTranslation } from '../../i18n/use-mobile-translation' import { colors, spacing } from '../../theme/mobile-theme' import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme' diff --git a/mobile/src/files/media/MobilePdfPreview.tsx b/mobile/src/files/media/MobilePdfPreview.tsx index d76848660ee..bdbc71cecf9 100644 --- a/mobile/src/files/media/MobilePdfPreview.tsx +++ b/mobile/src/files/media/MobilePdfPreview.tsx @@ -3,8 +3,8 @@ import { useFocusEffect } from 'expo-router' import { ActivityIndicator, Text, View } from 'react-native' import { colors } from '../../theme/mobile-theme' import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme' -import { usePdfOpener } from '../../platform/pdf-opener' -import { PdfOpenRefusedError } from '../../platform/pdf-opener-contract' +import { usePdfOpener } from '../../platform/pdf-viewing/pdf-opener' +import { PdfOpenRefusedError } from '../../platform/pdf-viewing/pdf-opener-contract' import type { MobileFileMedia } from './mobile-file-media' import { filePreviewStyles as styles } from '../preview/mobile-file-preview-styles' import { MobileLoadErrorState, MobileStateButton } from '../../components/MobileStateView' diff --git a/mobile/src/files/session-documents/use-mobile-file-tap-handlers.ts b/mobile/src/files/session-documents/use-mobile-file-tap-handlers.ts index 5718a80e123..8de04d481d8 100644 --- a/mobile/src/files/session-documents/use-mobile-file-tap-handlers.ts +++ b/mobile/src/files/session-documents/use-mobile-file-tap-handlers.ts @@ -5,7 +5,7 @@ import type { ParsedFileLinkLocation } from '../../../../src/shared/file-links/f import { hostStackRouteHref } from '../../navigation/host-stack-navigation' import { useWorkspaceExecutionHost } from '../../navigation/workspace-execution-host' import { useWorkspaceRouteHandoff } from '../../navigation/workspace-route-handoff' -import { triggerSelection } from '../../platform/haptics' +import { triggerSelection } from '../../platform/haptics/haptics' import { openMobileFileTap, type FileTapSessionTab } from './mobile-file-tap-open' import { mobileSessionRouteTarget } from '../../session/mobile-session-route' import { openMobileNativeChatFileTap } from '../../session/native-chat/visual/mobile-native-chat-open-file' diff --git a/mobile/src/files/session-documents/use-mobile-session-file-actions.ts b/mobile/src/files/session-documents/use-mobile-session-file-actions.ts index 1a2652f192f..3bc5dcb5137 100644 --- a/mobile/src/files/session-documents/use-mobile-session-file-actions.ts +++ b/mobile/src/files/session-documents/use-mobile-session-file-actions.ts @@ -1,5 +1,5 @@ import { useCallback } from 'react' -import { openExternalLink } from '../../platform/external-link' +import { openExternalLink } from '../../platform/external-links/external-link' import { useMobileFileTapHandlers } from './use-mobile-file-tap-handlers' import { resolveMobileNativeChatSessionId } from '../../session/native-chat/mobile-native-chat-eligibility' import { useProvideSessionScreenHandlers } from '../../session/session-screen-handlers' diff --git a/mobile/src/files/session-documents/use-mobile-session-markdown-actions.ts b/mobile/src/files/session-documents/use-mobile-session-markdown-actions.ts index 41502a7ba5e..6b6687eaded 100644 --- a/mobile/src/files/session-documents/use-mobile-session-markdown-actions.ts +++ b/mobile/src/files/session-documents/use-mobile-session-markdown-actions.ts @@ -1,6 +1,6 @@ import { useCallback, useMemo } from 'react' import { Keyboard, Platform } from 'react-native' -import { useClipboardWriter } from '../../platform/clipboard' +import { useClipboardWriter } from '../../platform/clipboard/clipboard' import { translate } from '../../i18n/mobile-i18n' import { useMobileTranslation } from '../../i18n/use-mobile-translation' import { useBackClaim } from '../../navigation/use-back-claim' @@ -8,7 +8,7 @@ import { useWorkspaceExecutionHost } from '../../navigation/workspace-execution- import { useSessionLeaveGuard } from '../../session/session-leave-guard' import { markdownTabSave } from './mobile-markdown-tab-operations' import { dropDocumentEditDraft, markdownTabDraftKey } from '../../drafts/document-edit-draft-rows' -import { triggerSuccess, triggerError } from '../../platform/haptics' +import { triggerSuccess, triggerError } from '../../platform/haptics/haptics' import type { DirtyMarkdownDraft } from './session-document-state' import type { MobileSessionTab } from '../../session/tabs/mobile-session-tabs' import type { MobileSessionDiffCommentsModel } from '../../source-control/review/use-mobile-session-diff-comments' diff --git a/mobile/src/files/terminal-artifacts/MobileFilePreviewEditDraft.tsx b/mobile/src/files/terminal-artifacts/MobileFilePreviewEditDraft.tsx index 6e16aa7f29d..545dcae77f2 100644 --- a/mobile/src/files/terminal-artifacts/MobileFilePreviewEditDraft.tsx +++ b/mobile/src/files/terminal-artifacts/MobileFilePreviewEditDraft.tsx @@ -4,9 +4,9 @@ import { useDocumentEditDraft, type DocumentEditDraftOffer } from '../../drafts/use-document-edit-draft' -import { useClipboardWriter } from '../../platform/clipboard' -import { triggerError, triggerSuccess } from '../../platform/haptics' -import { useKeyboardOcclusion } from '../../platform/keyboard-occlusion' +import { useClipboardWriter } from '../../platform/clipboard/clipboard' +import { triggerError, triggerSuccess } from '../../platform/haptics/haptics' +import { useKeyboardOcclusion } from '../../platform/keyboard/keyboard-occlusion' import { spacing } from '../../theme/mobile-theme' import { useSafeAreaInsets } from 'react-native-safe-area-context' import type { MobileFilePreviewSource } from '../preview/mobile-file-preview-request' diff --git a/mobile/src/home/MobileHomeScreen.tsx b/mobile/src/home/MobileHomeScreen.tsx index f860dd48ea2..3d31bb2db08 100644 --- a/mobile/src/home/MobileHomeScreen.tsx +++ b/mobile/src/home/MobileHomeScreen.tsx @@ -9,7 +9,7 @@ import { useOpenMobileAccounts } from '../accounts/use-open-mobile-accounts' import { ActionSheetModal } from '../components/ActionSheetModal' import { ConfirmModal } from '../components/ConfirmModal' import { getHostListActionSheetActions } from '../host-list-action-sheet-actions' -import { triggerMediumImpact } from '../platform/haptics' +import { triggerMediumImpact } from '../platform/haptics/haptics' import { useAgentListRowActions } from '../agents/use-agent-list-row-actions' import { useOpenMobileTasks } from '../tasks/use-open-mobile-tasks' import { useOpenMobileAutomations } from '../automations/use-open-mobile-automations' diff --git a/mobile/src/home/tabs/MobileHomeAutomationsTab.tsx b/mobile/src/home/tabs/MobileHomeAutomationsTab.tsx index eeda1071407..c9f3e806753 100644 --- a/mobile/src/home/tabs/MobileHomeAutomationsTab.tsx +++ b/mobile/src/home/tabs/MobileHomeAutomationsTab.tsx @@ -14,7 +14,7 @@ import { useAutomationRuns } from '../../automations/use-automation-runs' import { describeLoadError } from '../../components/load-error-description' import { useNow } from '../../hooks/use-now' import { useMobileTranslation } from '../../i18n/use-mobile-translation' -import { useReducedMotionEnabled } from '../../platform/use-reduced-motion' +import { useReducedMotionEnabled } from '../../platform/accessibility/use-reduced-motion' import { colors, spacing } from '../../theme/mobile-theme' import { themedStyleSheet } from '../../theme/themed-styles' import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme' diff --git a/mobile/src/home/tabs/MobileHomeFab.tsx b/mobile/src/home/tabs/MobileHomeFab.tsx index 0f37550c526..c25184e0e90 100644 --- a/mobile/src/home/tabs/MobileHomeFab.tsx +++ b/mobile/src/home/tabs/MobileHomeFab.tsx @@ -2,7 +2,7 @@ import { Plus } from 'lucide-react-native' import { useEffect, useState } from 'react' import { Pressable, Text, View } from 'react-native' import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated' -import { useReducedMotionEnabled } from '../../platform/use-reduced-motion' +import { useReducedMotionEnabled } from '../../platform/accessibility/use-reduced-motion' import { colors, radii, spacing } from '../../theme/mobile-theme' import { themedStyleSheet } from '../../theme/themed-styles' import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme' diff --git a/mobile/src/home/tabs/MobileHomeTabBar.tsx b/mobile/src/home/tabs/MobileHomeTabBar.tsx index f5d84087a79..129dc771c27 100644 --- a/mobile/src/home/tabs/MobileHomeTabBar.tsx +++ b/mobile/src/home/tabs/MobileHomeTabBar.tsx @@ -15,7 +15,7 @@ import Animated, { withTiming } from 'react-native-reanimated' import { useMobileTranslation } from '../../i18n/use-mobile-translation' -import { useReducedMotionEnabled } from '../../platform/use-reduced-motion' +import { useReducedMotionEnabled } from '../../platform/accessibility/use-reduced-motion' import { colors, radii, spacing } from '../../theme/mobile-theme' import { themedStyleSheet } from '../../theme/themed-styles' import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme' diff --git a/mobile/src/home/tabs/MobileHomeUsageTab.tsx b/mobile/src/home/tabs/MobileHomeUsageTab.tsx index 0f97179ff0c..a8850bbbd2b 100644 --- a/mobile/src/home/tabs/MobileHomeUsageTab.tsx +++ b/mobile/src/home/tabs/MobileHomeUsageTab.tsx @@ -18,7 +18,7 @@ import { ClaudeIcon, OpenAIIcon } from '../../components/AgentIcons' import { useNow } from '../../hooks/use-now' import { useMobileTranslation } from '../../i18n/use-mobile-translation' import { useResponsiveLayout } from '../../layout/responsive-layout' -import { useReducedMotionEnabled } from '../../platform/use-reduced-motion' +import { useReducedMotionEnabled } from '../../platform/accessibility/use-reduced-motion' import type { HomeStatsSummary } from '../../stats/home-stats-total' import { colors, spacing } from '../../theme/mobile-theme' import '../../theme/themed-styles' diff --git a/mobile/src/hooks/mobile-dictation-audio-chunk.ts b/mobile/src/hooks/mobile-dictation-audio-chunk.ts index 524c11ec299..239cd142baf 100644 --- a/mobile/src/hooks/mobile-dictation-audio-chunk.ts +++ b/mobile/src/hooks/mobile-dictation-audio-chunk.ts @@ -17,7 +17,7 @@ import { dictationSessionErrorMessage } from '../dictation/mobile-dictation-setu import type { DictationCapture, DictationCaptureChunk -} from '../platform/dictation-capture-contract' +} from '../platform/audio-capture/dictation-capture-contract' import type { MobileDictationPendingAudioBudget } from './mobile-dictation-pending-audio-budget' import type { MobileDictationHeldAudio } from './mobile-dictation-held-audio' import type { MobileDictationRecording } from './mobile-dictation-recording' diff --git a/mobile/src/hooks/mobile-dictation-background.test.tsx b/mobile/src/hooks/mobile-dictation-background.test.tsx index 585c78e85a8..06e5ab70a1c 100644 --- a/mobile/src/hooks/mobile-dictation-background.test.tsx +++ b/mobile/src/hooks/mobile-dictation-background.test.tsx @@ -7,7 +7,7 @@ import { rpcSuccess, type FakeRpcClient } from '../mobile-web-shell/bridge-host-test-fakes' -import type { DictationCapture } from '../platform/dictation-capture-contract' +import type { DictationCapture } from '../platform/audio-capture/dictation-capture-contract' const appState = vi.hoisted(() => ({ handlers: new Set<(state: string) => void>() })) const microphone = vi.hoisted(() => ({ ends: 0 })) @@ -23,7 +23,7 @@ vi.mock('react-native', () => ({ Platform: { OS: 'ios' } })) -vi.mock('../platform/dictation-capture', () => { +vi.mock('../platform/audio-capture/dictation-capture', () => { const capture: DictationCapture = { open: async () => ({ ok: true }), begin: () => true, @@ -109,29 +109,4 @@ describe('leaving the app while dictating', () => { expect(transcripts).toEqual(['what was heard']) expect(dictation().status).toBe('idle') }) - - it('keeps what the open mic heard while the computer is still starting', async () => { - const rpc = createFakeRpcClient() - const transcripts: string[] = [] - mount(rpc, transcripts) - await act(async () => { - void dictation().start() - for (let turn = 0; turn < 20 && rpc.requests.length === 0; turn += 1) { - await Promise.resolve() - } - }) - expect(dictation().status).toBe('starting') - const endsBefore = microphone.ends - - act(() => { - leave('background') - }) - expect(microphone.ends).toBe(endsBefore + 1) - - await act(async () => { - await pump(rpc) - }) - expect(transcripts).toEqual(['what was heard']) - expect(dictation().status).toBe('idle') - }) }) diff --git a/mobile/src/hooks/mobile-dictation-capture-open.ts b/mobile/src/hooks/mobile-dictation-capture-open.ts index aeb753d466a..753f4934730 100644 --- a/mobile/src/hooks/mobile-dictation-capture-open.ts +++ b/mobile/src/hooks/mobile-dictation-capture-open.ts @@ -1,5 +1,5 @@ import { dictationSessionErrorMessage } from '../dictation/mobile-dictation-setup' -import type { DictationCapture } from '../platform/dictation-capture-contract' +import type { DictationCapture } from '../platform/audio-capture/dictation-capture-contract' type OpenMobileDictationCaptureOptions = { capture: DictationCapture diff --git a/mobile/src/hooks/mobile-dictation-device-engine.ts b/mobile/src/hooks/mobile-dictation-device-engine.ts index 30758b3cc3f..d48642250a8 100644 --- a/mobile/src/hooks/mobile-dictation-device-engine.ts +++ b/mobile/src/hooks/mobile-dictation-device-engine.ts @@ -2,8 +2,8 @@ import type { BridgeSpeechFailure, BridgeSpeechRead } from '../mobile-web-shell/bridge/bridge-speech-verbs' -import type { OnDeviceSpeech } from '../platform/on-device-speech-contract' -import { DICTATION_CAPTURE_DRAIN_INTERVAL_MS } from '../platform/dictation-capture-contract' +import type { OnDeviceSpeech } from '../platform/speech/on-device-speech-contract' +import { DICTATION_CAPTURE_DRAIN_INTERVAL_MS } from '../platform/audio-capture/dictation-capture-contract' import { translate } from '../i18n/mobile-i18n' // Which engine hears this dictation. fallback: a failed phone session may replay its audio to the computer (Automatic only). diff --git a/mobile/src/hooks/mobile-dictation-engine-run.ts b/mobile/src/hooks/mobile-dictation-engine-run.ts index b61d5390e51..8ee2bfe74ee 100644 --- a/mobile/src/hooks/mobile-dictation-engine-run.ts +++ b/mobile/src/hooks/mobile-dictation-engine-run.ts @@ -1,7 +1,7 @@ import type { RefObject } from 'react' import { dictationSessionErrorMessage } from '../dictation/mobile-dictation-setup' -import type { DictationCapture } from '../platform/dictation-capture-contract' -import type { OnDeviceSpeech } from '../platform/on-device-speech-contract' +import type { DictationCapture } from '../platform/audio-capture/dictation-capture-contract' +import type { OnDeviceSpeech } from '../platform/speech/on-device-speech-contract' import { openMobileDictationCapture } from './mobile-dictation-capture-open' import { createMobileDictationId, type DictationStatus } from './mobile-dictation-session-state' import type { RpcClient } from '../transport/rpc/rpc-client-contract' diff --git a/mobile/src/hooks/mobile-dictation-finish.ts b/mobile/src/hooks/mobile-dictation-finish.ts index c676c3de41f..1e692aa30e2 100644 --- a/mobile/src/hooks/mobile-dictation-finish.ts +++ b/mobile/src/hooks/mobile-dictation-finish.ts @@ -1,5 +1,5 @@ import { rpcPayloadMember } from '../transport/rpc/rpc-reader-payload' -import type { DictationCapture } from '../platform/dictation-capture-contract' +import type { DictationCapture } from '../platform/audio-capture/dictation-capture-contract' import { isCurrentMobileDictationFinish } from './mobile-dictation-session-state' import { finishDesktopDictation } from './mobile-dictation-desktop-start' import { diff --git a/mobile/src/hooks/mobile-dictation-held-audio.ts b/mobile/src/hooks/mobile-dictation-held-audio.ts index 21e142a5683..c87b531c136 100644 --- a/mobile/src/hooks/mobile-dictation-held-audio.ts +++ b/mobile/src/hooks/mobile-dictation-held-audio.ts @@ -3,7 +3,7 @@ import { MAX_DICTATION_AUDIO_CHUNK_BYTES, PCM_BYTES_PER_SAMPLE } from '../../../src/shared/rpc-contract/speech-params' -import type { DictationCaptureChunk } from '../platform/dictation-capture-contract' +import type { DictationCaptureChunk } from '../platform/audio-capture/dictation-capture-contract' // Five minutes of 16 kHz PCM (9.6 MB): the longest recording kept for a replay to the computer. export const MAX_HELD_AUDIO_BYTES = DICTATION_SAMPLE_RATE * PCM_BYTES_PER_SAMPLE * 300 diff --git a/mobile/src/hooks/mobile-dictation-meter.ts b/mobile/src/hooks/mobile-dictation-meter.ts index dda46c194d4..36c6e4c6c64 100644 --- a/mobile/src/hooks/mobile-dictation-meter.ts +++ b/mobile/src/hooks/mobile-dictation-meter.ts @@ -12,7 +12,7 @@ import { DICTATION_SAMPLE_RATE, PCM_BYTES_PER_SAMPLE } from '../../../src/shared/rpc-contract/speech-params' -import type { DictationCapture } from '../platform/dictation-capture-contract' +import type { DictationCapture } from '../platform/audio-capture/dictation-capture-contract' const WINDOW_MS = 50 const WINDOW_SAMPLES = (DICTATION_SAMPLE_RATE * WINDOW_MS) / 1000 diff --git a/mobile/src/hooks/mobile-dictation-on-device.test.tsx b/mobile/src/hooks/mobile-dictation-on-device.test.tsx index cca33fcf3c9..8406d44a613 100644 --- a/mobile/src/hooks/mobile-dictation-on-device.test.tsx +++ b/mobile/src/hooks/mobile-dictation-on-device.test.tsx @@ -11,8 +11,8 @@ import { import type { DictationCapture, DictationCaptureChunk -} from '../platform/dictation-capture-contract' -import type { OnDeviceSpeech } from '../platform/on-device-speech-contract' +} from '../platform/audio-capture/dictation-capture-contract' +import type { OnDeviceSpeech } from '../platform/speech/on-device-speech-contract' import type { BridgeSpeechFinish, BridgeSpeechRead, @@ -40,7 +40,7 @@ vi.mock('react-native', () => ({ Platform: { OS: 'ios' } })) -vi.mock('../platform/dictation-capture', () => { +vi.mock('../platform/audio-capture/dictation-capture', () => { const capture: DictationCapture = { open: async () => ({ ok: true }), begin: () => true, @@ -55,7 +55,7 @@ vi.mock('../platform/dictation-capture', () => { return { useDictationCapture: () => capture } }) -vi.mock('../platform/on-device-speech', () => { +vi.mock('../platform/speech/on-device-speech', () => { const speech: OnDeviceSpeech = { status: async () => ({ status: 'ready', @@ -200,60 +200,3 @@ describe('a phone recognizer that fails at the finish', () => { expect(dictation().status).toBe('error') }) }) - -describe('a phone recognizer that fails after a long dictation', () => { - it('replays all of it to the computer instead of calling the connection slow', async () => { - const rpc = createFakeRpcClient() - const sent: SentRequest[] = [] - const transcripts: string[] = [] - mount(rpc, (text) => transcripts.push(text)) - await act(async () => { - await dictation().start() - }) - // 40 s of 16 kHz PCM: more than the 30 s the host may have pending at once. - for (let second = 0; second < 40; second += 1) { - speak(Array.from({ length: 32_000 }, () => second)) - } - seam.finish = { text: null, failure: 'failed' } - await act(async () => { - const stopped = dictation().stop() - await pump(rpc, sent) - await stopped - }) - expect(held.errors).toEqual([]) - expect(chunkAudio(sent).map(([seq]) => seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]) - expect(transcripts).toEqual(['from the computer']) - }) -}) - -describe('a stop while a failed phone dictation is reaching the computer', () => { - it('finishes on the computer once its start lands, with the audio kept so far', async () => { - const rpc = createFakeRpcClient() - const sent: SentRequest[] = [] - const transcripts: string[] = [] - mount(rpc, (text) => transcripts.push(text)) - await act(async () => { - await dictation().start() - }) - speak([1, 2, 3]) - seam.read = { revision: 1, committed: '', tentative: '', failure: 'failed' } - // The drain tick reads the failure and starts the computer's session. - await act(async () => { - await vi.waitFor(() => - expect(rpc.requests.map((request) => request.method)).toEqual(['speech.dictation.start']) - ) - }) - await act(async () => { - const stopped = dictation().stop() - await pump(rpc, sent) - await stopped - }) - expect(sent.map((request) => request.method)).toEqual([ - 'speech.dictation.start', - 'speech.dictation.chunk', - 'speech.dictation.finish' - ]) - expect(transcripts).toEqual(['from the computer']) - expect(dictation().status).toBe('idle') - }) -}) diff --git a/mobile/src/hooks/mobile-dictation-recording.ts b/mobile/src/hooks/mobile-dictation-recording.ts index 872c6231794..5ca09c6daa5 100644 --- a/mobile/src/hooks/mobile-dictation-recording.ts +++ b/mobile/src/hooks/mobile-dictation-recording.ts @@ -1,4 +1,4 @@ -import type { DictationCaptureChunk } from '../platform/dictation-capture-contract' +import type { DictationCaptureChunk } from '../platform/audio-capture/dictation-capture-contract' import { MAX_HELD_AUDIO_BYTES, type MobileDictationHeldAudio } from './mobile-dictation-held-audio' // Everything one dictation heard, from the tap, whichever engine hears it: replayed to the computer when the phone fails, and kept after a failure for Retry. diff --git a/mobile/src/hooks/mobile-dictation-retry.test.tsx b/mobile/src/hooks/mobile-dictation-retry.test.tsx index 474685d5a83..cb025b734ba 100644 --- a/mobile/src/hooks/mobile-dictation-retry.test.tsx +++ b/mobile/src/hooks/mobile-dictation-retry.test.tsx @@ -10,7 +10,7 @@ import { import type { DictationCapture, DictationCaptureChunk -} from '../platform/dictation-capture-contract' +} from '../platform/audio-capture/dictation-capture-contract' type Seam = { chunkHandlers: Set<(chunk: DictationCaptureChunk) => void> @@ -25,7 +25,7 @@ vi.mock('react-native', () => ({ })) // Keep capture identity stable like both real seams; a fresh object on render would rerun teardown and cancel dictation. -vi.mock('../platform/dictation-capture', () => { +vi.mock('../platform/audio-capture/dictation-capture', () => { const capture: DictationCapture = { open: async () => ({ ok: true }), begin: () => true, diff --git a/mobile/src/hooks/mobile-dictation-send-window.test.tsx b/mobile/src/hooks/mobile-dictation-send-window.test.tsx index 5df494d67f3..2649f71518a 100644 --- a/mobile/src/hooks/mobile-dictation-send-window.test.tsx +++ b/mobile/src/hooks/mobile-dictation-send-window.test.tsx @@ -10,7 +10,7 @@ import { import type { DictationCapture, DictationCaptureChunk -} from '../platform/dictation-capture-contract' +} from '../platform/audio-capture/dictation-capture-contract' const chunkHandlers = vi.hoisted(() => new Set<(chunk: DictationCaptureChunk) => void>()) @@ -19,7 +19,7 @@ vi.mock('react-native', () => ({ Platform: { OS: 'ios' } })) -vi.mock('../platform/dictation-capture', () => { +vi.mock('../platform/audio-capture/dictation-capture', () => { const capture: DictationCapture = { open: async () => ({ ok: true }), begin: () => true, @@ -40,11 +40,6 @@ function chunkRequests(rpc: FakeRpcClient): SentRequest[] { return rpc.requests.filter((request) => request.method === 'speech.dictation.chunk') } -function paramsOf(request: SentRequest): { audioBase64: string; seq: number } { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: chunk requests carry DictationChunk params. - return request.args[1] as { audioBase64: string; seq: number } -} - async function answer(rpc: FakeRpcClient, requests: SentRequest[], text: string): Promise { for (const request of requests) { rpc.requests.splice(rpc.requests.indexOf(request), 1) @@ -94,35 +89,6 @@ function speak(count: number): void { } describe('phone dictation chunks on a link that stops answering', () => { - it('keep four requests waiting, then send the audio captured meanwhile as one, before the finish', async () => { - const rpc = createFakeRpcClient() - const transcripts: string[] = [] - const dictation = await startDictation(rpc, transcripts) - - speak(10) - const waiting = chunkRequests(rpc) - expect(waiting.map((request) => paramsOf(request).seq)).toEqual([0, 1, 2, 3]) - - let stopped: Promise = Promise.resolve() - await act(async () => { - stopped = dictation().stop() - await answer(rpc, waiting, '') - }) - // Six held two-byte captures, merged into one request after the first that came free. - const merged = chunkRequests(rpc) - expect(merged.map(paramsOf)).toEqual([ - { ...paramsOf(merged[0]), seq: 4, audioBase64: 'BAQFBQYGBwcICAkJ' } - ]) - expect(rpc.requests.some((request) => request.method === 'speech.dictation.finish')).toBe(false) - - await act(async () => { - await answer(rpc, merged, '') - await answer(rpc, [...rpc.requests], 'all ten') - await stopped - }) - expect(transcripts).toEqual(['all ten']) - }) - it('keep the newest text so far when an older reply arrives last', async () => { const rpc = createFakeRpcClient() const dictation = await startDictation(rpc) diff --git a/mobile/src/hooks/mobile-dictation-stop-tail.test.tsx b/mobile/src/hooks/mobile-dictation-stop-tail.test.tsx index a99d87534e4..1ac7b0ca48e 100644 --- a/mobile/src/hooks/mobile-dictation-stop-tail.test.tsx +++ b/mobile/src/hooks/mobile-dictation-stop-tail.test.tsx @@ -11,7 +11,7 @@ import { import type { DictationCapture, DictationCaptureChunk -} from '../platform/dictation-capture-contract' +} from '../platform/audio-capture/dictation-capture-contract' type Seam = { chunkHandlers: Set<(chunk: DictationCaptureChunk) => void> @@ -26,7 +26,7 @@ vi.mock('react-native', () => ({ })) // Keep capture identity stable like both real seams; a fresh object on render would rerun teardown and cancel dictation. -vi.mock('../platform/dictation-capture', () => { +vi.mock('../platform/audio-capture/dictation-capture', () => { const capture: DictationCapture = { open: async () => ({ ok: true }), begin: () => true, @@ -150,32 +150,6 @@ describe('the audio a capture hands over as it ends', () => { methods.indexOf('speech.dictation.finish') ) }) - - it('stops being accepted once the capture has ended', async () => { - const rpc = createFakeRpcClient() - const sent: SentRequest[] = [] - mount(rpc) - await act(async () => { - const started = dictation().start() - await pump(rpc, sent) - await started - }) - await act(async () => { - const stopped = dictation().stop() - await pump(rpc, sent) - await stopped - }) - const before = sent.filter((request) => request.method === 'speech.dictation.chunk').length - for (const handler of seam.chunkHandlers) { - handler({ data: Uint8Array.from([1, 2, 3, 4]), droppedBytes: 0 }) - } - await act(async () => { - await pump(rpc, sent) - }) - expect(sent.filter((request) => request.method === 'speech.dictation.chunk')).toHaveLength( - before - ) - }) }) describe('a finish whose dictation stopped being the current one', () => { diff --git a/mobile/src/hooks/use-mobile-dictation-interruptions.ts b/mobile/src/hooks/use-mobile-dictation-interruptions.ts index a0c5ccf0892..7f4ff4f7c50 100644 --- a/mobile/src/hooks/use-mobile-dictation-interruptions.ts +++ b/mobile/src/hooks/use-mobile-dictation-interruptions.ts @@ -1,7 +1,7 @@ import { useEffect, type RefObject } from 'react' import { AppState } from 'react-native' import { dictationSessionErrorMessage } from '../dictation/mobile-dictation-setup' -import type { DictationCapture } from '../platform/dictation-capture-contract' +import type { DictationCapture } from '../platform/audio-capture/dictation-capture-contract' import type { MobileDictationAudioPhase } from './mobile-dictation-audio-chunk' import type { DictationStatus } from './mobile-dictation-session-state' diff --git a/mobile/src/hooks/use-mobile-dictation.ts b/mobile/src/hooks/use-mobile-dictation.ts index 027042548be..36e36ff36f6 100644 --- a/mobile/src/hooks/use-mobile-dictation.ts +++ b/mobile/src/hooks/use-mobile-dictation.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' -import { useDictationCapture } from '../platform/dictation-capture' -import { useOnDeviceSpeech } from '../platform/on-device-speech' +import { useDictationCapture } from '../platform/audio-capture/dictation-capture' +import { useOnDeviceSpeech } from '../platform/speech/on-device-speech' import { dictationSessionErrorMessage } from '../dictation/mobile-dictation-setup' import { useMobileTranslation } from '../i18n/use-mobile-translation' import { useMobileDictationMeter } from './mobile-dictation-meter' diff --git a/mobile/src/hooks/use-phone-dictation-ready.ts b/mobile/src/hooks/use-phone-dictation-ready.ts index 214e659455d..6ffc8a4490c 100644 --- a/mobile/src/hooks/use-phone-dictation-ready.ts +++ b/mobile/src/hooks/use-phone-dictation-ready.ts @@ -1,5 +1,5 @@ import { useCallback, useRef, useState } from 'react' -import { useOnDeviceSpeech } from '../platform/on-device-speech' +import { useOnDeviceSpeech } from '../platform/speech/on-device-speech' import { chooseMobileDictationRoute } from './mobile-dictation-device-engine' // Whether a tap now would dictate on the phone itself. The ref answers inside a tap; the state re-renders the mic. diff --git a/mobile/src/host-screen/host-screen-overlays.tsx b/mobile/src/host-screen/host-screen-overlays.tsx index 0526700d08f..ddee0ad056a 100644 --- a/mobile/src/host-screen/host-screen-overlays.tsx +++ b/mobile/src/host-screen/host-screen-overlays.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { openExternalLink } from '../platform/external-link' +import { openExternalLink } from '../platform/external-links/external-link' import { Pressable, Text, View } from 'react-native' import { Edit3, Moon, Pin, PinOff } from 'lucide-react-native' import { buildWorktreeNavigationActions } from '../agent-history/worktree-navigation-actions' diff --git a/mobile/src/host-screen/workspace-actions/use-host-worktree-delete.ts b/mobile/src/host-screen/workspace-actions/use-host-worktree-delete.ts index 33dae59848e..e609a05f757 100644 --- a/mobile/src/host-screen/workspace-actions/use-host-worktree-delete.ts +++ b/mobile/src/host-screen/workspace-actions/use-host-worktree-delete.ts @@ -8,7 +8,7 @@ import { persistOrcaHookTrustApproval, wasOrcaHookPreviouslyApproved } from '../../new-workspace/creation/setup-hook-trust' -import { triggerError, triggerSuccess } from '../../platform/haptics' +import { triggerError, triggerSuccess } from '../../platform/haptics/haptics' import type { RpcClient } from '../../transport/rpc/rpc-client-contract' import { buildWorktreeRemoveRequest } from './worktree-remove-request' import { removeWorktreeRow } from '../../worktree/worktree-host-row-identity' diff --git a/mobile/src/layout/side-rail.tsx b/mobile/src/layout/side-rail.tsx index 4b2ea69a040..dc769c99ffd 100644 --- a/mobile/src/layout/side-rail.tsx +++ b/mobile/src/layout/side-rail.tsx @@ -10,7 +10,7 @@ import { import { StyleSheet, useWindowDimensions, View } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { useReservedRegions } from './reserved-regions' -import { useKeyboardOcclusion } from '../platform/keyboard-occlusion' +import { useKeyboardOcclusion } from '../platform/keyboard/keyboard-occlusion' import { getSideRailStrip, hasTerminalDirectionPadSpace, diff --git a/mobile/src/mobile-web-shell/DownloadProgressBar.tsx b/mobile/src/mobile-web-shell/DownloadProgressBar.tsx index 7364dfe8d5a..55619dab191 100644 --- a/mobile/src/mobile-web-shell/DownloadProgressBar.tsx +++ b/mobile/src/mobile-web-shell/DownloadProgressBar.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react' import { Animated, Easing, Platform, StyleSheet, View } from 'react-native' -import { useReducedMotionEnabled } from '../platform/use-reduced-motion' +import { useReducedMotionEnabled } from '../platform/accessibility/use-reduced-motion' import { colors } from '../theme/mobile-theme' import { themedStyleSheet } from '../theme/themed-styles' import { useMobileColorScheme } from '../theme/use-mobile-color-scheme' diff --git a/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx b/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx index baacf8790c5..84d89d80029 100644 --- a/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx +++ b/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx @@ -20,10 +20,10 @@ import { cancelledShellNavigationTarget } from './cancelled-navigation-target' import { playPageHaptic } from './page-haptics' import { useMobileWebShellBridge } from './use-mobile-web-shell-bridge' import type { MobileWebShellRuntime } from './mobile-web-shell-runtime' -import { useKeyboardOcclusion } from '../platform/keyboard-occlusion' +import { useKeyboardOcclusion } from '../platform/keyboard/keyboard-occlusion' import { useNativeDeviceVerbs } from '../platform/use-native-device-verbs' -import { useNativePdfViewer } from '../platform/native-pdf-view' -import { useNativeImageActions } from '../platform/native-image-actions' +import { useNativePdfViewer } from '../platform/pdf-viewing/native-pdf-view' +import { useNativeImageActions } from '../platform/image-actions/native-image-actions' import { processDeviceStore } from '../device-store/device-store' import { serveDeviceStoreVerb } from './device-store-verb-server' import { BRIDGE_STORE_VERB } from './bridge/bridge-store-verb' diff --git a/mobile/src/mobile-web-shell/bridge/bridge-audio-verbs.test.ts b/mobile/src/mobile-web-shell/bridge/bridge-audio-verbs.test.ts index 19da0366f3b..bee1db1acbb 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-audio-verbs.test.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-audio-verbs.test.ts @@ -2,15 +2,15 @@ import { afterEach, describe, expect, it } from 'vitest' import { BridgeNativeVerbRefusedError } from '../bridge-host-errors' -import { createNativeAudioCapture, type NativeAudioEngine } from '../../platform/native-audio' import { - BRIDGE_AUDIO_READ_MAX_BASE64_CHARS, + createNativeAudioCapture, + type NativeAudioEngine +} from '../../platform/audio-capture/native-audio' +import { BRIDGE_AUDIO_RING_MAX_BYTES, audioReadResultSchema, audioStopResultSchema } from './bridge-audio-verbs' -import { BRIDGE_MAX_MESSAGE_BYTES } from './bridge-caps' -import { readBridgeHostMessage } from './bridge-envelope' const captures = new Set>() function createCapture(engine: NativeAudioEngine) { @@ -117,36 +117,6 @@ function decode(base64: string): Uint8Array { return bytes } -describe('what the audio schemas refuse', () => { - it('accepts a complete full-ring drain within the result and frame caps', async () => { - const { engine, emit } = createTestEngine() - const capture = createCapture(engine) - await capture.serve('native.audio.start', { sampleRate: 16_000 }) - const bytes = pcm(BRIDGE_AUDIO_RING_MAX_BYTES, 19) - emit(bytes) - const result = audioReadResultSchema.parse( - await capture.serve('native.audio.read', { maxBytes: BRIDGE_AUDIO_RING_MAX_BYTES }) - ) - expect(decode(result.base64)).toEqual(bytes) - const raw = JSON.stringify({ - v: 1, - type: 'reply', - id: 'AAAAAAAAAAAAAAAAAAAAAA', - payload: { id: 'audio', ok: true, result } - }) - expect(new TextEncoder().encode(raw).byteLength).toBeLessThanOrEqual(BRIDGE_MAX_MESSAGE_BYTES) - expect(readBridgeHostMessage(raw).ok).toBe(true) - expect( - audioReadResultSchema.safeParse({ - base64: 'A'.repeat(BRIDGE_AUDIO_READ_MAX_BASE64_CHARS + 1), - droppedBytes: 0, - recording: true, - interruption: null - }).success - ).toBe(false) - }) -}) - describe('the shell capture', () => { it('serves a partial drain from the front and keeps the rest for the next read', async () => { const { engine, emit } = createTestEngine() @@ -235,30 +205,6 @@ describe('the shell capture', () => { expect(read.base64).toBe('') }) - it('leaves one capture behind when two starts race the permission prompt', async () => { - // Concurrent starts can both leave the OS prompt and reach listen; replacement must remove the earlier handlers rather than leak a ring nobody drains. - const entered = gate() - const blocked = gate() - const { engine, log, liveListeners } = createTestEngine({ - permission: async () => { - entered.release() - await blocked.promise - return 'granted' - } - }) - const capture = createCapture(engine) - const first = capture.serve('native.audio.start', { sampleRate: 16_000 }) - await entered.promise - const second = capture.serve('native.audio.start', { sampleRate: 16_000 }) - blocked.release() - await expect(first).resolves.toMatchObject({ started: true }) - await expect(second).resolves.toMatchObject({ started: true }) - expect(liveListeners()).toEqual({ microphone: 1, interruptions: 1 }) - capture.dispose() - expect(liveListeners()).toEqual({ microphone: 0, interruptions: 0 }) - expect(log.filter((entry) => entry === 'end')).toHaveLength(2) - }) - it('does not open a capture for a start that lands after the session ended', async () => { const entered = gate() const blocked = gate() @@ -280,30 +226,6 @@ describe('the shell capture', () => { expect(log).not.toContain('end') }) - it('tears the device down for a start that lost the race after opening', async () => { - const entered = gate() - const blocked = gate() - const { engine, log, liveListeners } = createTestEngine({ - open: async (sampleRate) => { - entered.release() - await blocked.promise - return { opened: true, sampleRate } - } - }) - const capture = createCapture(engine) - const pending = capture.serve('native.audio.start', { sampleRate: 16_000 }) - await entered.promise - capture.dispose() - blocked.release() - await expect(pending).resolves.toEqual({ - started: false, - sampleRate: 16_000, - permission: 'granted' - }) - expect(log.filter((entry) => entry === 'end')).toHaveLength(1) - expect(liveListeners()).toEqual({ microphone: 0, interruptions: 0 }) - }) - it('ends a capture a stop asked for while its start was still opening', async () => { const entered = gate() const blocked = gate() @@ -326,32 +248,6 @@ describe('the shell capture', () => { }) }) -describe('the screen the shell holds awake while it is capturing', () => { - it('gives it back when the engine throws after the capture is open', async () => { - const { engine, screen } = createTestEngine({ - begin: () => { - throw new Error('the audio engine would not start') - } - }) - const capture = createCapture(engine) - await expect(capture.serve('native.audio.start', { sampleRate: 16_000 })).rejects.toThrow( - 'would not start' - ) - expect(screen).toEqual(['+', '-']) - }) - - it('does not take it twice when a second start replaces the first', async () => { - const { engine, screen } = createTestEngine() - const capture = createCapture(engine) - await capture.serve('native.audio.start', { sampleRate: 16_000 }) - await capture.serve('native.audio.start', { sampleRate: 16_000 }) - // Replace the old capture’s wake tag before activating another; never hold two with only one future release. - expect(screen).toEqual(['+', '-', '+']) - capture.dispose() - expect(screen).toEqual(['+', '-', '+', '-']) - }) -}) - describe('the tail the stop reply carries', () => { it('hands a byte to the read or to the stop, never to both and never to neither', async () => { const { engine, emit } = createTestEngine() diff --git a/mobile/src/mobile-web-shell/page-haptics.ts b/mobile/src/mobile-web-shell/page-haptics.ts index aa294542eb8..45c3856553e 100644 --- a/mobile/src/mobile-web-shell/page-haptics.ts +++ b/mobile/src/mobile-web-shell/page-haptics.ts @@ -4,7 +4,7 @@ import { triggerMediumImpact, triggerSelection, triggerSuccess -} from '../platform/haptics' +} from '../platform/haptics/haptics' import type { BridgeHapticsKind } from './bridge/bridge-haptics-notify' // Reuse native functions so platform styles stay identical. The typed table covers every bridge kind; the platform-seams haptics census checks new native functions also get a kind. diff --git a/mobile/src/new-workspace/NewWorkspacePageScreen.tsx b/mobile/src/new-workspace/NewWorkspacePageScreen.tsx index c067d406cfa..0386361d49b 100644 --- a/mobile/src/new-workspace/NewWorkspacePageScreen.tsx +++ b/mobile/src/new-workspace/NewWorkspacePageScreen.tsx @@ -7,7 +7,7 @@ import { useHostWorktreeRows } from './use-host-worktree-rows' import { useRouteHandoff } from '../navigation/route-handoff' import { firstParam } from '../navigation/route-param-reader' import { workspaceRouteHref } from '../navigation/workspace-execution-host' -import { openExternalLink } from '../platform/external-link' +import { openExternalLink } from '../platform/external-links/external-link' import { useHostClient } from '../transport/connection/host-client-hooks' import { useHostProtocolGates } from '../components/host-protocol-gates-context' diff --git a/mobile/src/onboarding/NotificationOnboardingPreview.tsx b/mobile/src/onboarding/NotificationOnboardingPreview.tsx index 2a97d48997c..dbcc69a9492 100644 --- a/mobile/src/onboarding/NotificationOnboardingPreview.tsx +++ b/mobile/src/onboarding/NotificationOnboardingPreview.tsx @@ -6,7 +6,7 @@ import { useMobileTranslation } from '../i18n/use-mobile-translation' import { colors, radii, spacing, typography } from '../theme/mobile-theme' import { themedStyleSheet } from '../theme/themed-styles' import { useMobileColorScheme } from '../theme/use-mobile-color-scheme' -import { useReducedMotionEnabled } from '../platform/use-reduced-motion' +import { useReducedMotionEnabled } from '../platform/accessibility/use-reduced-motion' function sampleNotifications(t: Translate) { return [ diff --git a/mobile/src/pairing/pairing-visuals.tsx b/mobile/src/pairing/pairing-visuals.tsx index d25c004d00f..825a89aafc7 100644 --- a/mobile/src/pairing/pairing-visuals.tsx +++ b/mobile/src/pairing/pairing-visuals.tsx @@ -10,7 +10,7 @@ import Animated, { import { Check } from 'lucide-react-native' import { SHEET_CURVE } from '../components/sheet-motion-values' import { useMobileTranslation } from '../i18n/use-mobile-translation' -import { useReducedMotionEnabled } from '../platform/use-reduced-motion' +import { useReducedMotionEnabled } from '../platform/accessibility/use-reduced-motion' import { pairScanStyles as scanStyles } from '../pair-scan-styles' import { colors, radii } from '../theme/mobile-theme' import { themedStyleSheet } from '../theme/themed-styles' diff --git a/mobile/src/platform/accessibility-announcer.ts b/mobile/src/platform/accessibility/accessibility-announcer.ts similarity index 100% rename from mobile/src/platform/accessibility-announcer.ts rename to mobile/src/platform/accessibility/accessibility-announcer.ts diff --git a/mobile/src/platform/accessibility-announcer.web.ts b/mobile/src/platform/accessibility/accessibility-announcer.web.ts similarity index 85% rename from mobile/src/platform/accessibility-announcer.web.ts rename to mobile/src/platform/accessibility/accessibility-announcer.web.ts index 647555ab841..4e5f816cf49 100644 --- a/mobile/src/platform/accessibility-announcer.web.ts +++ b/mobile/src/platform/accessibility/accessibility-announcer.web.ts @@ -1,5 +1,5 @@ import { useCallback } from 'react' -import { useNativeVerbs } from '../mobile-web-shell/bridge/use-native-verbs' +import { useNativeVerbs } from '../../mobile-web-shell/bridge/use-native-verbs' // RNW's announceForAccessibility does nothing, so the shell announces; older shells and Android leave it to the page's live region. export function useAccessibilityAnnouncer(): (message: string) => Promise { diff --git a/mobile/src/platform/native-accessibility-announce.ts b/mobile/src/platform/accessibility/native-accessibility-announce.ts similarity index 85% rename from mobile/src/platform/native-accessibility-announce.ts rename to mobile/src/platform/accessibility/native-accessibility-announce.ts index b171ff3948e..3a18cbc13f9 100644 --- a/mobile/src/platform/native-accessibility-announce.ts +++ b/mobile/src/platform/accessibility/native-accessibility-announce.ts @@ -1,5 +1,5 @@ import { AccessibilityInfo, Platform } from 'react-native' -import { a11yAnnounceParamsSchema } from '../mobile-web-shell/bridge/bridge-native-verbs' +import { a11yAnnounceParamsSchema } from '../../mobile-web-shell/bridge/bridge-native-verbs' // VoiceOver ignores the page's live regions inside the shell's WebView, so the shell announces for it; TalkBack reads them, so Android leaves it to the page. export function serveNativeAnnounceVerb(params: unknown): { announced: boolean } { diff --git a/mobile/src/platform/use-reduced-motion.ts b/mobile/src/platform/accessibility/use-reduced-motion.ts similarity index 100% rename from mobile/src/platform/use-reduced-motion.ts rename to mobile/src/platform/accessibility/use-reduced-motion.ts diff --git a/mobile/src/platform/dictation-capture-compile-fence.ts b/mobile/src/platform/audio-capture/dictation-capture-compile-fence.ts similarity index 86% rename from mobile/src/platform/dictation-capture-compile-fence.ts rename to mobile/src/platform/audio-capture/dictation-capture-compile-fence.ts index c580a2c5d22..721f05a4298 100644 --- a/mobile/src/platform/dictation-capture-compile-fence.ts +++ b/mobile/src/platform/audio-capture/dictation-capture-compile-fence.ts @@ -1,7 +1,7 @@ import type { MicrophoneDataEvent } from '@orca/expo-two-way-audio' -import { enqueueMobileDictationAudioChunk } from '../hooks/mobile-dictation-audio-chunk' +import { enqueueMobileDictationAudioChunk } from '../../hooks/mobile-dictation-audio-chunk' import type { DictationCaptureChunk } from './dictation-capture-contract' -import type { RpcClient } from '../transport/rpc/rpc-client-contract' +import type { RpcClient } from '../../transport/rpc/rpc-client-contract' // Pin capture chunks separately from raw microphone events so droppedBytes cannot disappear at the sender. Unused expect-error directives fail typecheck; this module never runs. diff --git a/mobile/src/platform/dictation-capture-contract.ts b/mobile/src/platform/audio-capture/dictation-capture-contract.ts similarity index 100% rename from mobile/src/platform/dictation-capture-contract.ts rename to mobile/src/platform/audio-capture/dictation-capture-contract.ts diff --git a/mobile/src/platform/dictation-capture.ts b/mobile/src/platform/audio-capture/dictation-capture.ts similarity index 95% rename from mobile/src/platform/dictation-capture.ts rename to mobile/src/platform/audio-capture/dictation-capture.ts index e3ef12486f3..e57920a5972 100644 --- a/mobile/src/platform/dictation-capture.ts +++ b/mobile/src/platform/audio-capture/dictation-capture.ts @@ -5,7 +5,7 @@ import { tearDown, toggleRecording } from '@orca/expo-two-way-audio' -import { bridgeAudioInterruptionEndsCapture } from '../mobile-web-shell/bridge/bridge-audio-verbs' +import { bridgeAudioInterruptionEndsCapture } from '../../mobile-web-shell/bridge/bridge-audio-verbs' import { nativeMicrophoneScreenLock } from './native-audio-device' import type { DictationCapture } from './dictation-capture-contract' diff --git a/mobile/src/platform/dictation-capture.web.ts b/mobile/src/platform/audio-capture/dictation-capture.web.ts similarity index 92% rename from mobile/src/platform/dictation-capture.web.ts rename to mobile/src/platform/audio-capture/dictation-capture.web.ts index ca4aac3e0b9..c01d22a3aff 100644 --- a/mobile/src/platform/dictation-capture.web.ts +++ b/mobile/src/platform/audio-capture/dictation-capture.web.ts @@ -1,11 +1,14 @@ import { useMemo } from 'react' import { - BRIDGE_AUDIO_RING_MAX_BYTES, bridgeAudioInterruptionEndsCapture, type BridgeAudioChunk -} from '../mobile-web-shell/bridge/bridge-audio-verbs' -import { useNativeVerbs, type NativeVerbs } from '../mobile-web-shell/bridge/use-native-verbs' -import { MOBILE_DICTATION_PCM_SAMPLE_RATE } from '../hooks/mobile-dictation-pending-audio-budget' +} from '../../mobile-web-shell/bridge/bridge-audio-verbs' +import { + MAX_DICTATION_AUDIO_CHUNK_BYTES as BRIDGE_AUDIO_RING_MAX_BYTES, + DICTATION_SAMPLE_RATE as MOBILE_DICTATION_PCM_SAMPLE_RATE +} from '../../../../src/shared/rpc-contract/speech-params' +import { useNativeVerbs, type NativeVerbs } from '../../mobile-web-shell/bridge/use-native-verbs' +import '../../hooks/mobile-dictation-pending-audio-budget' import { DICTATION_CAPTURE_DRAIN_INTERVAL_MS, type DictationCapture, diff --git a/mobile/src/platform/microphone-screen-lock.ts b/mobile/src/platform/audio-capture/microphone-screen-lock.ts similarity index 100% rename from mobile/src/platform/microphone-screen-lock.ts rename to mobile/src/platform/audio-capture/microphone-screen-lock.ts diff --git a/mobile/src/platform/native-audio-device.ts b/mobile/src/platform/audio-capture/native-audio-device.ts similarity index 96% rename from mobile/src/platform/native-audio-device.ts rename to mobile/src/platform/audio-capture/native-audio-device.ts index 7ace5684478..e051019ffe0 100644 --- a/mobile/src/platform/native-audio-device.ts +++ b/mobile/src/platform/audio-capture/native-audio-device.ts @@ -7,7 +7,7 @@ import { toggleRecording } from '@orca/expo-two-way-audio' import { activateKeepAwakeAsync, deactivateKeepAwake } from 'expo-keep-awake' -import { BRIDGE_AUDIO_INTERRUPTIONS } from '../mobile-web-shell/bridge/bridge-audio-verbs' +import { BRIDGE_AUDIO_INTERRUPTIONS } from '../../mobile-web-shell/bridge/bridge-audio-verbs' import { createMicrophoneScreenLock } from './microphone-screen-lock' import type { NativeAudioEngine } from './native-audio' diff --git a/mobile/src/platform/native-audio-session-ownership.test.ts b/mobile/src/platform/audio-capture/native-audio-session-ownership.test.ts similarity index 97% rename from mobile/src/platform/native-audio-session-ownership.test.ts rename to mobile/src/platform/audio-capture/native-audio-session-ownership.test.ts index 60717ebcc53..d8741d69882 100644 --- a/mobile/src/platform/native-audio-session-ownership.test.ts +++ b/mobile/src/platform/audio-capture/native-audio-session-ownership.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from 'vitest' import { createNativeAudioCapture, type NativeAudioEngine } from './native-audio' -import { audioReadResultSchema } from '../mobile-web-shell/bridge/bridge-audio-verbs' +import { audioReadResultSchema } from '../../mobile-web-shell/bridge/bridge-audio-verbs' function gate() { let release = () => {} diff --git a/mobile/src/platform/native-audio.ts b/mobile/src/platform/audio-capture/native-audio.ts similarity index 94% rename from mobile/src/platform/native-audio.ts rename to mobile/src/platform/audio-capture/native-audio.ts index 472f13de5bc..8392a9de928 100644 --- a/mobile/src/platform/native-audio.ts +++ b/mobile/src/platform/audio-capture/native-audio.ts @@ -1,15 +1,15 @@ import { - BRIDGE_AUDIO_RING_MAX_BYTES, bridgeAudioInterruptionEndsCapture, audioReadParamsSchema, audioStartParamsSchema, audioStopParamsSchema, type BridgeAudioInterruption, type BridgeAudioPermission -} from '../mobile-web-shell/bridge/bridge-audio-verbs' -import type { BridgeNativeVerb } from '../mobile-web-shell/bridge/bridge-native-verbs' -import { BridgeNativeVerbRefusedError } from '../mobile-web-shell/bridge-host-errors' -import { bytesToBase64 } from '../hooks/mobile-dictation-session-state' +} from '../../mobile-web-shell/bridge/bridge-audio-verbs' +import { MAX_DICTATION_AUDIO_CHUNK_BYTES as BRIDGE_AUDIO_RING_MAX_BYTES } from '../../../../src/shared/rpc-contract/speech-params' +import type { BridgeNativeVerb } from '../../mobile-web-shell/bridge/bridge-native-verbs' +import { BridgeNativeVerbRefusedError } from '../../mobile-web-shell/bridge-host-errors' +import { bytesToBase64 } from '../../hooks/mobile-dictation-session-state' import type { MicrophoneScreenLock } from './microphone-screen-lock' // Keep transcription protocol in the page so it can update without a binary release; the shell owns only microphone permission and PCM capture. diff --git a/mobile/src/platform/clipboard.test.tsx b/mobile/src/platform/clipboard/clipboard.test.tsx similarity index 54% rename from mobile/src/platform/clipboard.test.tsx rename to mobile/src/platform/clipboard/clipboard.test.tsx index 09c56cbac15..7db037f82b7 100644 --- a/mobile/src/platform/clipboard.test.tsx +++ b/mobile/src/platform/clipboard/clipboard.test.tsx @@ -1,6 +1,6 @@ import { act, create } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { ClipboardImage, ClipboardReader, ClipboardWriter } from './clipboard' +import type { ClipboardImage, ClipboardReader } from './clipboard' const clipboard = vi.hoisted(() => ({ setStringAsync: vi.fn(() => Promise.resolve(true)), @@ -12,23 +12,7 @@ const clipboard = vi.hoisted(() => ({ vi.mock('expo-clipboard', () => clipboard) -import { useClipboardReader, useClipboardWriter } from './clipboard' - -function mountWriter(): ClipboardWriter { - const held: { writer: ClipboardWriter | null } = { writer: null } - function Screen(): null { - held.writer = useClipboardWriter() - return null - } - act(() => { - create() - }) - const writer = held.writer - if (writer === null) { - throw new Error('nothing mounted') - } - return writer -} +import { useClipboardReader } from './clipboard' function mountReader(): ClipboardReader { const held: { reader: ClipboardReader | null } = { reader: null } @@ -63,38 +47,7 @@ afterEach(() => { vi.restoreAllMocks() }) -describe('writing the clipboard on a phone', () => { - it('hands the text to the app unchanged', async () => { - const writer = mountWriter() - await expect(writer.writeText('copied')).resolves.toBeUndefined() - expect(clipboard.setStringAsync.mock.calls).toEqual([['copied']]) - }) - - it('propagates a pasteboard write failure', async () => { - clipboard.setStringAsync.mockRejectedValue(new Error('Clipboard unavailable')) - const writer = mountWriter() - await expect(writer.writeText('copied')).rejects.toThrow('Clipboard unavailable') - }) -}) - describe('reading the clipboard on a phone', () => { - it('hands back the text the app has', async () => { - clipboard.getStringAsync.mockImplementation(() => Promise.resolve('pasted')) - await expect(mountReader().readText()).resolves.toBe('pasted') - }) - - it('asks for PNG and returns the image and its dimensions', async () => { - clipboard.getImageAsync.mockResolvedValue({ - data: 'data:image/png;base64,AQID', - size: { width: 3, height: 1 } - }) - await expect(mountReader().readImage()).resolves.toEqual({ - data: 'data:image/png;base64,AQID', - size: { width: 3, height: 1 } - }) - expect(clipboard.getImageAsync.mock.calls).toEqual([[{ format: 'png' }]]) - }) - it('starts both probes before either has answered', async () => { // Hold both probes open to prove they start in parallel; sequential awaits add IPC latency on mount/foreground/toggle. const started: string[] = [] @@ -115,14 +68,6 @@ describe('reading the clipboard on a phone', () => { expect(started).toEqual(['string', 'image']) }) - it('probes both kinds without reading either', async () => { - clipboard.hasImageAsync.mockImplementation(() => Promise.resolve(true)) - await expect(mountReader().contents()).resolves.toEqual({ text: false, image: true }) - // Probe without reading so iOS does not show paste consent just to enable a button. - expect(clipboard.getStringAsync).not.toHaveBeenCalled() - expect(clipboard.getImageAsync).not.toHaveBeenCalled() - }) - it('reads a probe that threw as absent, rather than disabling paste on a rejection', async () => { clipboard.hasStringAsync.mockImplementation(() => Promise.reject(new Error('no pasteboard'))) await expect(mountReader().contents()).resolves.toEqual({ text: false, image: false }) diff --git a/mobile/src/platform/clipboard.ts b/mobile/src/platform/clipboard/clipboard.ts similarity index 100% rename from mobile/src/platform/clipboard.ts rename to mobile/src/platform/clipboard/clipboard.ts diff --git a/mobile/src/platform/clipboard.web.ts b/mobile/src/platform/clipboard/clipboard.web.ts similarity index 93% rename from mobile/src/platform/clipboard.web.ts rename to mobile/src/platform/clipboard/clipboard.web.ts index fe0c4c126e0..86867c3fbb0 100644 --- a/mobile/src/platform/clipboard.web.ts +++ b/mobile/src/platform/clipboard/clipboard.web.ts @@ -2,8 +2,8 @@ import { useMemo } from 'react' import { readStagedMediaItem, releaseAllStagedMedia -} from '../mobile-web-shell/bridge/staged-media-bytes' -import { useNativeVerbs } from '../mobile-web-shell/bridge/use-native-verbs' +} from '../../mobile-web-shell/bridge/staged-media-bytes' +import { useNativeVerbs } from '../../mobile-web-shell/bridge/use-native-verbs' import type { ClipboardImage, ClipboardReader, ClipboardWriter } from './clipboard' // Use native clipboard verbs: navigator.clipboard requires a secure context unavailable under iOS’s custom scheme. Ungranted writes reject for callers to report. diff --git a/mobile/src/platform/native-clipboard.ts b/mobile/src/platform/clipboard/native-clipboard.ts similarity index 91% rename from mobile/src/platform/native-clipboard.ts rename to mobile/src/platform/clipboard/native-clipboard.ts index 48fb50817fa..4da246d4c27 100644 --- a/mobile/src/platform/native-clipboard.ts +++ b/mobile/src/platform/clipboard/native-clipboard.ts @@ -3,7 +3,7 @@ import { clipboardReadParamsSchema, clipboardWriteParamsSchema, type BridgeNativeVerb -} from '../mobile-web-shell/bridge/bridge-native-verbs' +} from '../../mobile-web-shell/bridge/bridge-native-verbs' // Text-only: images use staged media handles to stay below reply caps. Parse again at the device boundary for checked types without assertions. export async function serveNativeClipboardVerb( diff --git a/mobile/src/platform/external-link.test.ts b/mobile/src/platform/external-link.test.ts deleted file mode 100644 index 42c79d6b949..00000000000 --- a/mobile/src/platform/external-link.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -// Mirror RN 0.83.10 openURL’s synchronous empty-URL validation; a promise-only rejecting mock cannot expose throws that escape .catch. -const linking = vi.hoisted(() => { - const validatingOpen = (url: string): Promise => { - if (url === '') { - throw new Error('Invalid URL: cannot be empty') - } - return Promise.resolve(true) - } - return { validatingOpen, openURL: vi.fn(validatingOpen) } -}) - -vi.mock('react-native', () => ({ Linking: { openURL: linking.openURL } })) - -import { openExternalLink } from './external-link' - -beforeEach(() => { - linking.openURL.mockReset() - // Restore synchronous validation too so later tests can still reproduce the escaping throw. - linking.openURL.mockImplementation(linking.validatingOpen) -}) - -afterEach(() => { - vi.restoreAllMocks() -}) - -describe('opening a URL from a phone screen', () => { - it('does not throw out of a tap handler when nothing can open the URL', async () => { - linking.openURL.mockImplementation(() => Promise.reject(new Error('no activity found'))) - expect(() => openExternalLink('mailto:someone@example.com')).not.toThrow() - await Promise.resolve() - }) -}) - -describe('a URL the platform refuses before it returns a promise', () => { - it('names the refusal rather than failing silently', () => { - const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - openExternalLink('') - expect(warned.mock.calls).toEqual([ - [ - '[platform] could not open a URL', - { url: '' }, - expect.objectContaining({ message: 'Invalid URL: cannot be empty' }) - ] - ]) - warned.mockRestore() - }) -}) diff --git a/mobile/src/platform/external-link.web.test.ts b/mobile/src/platform/external-link.web.test.ts deleted file mode 100644 index 537e5cb58dd..00000000000 --- a/mobile/src/platform/external-link.web.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { openExternalLink, publishExternalLinkOpener } from './external-link.web' - -let warned: ReturnType - -beforeEach(() => { - warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined) -}) - -afterEach(() => { - warned.mockRestore() -}) - -describe('opening a URL from inside the shell', () => { - it('hands an allowed URL to whatever the entry published', () => { - const asked: string[] = [] - publishExternalLinkOpener((url) => { - asked.push(url) - return true - }) - openExternalLink('https://github.com/stablyai/orca') - expect(asked).toEqual(['https://github.com/stablyai/orca']) - expect(warned).not.toHaveBeenCalled() - }) - - it('names a scheme the grant does not cover and asks for nothing', () => { - const asked: string[] = [] - publishExternalLinkOpener((url) => { - asked.push(url) - return true - }) - for (const url of ['javascript:alert(1)', 'file:///etc/passwd', '/h/host-a/tasks']) { - expect(() => openExternalLink(url), url).not.toThrow() - } - expect(asked).toEqual([]) - expect(warned).toHaveBeenCalledTimes(3) - }) - - it('names a shell that would not take it, rather than reporting a tap that did nothing', () => { - publishExternalLinkOpener(() => false) - openExternalLink('https://example.com') - expect(warned.mock.calls[0]?.[0]).toBe('[page] the shell did not take a URL to open') - }) - - it('refuses everything in a document that published no opener', async () => { - // Reload module state to test its unpublished default; assert the refusal reason too because not.toThrow alone proves no policy. - vi.resetModules() - const fresh: typeof import('./external-link.web') = await import('./external-link.web') - fresh.openExternalLink('https://example.com') - expect(warned.mock.calls).toEqual([ - ['[page] the shell did not take a URL to open', { url: 'https://example.com' }] - ]) - }) -}) diff --git a/mobile/src/platform/external-link.ts b/mobile/src/platform/external-links/external-link.ts similarity index 100% rename from mobile/src/platform/external-link.ts rename to mobile/src/platform/external-links/external-link.ts diff --git a/mobile/src/platform/external-links/external-link.web.test.ts b/mobile/src/platform/external-links/external-link.web.test.ts new file mode 100644 index 00000000000..b850e254411 --- /dev/null +++ b/mobile/src/platform/external-links/external-link.web.test.ts @@ -0,0 +1,25 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { openExternalLink, publishExternalLinkOpener } from './external-link.web' + +let warned: ReturnType + +beforeEach(() => { + warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined) +}) + +afterEach(() => { + warned.mockRestore() +}) + +describe('opening a URL from inside the shell', () => { + it('hands an allowed URL to whatever the entry published', () => { + const asked: string[] = [] + publishExternalLinkOpener((url) => { + asked.push(url) + return true + }) + openExternalLink('https://github.com/stablyai/orca') + expect(asked).toEqual(['https://github.com/stablyai/orca']) + expect(warned).not.toHaveBeenCalled() + }) +}) diff --git a/mobile/src/platform/external-link.web.ts b/mobile/src/platform/external-links/external-link.web.ts similarity index 91% rename from mobile/src/platform/external-link.web.ts rename to mobile/src/platform/external-links/external-link.web.ts index 487a99d565b..24df974543c 100644 --- a/mobile/src/platform/external-link.web.ts +++ b/mobile/src/platform/external-links/external-link.web.ts @@ -1,4 +1,4 @@ -import { readBridgeExternalLinkUrl } from '../mobile-web-shell/bridge/bridge-caps' +import { readBridgeExternalLinkUrl } from '../../mobile-web-shell/bridge/bridge-caps' // Use shell linking because RN Web window.open reports success even though both native WebViews refuse new windows. // Publish outside React for plain-function callers; missing publication/invalid URLs log refusal without throwing from tap handlers. diff --git a/mobile/src/platform/global-timer-receiver-test-fakes.ts b/mobile/src/platform/global-timer-receiver-test-fakes.ts deleted file mode 100644 index 518cb4a834d..00000000000 --- a/mobile/src/platform/global-timer-receiver-test-fakes.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { vi } from 'vitest' - -// Mirror WebIDL timer receivers: omitted means global; explicit nonglobal receivers throw. -function assertGlobalReceiver(receiver: unknown): void { - if (receiver !== undefined && receiver !== globalThis) { - throw new TypeError('Illegal invocation') - } -} - -export type GuardedTimerHandles = { - scheduled: ReturnType[] - cleared: ReturnType[] -} - -// Wrap current real/fake timers so virtual time still works; restore with vi.unstubAllGlobals(). -export function installIllegalInvocationTimerGuards(): GuardedTimerHandles { - const scheduleTimer = globalThis.setTimeout - const cancelTimer = globalThis.clearTimeout - const handles: GuardedTimerHandles = { scheduled: [], cleared: [] } - vi.stubGlobal('setTimeout', function (this: unknown, handler: () => void, ms?: number) { - assertGlobalReceiver(this) - const handle = scheduleTimer(handler, ms) - handles.scheduled.push(handle) - return handle - }) - vi.stubGlobal('clearTimeout', function (this: unknown, handle: ReturnType) { - assertGlobalReceiver(this) - handles.cleared.push(handle) - cancelTimer(handle) - }) - return handles -} diff --git a/mobile/src/platform/haptics.ts b/mobile/src/platform/haptics/haptics.ts similarity index 100% rename from mobile/src/platform/haptics.ts rename to mobile/src/platform/haptics/haptics.ts diff --git a/mobile/src/platform/haptics.web.ts b/mobile/src/platform/haptics/haptics.web.ts similarity index 89% rename from mobile/src/platform/haptics.web.ts rename to mobile/src/platform/haptics/haptics.web.ts index 8380772651c..ca12cb207ea 100644 --- a/mobile/src/platform/haptics.web.ts +++ b/mobile/src/platform/haptics/haptics.web.ts @@ -1,4 +1,4 @@ -import type { BridgeHapticsKind } from '../mobile-web-shell/bridge/bridge-haptics-notify' +import type { BridgeHapticsKind } from '../../mobile-web-shell/bridge/bridge-haptics-notify' // Ask native through a notify: Expo’s web fallback clicks a hidden checkbox on iOS, disrupting long presses. No reply slot is needed. // Publish outside React for plain-function callers; ungranted haptics stay quiet to avoid logging every list tap. diff --git a/mobile/src/platform/host-os.web.ts b/mobile/src/platform/host-os.web.ts index 1fa67c726d6..fd88a823c88 100644 --- a/mobile/src/platform/host-os.web.ts +++ b/mobile/src/platform/host-os.web.ts @@ -1,7 +1,5 @@ import type { HostOs } from './host-os' -export type { HostOs } from './host-os' - // Platform.OS is web here: read the phone OS for keyboard/inset arithmetic (iPadOS needs the Mac-plus-touch heuristic) and a desktop Mac for Cmd chords. export function hostOs(): HostOs { const agent = globalThis.navigator?.userAgent ?? '' diff --git a/mobile/src/platform/image-actions-contract.ts b/mobile/src/platform/image-actions/image-actions-contract.ts similarity index 87% rename from mobile/src/platform/image-actions-contract.ts rename to mobile/src/platform/image-actions/image-actions-contract.ts index a1707cddbdc..d6bb66df92d 100644 --- a/mobile/src/platform/image-actions-contract.ts +++ b/mobile/src/platform/image-actions/image-actions-contract.ts @@ -1,4 +1,4 @@ -import type { BridgeImageOperation } from '../mobile-web-shell/bridge/bridge-image-verbs' +import type { BridgeImageOperation } from '../../mobile-web-shell/bridge/bridge-image-verbs' export type ImageAction = (operation: BridgeImageOperation) => Promise diff --git a/mobile/src/platform/image-actions.ts b/mobile/src/platform/image-actions/image-actions.ts similarity index 100% rename from mobile/src/platform/image-actions.ts rename to mobile/src/platform/image-actions/image-actions.ts diff --git a/mobile/src/platform/image-actions.web.ts b/mobile/src/platform/image-actions/image-actions.web.ts similarity index 87% rename from mobile/src/platform/image-actions.web.ts rename to mobile/src/platform/image-actions/image-actions.web.ts index bb4127adc3c..ca56cf1f049 100644 --- a/mobile/src/platform/image-actions.web.ts +++ b/mobile/src/platform/image-actions/image-actions.web.ts @@ -2,20 +2,20 @@ import { useMemo } from 'react' import { agentSessionAttachmentAppends, base64DecodedByteLength -} from '../../../src/shared/agent-session/agent-session-attachments' -import { readRasterImageDataUri } from '../../../src/shared/file-preview/image-data-uri' -import { isKnownRasterImageMimeType } from '../../../src/shared/file-preview/raster-image-preview-limits' -import { isSvgFilePath } from '../../../src/shared/file-preview/svg-document' +} from '../../../../src/shared/agent-session/agent-session-attachments' +import { readRasterImageDataUri } from '../../../../src/shared/file-preview/image-data-uri' +import { isKnownRasterImageMimeType } from '../../../../src/shared/file-preview/raster-image-preview-limits' +import { isSvgFilePath } from '../../../../src/shared/file-preview/svg-document' import type { BridgeImageOperation, BridgeImageRunParams -} from '../mobile-web-shell/bridge/bridge-image-verbs' +} from '../../mobile-web-shell/bridge/bridge-image-verbs' import { NativeVerbError, useNativeVerbs, type NativeVerbReason, type NativeVerbs -} from '../mobile-web-shell/bridge/use-native-verbs' +} from '../../mobile-web-shell/bridge/use-native-verbs' import { ImageActionRefusedError, type ImageAction, diff --git a/mobile/src/platform/native-image-actions.ts b/mobile/src/platform/image-actions/native-image-actions.ts similarity index 88% rename from mobile/src/platform/native-image-actions.ts rename to mobile/src/platform/image-actions/native-image-actions.ts index 4699fed8e1b..b5a3e193a17 100644 --- a/mobile/src/platform/native-image-actions.ts +++ b/mobile/src/platform/image-actions/native-image-actions.ts @@ -3,20 +3,20 @@ import { Platform } from 'react-native' import * as Clipboard from 'expo-clipboard' import { File as FsFile, type Directory, type FileHandle } from 'expo-file-system' import { useFocusEffect, useNavigation } from 'expo-router' -import { saveImageToPhotos, shareImage } from '../../modules/orca-mobile-web-shell/src' +import { saveImageToPhotos, shareImage } from '../../../modules/orca-mobile-web-shell/src/index' import { CLIPBOARD_IMAGE_MAX_SOURCE_BYTES, CLIPBOARD_IMAGE_TOO_LARGE_ERROR -} from '../../../src/shared/clipboard-image' -import { isKnownRasterImageMimeType } from '../../../src/shared/file-preview/raster-image-preview-limits' +} from '../../../../src/shared/clipboard-image' +import { isKnownRasterImageMimeType } from '../../../../src/shared/file-preview/raster-image-preview-limits' import { imageParamsSchema, type BridgeImageRunParams -} from '../mobile-web-shell/bridge/bridge-image-verbs' -import { BridgeNativeVerbRefusedError } from '../mobile-web-shell/bridge-host-errors' -import { decodeCanonicalBase64Bytes } from '../transport/encryption/base64-byte-codec' -import { mobileFileExtensionForMime } from './mobile-picked-file-name' -import { freshCacheFolder } from './native-media-device' +} from '../../mobile-web-shell/bridge/bridge-image-verbs' +import { BridgeNativeVerbRefusedError } from '../../mobile-web-shell/bridge-host-errors' +import { decodeCanonicalBase64Bytes } from '../../transport/encryption/base64-byte-codec' +import { mobileFileExtensionForMime } from '../media-picking/mobile-picked-file-name' +import { freshCacheFolder } from '../media-picking/native-media-device' type Staged = { folder: Directory; file: FsFile; handle: FileHandle; byteLength: number } diff --git a/mobile/src/platform/keyboard-occlusion.test.tsx b/mobile/src/platform/keyboard-occlusion.test.tsx deleted file mode 100644 index c2615f7fc63..00000000000 --- a/mobile/src/platform/keyboard-occlusion.test.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -type Listener = (event: { - endCoordinates: { height: number; screenY?: number } - duration: number -}) => void - -type KeyboardHarness = { - listeners: Map - removed: string[] - platform: 'ios' | 'android' - // Count all subscriptions, not just surviving listeners, to detect transient duplicate keyboard work. - addListenerCalls: number - metrics: { height: number } | undefined -} - -const keyboard = vi.hoisted((): KeyboardHarness => ({ - listeners: new Map(), - removed: [], - platform: 'ios', - addListenerCalls: 0, - metrics: undefined -})) - -const regions = vi.hoisted( - (): { listener: ((value: { keyboardInset?: number }) => void) | null; removed: boolean } => ({ - listener: null, - removed: false - }) -) -vi.mock('../layout/reserved-regions', () => ({ - subscribeReservedRegionChanges: (listener: (value: { keyboardInset?: number }) => void) => { - regions.listener = listener - return () => { - regions.listener = null - regions.removed = true - } - } -})) - -vi.mock('react-native', () => ({ - Keyboard: { - metrics: () => keyboard.metrics, - addListener: (name: string, listener: Listener) => { - keyboard.addListenerCalls += 1 - keyboard.listeners.set(name, listener) - return { - remove: () => { - keyboard.removed.push(name) - keyboard.listeners.delete(name) - } - } - } - }, - Dimensions: { get: () => ({ height: 951 }) }, - Platform: { - get OS() { - return keyboard.platform - } - } -})) - -import type * as KeyboardOcclusion from './keyboard-occlusion' -let keyboardModule: typeof KeyboardOcclusion - -const subscribeSoftKeyboard: typeof KeyboardOcclusion.subscribeSoftKeyboard = (...args) => - keyboardModule.subscribeSoftKeyboard(...args) - -describe('the keyboard the phone reports', () => { - beforeEach(async () => { - vi.resetModules() - keyboardModule = await import('./keyboard-occlusion') - keyboard.listeners.clear() - keyboard.removed.length = 0 - keyboard.platform = 'ios' - keyboard.addListenerCalls = 0 - keyboard.metrics = undefined - regions.listener = null - regions.removed = false - }) - - it.each(['before', 'after'])('ignores an offscreen frame %s hide without reopening', (order) => { - const calls: string[] = [] - const unsubscribe = subscribeSoftKeyboard( - (height, duration) => calls.push(`show ${height} ${duration}`), - (duration) => calls.push(`hide ${duration}`) - ) - keyboard.listeners.get('keyboardWillShow')?.({ endCoordinates: { height: 306 }, duration: 250 }) - const frame = () => - keyboard.listeners.get('keyboardWillChangeFrame')?.({ - endCoordinates: { height: 253, screenY: 951 }, - duration: 180 - }) - if (order === 'before') { - frame() - } - keyboard.listeners.get('keyboardWillHide')?.({ endCoordinates: { height: 253 }, duration: 180 }) - if (order === 'after') { - frame() - } - // An overlapping transition's in-window frame must not override the explicit hide either. - keyboard.listeners.get('keyboardWillChangeFrame')?.({ - endCoordinates: { height: 253, screenY: 698 }, - duration: 180 - }) - expect(calls).toEqual(['show 306 250', 'hide 180']) - keyboard.listeners.get('keyboardWillShow')?.({ endCoordinates: { height: 306 }, duration: 250 }) - keyboard.listeners.get('keyboardWillChangeFrame')?.({ - endCoordinates: { height: 391, screenY: 560 }, - duration: 120 - }) - expect(calls.slice(-2)).toEqual(['show 306 250', 'show 391 120']) - unsubscribe() - expect(keyboard.listeners.size).toBe(3) - }) - - it('follows native geometry without keyboard events and preserves explicit hide ordering', () => { - const calls: string[] = [] - const unsubscribe = subscribeSoftKeyboard( - (height) => calls.push(`show ${height}`), - () => calls.push('hide') - ) - regions.listener?.({ keyboardInset: 495.5 }) - expect(calls).toEqual([]) - keyboard.listeners.get('keyboardWillShow')?.({ endCoordinates: { height: 495.5 }, duration: 0 }) - regions.listener?.({}) - regions.listener?.({ keyboardInset: 0 }) - expect(calls).toEqual(['show 495.5']) - regions.listener?.({ keyboardInset: 311 }) - expect(calls).toEqual(['show 495.5', 'show 311']) - keyboard.listeners.get('keyboardWillHide')?.({ endCoordinates: { height: 311 }, duration: 180 }) - regions.listener?.({ keyboardInset: 495.5 }) - regions.listener?.({ keyboardInset: 0 }) - expect(calls).toEqual(['show 495.5', 'show 311', 'hide']) - keyboard.listeners.get('keyboardWillShow')?.({ endCoordinates: { height: 0 }, duration: 0 }) - regions.listener?.({ keyboardInset: 311 }) - expect(calls.slice(-2)).toEqual(['show 0', 'show 311']) - unsubscribe() - expect(regions.removed).toBe(false) - }) -}) diff --git a/mobile/src/platform/keyboard-close-blur.web.test.ts b/mobile/src/platform/keyboard/keyboard-close-blur.web.test.ts similarity index 68% rename from mobile/src/platform/keyboard-close-blur.web.test.ts rename to mobile/src/platform/keyboard/keyboard-close-blur.web.test.ts index 3a7c7347956..f9a275733f0 100644 --- a/mobile/src/platform/keyboard-close-blur.web.test.ts +++ b/mobile/src/platform/keyboard/keyboard-close-blur.web.test.ts @@ -79,38 +79,4 @@ describe('a keyboard the phone closes', () => { expect(document.activeElement).toBe(field) }) - - it('keeps focus and reopens the keyboard when Chromium hides it for a tapped autocorrected word', () => { - const field = focusedField(ANDROID) - startBlurringOnKeyboardClose() - - field.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })) - TextInputState.focusTextInput(field) - shellReports(300) - const focusSessions = vi.fn() - field.addEventListener('focus', focusSessions) - vi.advanceTimersByTime(400) - shellReports(0) - vi.advanceTimersByTime(100) - - expect(document.activeElement).toBe(field) - expect(focusSessions).toHaveBeenCalledOnce() - }) - - it.each([ - ['a touch elsewhere', 'body', 400], - ['a Back long after a touch on the field', 'field', 5000] - ] as const)('still ends focus on a close after %s', (_case, touched, waitMs) => { - const field = focusedField(ANDROID) - startBlurringOnKeyboardClose() - shellReports(300) - - const target = touched === 'field' ? field : document.body - target.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })) - vi.advanceTimersByTime(waitMs) - shellReports(0) - vi.advanceTimersByTime(1000) - - expect(document.activeElement).not.toBe(field) - }) }) diff --git a/mobile/src/platform/keyboard-close-blur.web.ts b/mobile/src/platform/keyboard/keyboard-close-blur.web.ts similarity index 97% rename from mobile/src/platform/keyboard-close-blur.web.ts rename to mobile/src/platform/keyboard/keyboard-close-blur.web.ts index 54087c6929e..f33878a6672 100644 --- a/mobile/src/platform/keyboard-close-blur.web.ts +++ b/mobile/src/platform/keyboard/keyboard-close-blur.web.ts @@ -1,5 +1,5 @@ import { Keyboard } from 'react-native' -import { hostOs } from './host-os.web' +import { hostOs } from '../host-os.web' import { subscribeSoftKeyboard } from './keyboard-occlusion.web' // Chromium hides the IME itself to show a tapped autocorrected word's menu; Back comes with no touch. diff --git a/mobile/src/platform/keyboard-occlusion.ts b/mobile/src/platform/keyboard/keyboard-occlusion.ts similarity index 96% rename from mobile/src/platform/keyboard-occlusion.ts rename to mobile/src/platform/keyboard/keyboard-occlusion.ts index fcca29cad05..366efba1f7a 100644 --- a/mobile/src/platform/keyboard-occlusion.ts +++ b/mobile/src/platform/keyboard/keyboard-occlusion.ts @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react' import { Dimensions, Keyboard } from 'react-native' -import { subscribeReservedRegionChanges } from '../layout/reserved-regions' -import { hostOs } from './host-os' +import { subscribeReservedRegionChanges } from '../../layout/reserved-regions' +import { hostOs } from '../host-os' export type SoftKeyboardState = { readonly height: number; readonly visible: boolean } diff --git a/mobile/src/platform/keyboard-occlusion.web.ts b/mobile/src/platform/keyboard/keyboard-occlusion.web.ts similarity index 100% rename from mobile/src/platform/keyboard-occlusion.web.ts rename to mobile/src/platform/keyboard/keyboard-occlusion.web.ts diff --git a/mobile/src/platform/keyboard-persisting-taps.ts b/mobile/src/platform/keyboard/keyboard-persisting-taps.ts similarity index 100% rename from mobile/src/platform/keyboard-persisting-taps.ts rename to mobile/src/platform/keyboard/keyboard-persisting-taps.ts diff --git a/mobile/src/platform/primary-modifier-key.ts b/mobile/src/platform/keyboard/primary-modifier-key.ts similarity index 87% rename from mobile/src/platform/primary-modifier-key.ts rename to mobile/src/platform/keyboard/primary-modifier-key.ts index 769305b0da6..6e95d561b1c 100644 --- a/mobile/src/platform/primary-modifier-key.ts +++ b/mobile/src/platform/keyboard/primary-modifier-key.ts @@ -1,4 +1,4 @@ -import { hostOs } from './host-os' +import { hostOs } from '../host-os' /** The submit chord's modifier: Cmd on an Apple keyboard (iPhone, iPad, Mac), Ctrl elsewhere. */ export function primaryModifierKey(): 'Meta' | 'Control' { diff --git a/mobile/src/platform/media-picker-test-shell.ts b/mobile/src/platform/media-picker-test-shell.ts deleted file mode 100644 index e73d091ec5e..00000000000 --- a/mobile/src/platform/media-picker-test-shell.ts +++ /dev/null @@ -1,119 +0,0 @@ -// Model only observable staging/read/eof/release behavior with real verb schemas; native-media tests own device and cache details. -import { - BRIDGE_MEDIA_READ_MAX_BYTES, - mediaNameParamsSchema, - mediaPickParamsSchema, - mediaReadParamsSchema, - mediaReleaseParamsSchema, - type BridgeMediaItem -} from '../mobile-web-shell/bridge/bridge-media-verbs' -import type { BridgeNativeVerb } from '../mobile-web-shell/bridge/bridge-native-verbs' -import { BridgeNativeVerbRefusedError } from '../mobile-web-shell/bridge-host-errors' - -export type StagedTestMedia = { - readonly bytes: Uint8Array - readonly mime?: string - readonly width?: number - readonly height?: number - readonly name?: string -} - -export type MediaTestShell = { - readonly calls: string[] - readonly released: string[] - readonly serveNativeVerb: (verb: BridgeNativeVerb, params: unknown) => Promise -} - -// Offset-derived bytes expose reordered chunks and dropped boundaries without relying on length. -export function stagedTestBytes(byteLength: number): Uint8Array { - const bytes = new Uint8Array(byteLength) - for (let index = 0; index < byteLength; index += 1) { - bytes[index] = (index * 31 + 7) % 251 - } - return bytes -} - -export function encodeTestBase64(bytes: Uint8Array): string { - let binary = '' - for (const byte of bytes) { - binary += String.fromCharCode(byte) - } - return btoa(binary) -} - -export function createMediaTestShell(options: { - readonly staged: Readonly>> - readonly refusePick?: BridgeNativeVerbRefusedError - // Short reads are legal; only eof promises completion. - readonly chunkBytes?: number - // Simulate premature eof so callers cannot silently upload a truncated file. - readonly endAt?: number -}): MediaTestShell { - const calls: string[] = [] - const released: string[] = [] - const live = new Map() - let minted = 0 - - function hold(handle: string): StagedTestMedia { - const record = live.get(handle) - if (record === undefined) { - throw new BridgeNativeVerbRefusedError( - 'native_media_handle_unknown', - 'this session holds no staged item under that handle' - ) - } - return record - } - - return { - calls, - released, - serveNativeVerb: (verb, params) => { - if (verb === 'native.media.pick') { - // Parse the real verb schema so this fake cannot accept params the device would refuse. - const { source, multiple } = mediaPickParamsSchema.parse(params) - calls.push(`pick ${source} ${multiple ? 'multiple' : 'single'}`) - if (options.refusePick) { - return Promise.reject(options.refusePick) - } - const items: BridgeMediaItem[] = (options.staged[source] ?? []).map((item) => { - minted += 1 - const handle = `media-${minted}` - live.set(handle, item) - return { - handle, - mime: item.mime ?? 'image/png', - byteLength: item.bytes.byteLength, - ...(item.width === undefined ? {} : { width: item.width }), - ...(item.height === undefined ? {} : { height: item.height }) - } - }) - return Promise.resolve({ items }) - } - if (verb === 'native.media.read') { - const { handle, offset, length } = mediaReadParamsSchema.parse(params) - calls.push(`read ${handle} ${offset}:${length}`) - const record = hold(handle) - const served = Math.min(length, BRIDGE_MEDIA_READ_MAX_BYTES, options.chunkBytes ?? length) - const last = options.endAt ?? record.bytes.byteLength - const end = Math.min(offset + served, last) - return Promise.resolve({ - base64: encodeTestBase64(record.bytes.subarray(offset, end)), - eof: end >= last - }) - } - if (verb === 'native.media.name') { - const { handle } = mediaNameParamsSchema.parse(params) - calls.push(`name ${handle}`) - return Promise.resolve({ name: hold(handle).name ?? null }) - } - const { handle } = mediaReleaseParamsSchema.parse(params) - calls.push(`release ${handle}`) - const held = live.delete(handle) - if (held) { - released.push(handle) - } - return Promise.resolve({ released: held }) - } - } -} diff --git a/mobile/src/platform/media-picker-contract.ts b/mobile/src/platform/media-picking/media-picker-contract.ts similarity index 95% rename from mobile/src/platform/media-picker-contract.ts rename to mobile/src/platform/media-picking/media-picker-contract.ts index 6108257ba3e..5f446f9e764 100644 --- a/mobile/src/platform/media-picker-contract.ts +++ b/mobile/src/platform/media-picking/media-picker-contract.ts @@ -1,4 +1,4 @@ -import { CLIPBOARD_IMAGE_MAX_SOURCE_BYTES } from '../../../src/shared/clipboard-image' +import { CLIPBOARD_IMAGE_MAX_SOURCE_BYTES } from '../../../../src/shared/clipboard-image' // Keep contracts/errors free of Expo imports so a page-side instanceof cannot pull native picker code into the bundle. diff --git a/mobile/src/platform/media-picker.ts b/mobile/src/platform/media-picking/media-picker.ts similarity index 71% rename from mobile/src/platform/media-picker.ts rename to mobile/src/platform/media-picking/media-picker.ts index 3d22a3e8d83..2065fd1922f 100644 --- a/mobile/src/platform/media-picker.ts +++ b/mobile/src/platform/media-picking/media-picker.ts @@ -1,5 +1,8 @@ -import { pickMobileImage, pickMobileImages } from '../files/attachments/mobile-image-source-picker' -import { pickMobileFiles } from '../files/attachments/mobile-file-source-picker' +import { + pickMobileImage, + pickMobileImages +} from '../../files/attachments/mobile-image-source-picker' +import { pickMobileFiles } from '../../files/attachments/mobile-file-source-picker' import type { MediaPicker } from './media-picker-contract' // Keep Expo picker imports in the native sibling: their codegen lookup throws in browsers. diff --git a/mobile/src/platform/media-picker.web.ts b/mobile/src/platform/media-picking/media-picker.web.ts similarity index 94% rename from mobile/src/platform/media-picker.web.ts rename to mobile/src/platform/media-picking/media-picker.web.ts index a41d502bf5f..21140e3755d 100644 --- a/mobile/src/platform/media-picker.web.ts +++ b/mobile/src/platform/media-picking/media-picker.web.ts @@ -1,15 +1,15 @@ import { useMemo } from 'react' -import type { BridgeMediaItem } from '../mobile-web-shell/bridge/bridge-media-verbs' +import type { BridgeMediaItem } from '../../mobile-web-shell/bridge/bridge-media-verbs' import { readStagedMediaItem, releaseAllStagedMedia, releaseStagedMedia -} from '../mobile-web-shell/bridge/staged-media-bytes' +} from '../../mobile-web-shell/bridge/staged-media-bytes' import { NativeVerbError, useNativeVerbs, type NativeVerbs -} from '../mobile-web-shell/bridge/use-native-verbs' +} from '../../mobile-web-shell/bridge/use-native-verbs' import { MobileFileTooLargeError, type MediaPicker, diff --git a/mobile/src/platform/mobile-picked-file-name.ts b/mobile/src/platform/media-picking/mobile-picked-file-name.ts similarity index 100% rename from mobile/src/platform/mobile-picked-file-name.ts rename to mobile/src/platform/media-picking/mobile-picked-file-name.ts diff --git a/mobile/src/platform/native-media-device.ts b/mobile/src/platform/media-picking/native-media-device.ts similarity index 94% rename from mobile/src/platform/native-media-device.ts rename to mobile/src/platform/media-picking/native-media-device.ts index 539bd16e6fe..6daea70e19a 100644 --- a/mobile/src/platform/native-media-device.ts +++ b/mobile/src/platform/media-picking/native-media-device.ts @@ -2,8 +2,8 @@ import * as Clipboard from 'expo-clipboard' import * as DocumentPicker from 'expo-document-picker' import { Directory, File as FsFile, Paths } from 'expo-file-system' import * as ImagePicker from 'expo-image-picker' -import { agentReadableLibraryImageOptions } from '../files/attachments/mobile-image-source-picker' -import type { MediaHandleRegistry } from '../mobile-web-shell/media-handle-registry' +import { agentReadableLibraryImageOptions } from '../../files/attachments/mobile-image-source-picker' +import type { MediaHandleRegistry } from '../../mobile-web-shell/media-handle-registry' import type { NativeMediaDeps } from './native-media' // Isolate native imports so handlers can be tested with injected device calls. diff --git a/mobile/src/platform/native-media-stage-cleanup.test.ts b/mobile/src/platform/media-picking/native-media-stage-cleanup.test.ts similarity index 98% rename from mobile/src/platform/native-media-stage-cleanup.test.ts rename to mobile/src/platform/media-picking/native-media-stage-cleanup.test.ts index b29c17c0e44..ace030069a7 100644 --- a/mobile/src/platform/native-media-stage-cleanup.test.ts +++ b/mobile/src/platform/media-picking/native-media-stage-cleanup.test.ts @@ -83,7 +83,7 @@ vi.mock('expo-file-system', () => ({ Paths: { cache: 'file:///cache' } })) -import { MediaHandleRegistry } from '../mobile-web-shell/media-handle-registry' +import { MediaHandleRegistry } from '../../mobile-web-shell/media-handle-registry' import { createNativeMediaVerbServer } from './native-media' import { copyPickedMediaIntoCache, nativeMediaDeviceDeps } from './native-media-device' diff --git a/mobile/src/platform/native-media.test.ts b/mobile/src/platform/media-picking/native-media.test.ts similarity index 54% rename from mobile/src/platform/native-media.test.ts rename to mobile/src/platform/media-picking/native-media.test.ts index 5ee9410f0d5..c2f8683270a 100644 --- a/mobile/src/platform/native-media.test.ts +++ b/mobile/src/platform/media-picking/native-media.test.ts @@ -1,30 +1,23 @@ -import { Buffer } from 'node:buffer' import { beforeEach, describe, expect, it, vi } from 'vitest' import { BRIDGE_MEDIA_MAX_LIVE_HANDLES, - BRIDGE_MEDIA_READ_MAX_BYTES, mediaPickResultSchema, mediaReadResultSchema -} from '../mobile-web-shell/bridge/bridge-media-verbs' -import { readShellRefusalCode } from '../mobile-web-shell/bridge-host-errors' -import { MediaHandleRegistry } from '../mobile-web-shell/media-handle-registry' +} from '../../mobile-web-shell/bridge/bridge-media-verbs' +import { readShellRefusalCode } from '../../mobile-web-shell/bridge-host-errors' +import { MediaHandleRegistry } from '../../mobile-web-shell/media-handle-registry' -import type { BridgeNativeVerb } from '../mobile-web-shell/bridge/bridge-native-verbs' +import type { BridgeNativeVerb } from '../../mobile-web-shell/bridge/bridge-native-verbs' import { createNativeMediaVerbServer, type NativeMediaFile } from './native-media' const CACHE = 'file:///cache' -// Track closed handles explicitly because a fake otherwise hides native descriptor leaks. -const handles: { closed: boolean }[] = [] - function fakeFile(bytes: Uint8Array): NativeMediaFile { return { size: bytes.byteLength, open: () => { let cursor = 0 - const ledger = { closed: false } - handles.push(ledger) return { get offset() { return cursor @@ -37,9 +30,7 @@ function fakeFile(bytes: Uint8Array): NativeMediaFile { cursor += slice.byteLength return slice }, - close: () => { - ledger.closed = true - } + close: () => {} } } } @@ -120,7 +111,6 @@ function refusalOf(error: unknown): string | null { } beforeEach(() => { - handles.length = 0 vi.restoreAllMocks() }) @@ -158,36 +148,6 @@ describe('picking', () => { expect(probe.registry.liveCount()).toBe(0) }) - it('discards every copy if cleanup runs between staging and minting', async () => { - const providerUris = ['content://media/1', 'content://media/2'] - const probe = harness({ - launchLibrary: () => - Promise.resolve({ - canceled: false, - assets: providerUris.map((uri) => ({ uri, mimeType: 'image/jpeg' })) - }) - }) - for (const uri of providerUris) { - probe.files.set(uri, bytesOf(4096)) - } - const picked = probe.serve('native.media.pick', { source: 'library', multiple: true }) - await Promise.resolve() - expect(probe.copied).toHaveLength(2) - expect(probe.registry.liveCount()).toBe(0) - probe.registry.releaseAll() - const [live] = probe.registry.mint([ - { uri: `${CACHE}/successor.png`, byteLength: 64, mime: 'image/png' } - ]) - await expect(picked).resolves.toEqual({ items: [] }) - expect(probe.discarded).toEqual(probe.copied.map(({ to }) => to)) - expect(probe.registry.liveCount()).toBe(1) - expect(probe.registry.read(live?.handle ?? '', 0, 64)).toMatchObject({ - uri: `${CACHE}/successor.png`, - end: 64, - eof: true - }) - }) - it('refuses a pick up front when nothing is left, before the OS copies a byte', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) const launches: number[] = [] @@ -246,106 +206,6 @@ describe('picking', () => { warn.mockRestore() }) - it.each([0, 2])( - 'deletes an over-cap Files result while preserving %i prior handles', - async (held) => { - const liveUris = Array.from({ length: held }, (_, index) => `${CACHE}/held-${index}.png`) - const returnedUris = Array.from( - { length: BRIDGE_MEDIA_MAX_LIVE_HANDLES - held + 1 }, - (_, index) => `${CACHE}/doc-${index}.pdf` - ) - const probe = harness({ - launchFiles: () => - Promise.resolve({ - canceled: false, - assets: returnedUris.map((uri) => ({ uri, mimeType: 'application/pdf' })) - }) - }) - for (const uri of liveUris) { - probe.files.set(uri, new Uint8Array(4096)) - } - probe.registry.mint(liveUris.map((uri) => ({ uri, mime: 'image/png', byteLength: 4096 }))) - for (const uri of returnedUris) { - probe.files.set(uri, new Uint8Array(1024 * 1024)) - } - const storedBytes = (): number => - [...probe.files.values()].reduce((bytes, file) => bytes + file.byteLength, 0) - expect(storedBytes()).toBe(returnedUris.length * 1024 * 1024 + held * 4096) - await expect( - probe.serve('native.media.pick', { source: 'files', multiple: true }) - ).rejects.toSatisfy((error) => refusalOf(error) === 'native_media_handle_cap') - expect(probe.registry.liveCount()).toBe(held) - expect(probe.discarded).toEqual(returnedUris) - expect([...probe.files.keys()]).toEqual(liveUris) - expect(storedBytes()).toBe(held * 4096) - expect(probe.copied).toEqual([]) - expect(probe.written).toEqual([]) - probe.registry.releaseAll() - expect(probe.files.size).toBe(0) - } - ) - - it('leaves provider originals intact when refusing an over-cap mixed result', async () => { - const providerUri = 'content://media/external/images/media/42' - const ownedUris = Array.from( - { length: BRIDGE_MEDIA_MAX_LIVE_HANDLES }, - (_, index) => `${CACHE}/picked-${index}.png` - ) - const probe = harness({ - launchFiles: () => - Promise.resolve({ - canceled: false, - assets: [providerUri, ...ownedUris].map((uri) => ({ uri, mimeType: 'image/png' })) - }) - }) - for (const uri of [providerUri, ...ownedUris]) { - probe.files.set(uri, new Uint8Array(1024 * 1024)) - } - await expect( - probe.serve('native.media.pick', { source: 'files', multiple: true }) - ).rejects.toSatisfy((error) => refusalOf(error) === 'native_media_handle_cap') - expect(probe.discarded).toEqual(ownedUris) - expect([...probe.files.keys()]).toEqual([providerUri]) - expect(probe.files.get(providerUri)?.byteLength).toBe(1024 * 1024) - expect(probe.registry.liveCount()).toBe(0) - expect(probe.copied).toEqual([]) - }) - - it('continues sweeping refused copies when one file cannot be deleted', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const returnedUris = Array.from( - { length: BRIDGE_MEDIA_MAX_LIVE_HANDLES + 1 }, - (_, index) => `${CACHE}/doc-${index}.pdf` - ) - const probe = harness({ - launchFiles: () => - Promise.resolve({ - canceled: false, - assets: returnedUris.map((uri) => ({ uri, mimeType: 'application/pdf' })) - }), - discard: (uri) => { - probe.discarded.push(uri) - if (uri === returnedUris[0]) { - throw new Error('cache file is busy') - } - probe.files.delete(uri) - } - }) - try { - for (const uri of returnedUris) { - probe.files.set(uri, new Uint8Array(1024 * 1024)) - } - await expect( - probe.serve('native.media.pick', { source: 'files', multiple: true }) - ).rejects.toSatisfy((error) => refusalOf(error) === 'native_media_handle_cap') - expect(probe.discarded).toEqual(returnedUris) - expect([...probe.files.keys()]).toEqual([returnedUris[0]]) - expect(probe.registry.liveCount()).toBe(0) - } finally { - warn.mockRestore() - } - }) - it('refuses an item bigger than the staging ceiling, and deletes what it picked', async () => { const probe = harness({ openFile: () => ({ size: 64 * 1024 * 1024, open: () => fakeFile(new Uint8Array()).open() }) @@ -357,84 +217,6 @@ describe('picking', () => { }) }) -describe('reading chunks', () => { - async function staged(byteLength: number): Promise<{ probe: Harness; handle: string }> { - const probe = harness() - probe.files.set(`${CACHE}/lib.png`, bytesOf(byteLength)) - const result = mediaPickResultSchema.parse( - await probe.serve('native.media.pick', { source: 'library', multiple: false }) - ) - return { probe, handle: result.items[0]?.handle ?? '' } - } - - it('reads a million-byte item in order to eof, and the bytes come back whole', async () => { - const total = 1_000_000 - const { probe, handle } = await staged(total) - const source = bytesOf(total) - const collected = new Uint8Array(total) - let offset = 0 - let reads = 0 - for (;;) { - const chunk = mediaReadResultSchema.parse( - await probe.serve('native.media.read', { - handle, - offset, - length: BRIDGE_MEDIA_READ_MAX_BYTES - }) - ) - reads += 1 - const bytes = atob(chunk.base64) - collected.set(Buffer.from(bytes, 'latin1'), offset) - offset += bytes.length - expect(chunk.eof).toBe(offset === total) - if (chunk.eof) { - break - } - } - expect(reads).toBe(Math.ceil(total / BRIDGE_MEDIA_READ_MAX_BYTES)) - expect(offset).toBe(total) - expect(Buffer.compare(collected, source)).toBe(0) - }) - - it('closes the file handle it opened, on the way out and on the way through a throw', async () => { - const { probe, handle } = await staged(64) - await probe.serve('native.media.read', { handle, offset: 0, length: 16 }) - expect(handles).toHaveLength(1) - expect(handles[0]?.closed).toBe(true) - - const failing = harness({ - openFile: () => ({ - size: 64, - open: () => { - const ledger = { closed: false } - handles.push(ledger) - return { - offset: 0, - readBytesSync: (): Uint8Array => { - throw new Error('the device stopped reading') - }, - close: () => { - ledger.closed = true - } - } - } - }) - }) - failing.files.set(`${CACHE}/lib.png`, bytesOf(64)) - const picked = mediaPickResultSchema.parse( - await failing.serve('native.media.pick', { source: 'library', multiple: false }) - ) - await expect( - failing.serve('native.media.read', { - handle: picked.items[0]?.handle ?? '', - offset: 0, - length: 16 - }) - ).rejects.toThrow(/stopped reading/) - expect(handles.at(-1)?.closed).toBe(true) - }) -}) - describe('a provider uri the Android picker can answer', () => { it('copies it into the cache and mints the handle over the copy, bytes intact', async () => { // Android may return an uncopied provider URI when MIME resolution fails; adopt it rather than lose the selected photo. diff --git a/mobile/src/platform/native-media.ts b/mobile/src/platform/media-picking/native-media.ts similarity index 93% rename from mobile/src/platform/native-media.ts rename to mobile/src/platform/media-picking/native-media.ts index f50af3d2b3c..60c175a6082 100644 --- a/mobile/src/platform/native-media.ts +++ b/mobile/src/platform/media-picking/native-media.ts @@ -6,15 +6,13 @@ import { mediaReadParamsSchema, mediaReleaseParamsSchema, type BridgeMediaSource -} from '../mobile-web-shell/bridge/bridge-media-verbs' -import type { BridgeNativeVerb } from '../mobile-web-shell/bridge/bridge-native-verbs' -import { BridgeNativeVerbRefusedError } from '../mobile-web-shell/bridge-host-errors' -import { - MEDIA_STAGED_MAX_BYTES, - type MediaHandleRegistry, - type StagedMedia -} from '../mobile-web-shell/media-handle-registry' -import { MobileImageBase64Accumulator } from '../files/attachments/mobile-image-base64-accumulator' +} from '../../mobile-web-shell/bridge/bridge-media-verbs' +import type { BridgeNativeVerb } from '../../mobile-web-shell/bridge/bridge-native-verbs' +import { BridgeNativeVerbRefusedError } from '../../mobile-web-shell/bridge-host-errors' +import '../../mobile-web-shell/media-handle-registry' +import type { MediaHandleRegistry, StagedMedia } from '../../mobile-web-shell/media-handle-registry' +import { CLIPBOARD_IMAGE_MAX_SOURCE_BYTES as MEDIA_STAGED_MAX_BYTES } from '../../../../src/shared/clipboard-image' +import { MobileImageBase64Accumulator } from '../../files/attachments/mobile-image-base64-accumulator' // Stage native media behind handles because raw/base64 image sizes exceed reply caps; inject device calls to test provider/cancel/size failures. diff --git a/mobile/src/platform/microphone-screen-lock.test.ts b/mobile/src/platform/microphone-screen-lock.test.ts deleted file mode 100644 index cd6b41eda40..00000000000 --- a/mobile/src/platform/microphone-screen-lock.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -// Hold activation open across release to expose the race that leaves a screen awake after capture ends. -import { describe, expect, it } from 'vitest' -import { createMicrophoneScreenLock, type ScreenLockDevice } from './microphone-screen-lock' - -function createTestDevice() { - const calls: string[] = [] - const settles: (() => void)[] = [] - let holdOpen = false - const answer = (name: string): Promise => { - calls.push(name) - if (!holdOpen) { - return Promise.resolve() - } - return new Promise((resolve) => settles.push(resolve)) - } - const device: ScreenLockDevice = { - activate: (tag) => answer(`+${tag}`), - deactivate: (tag) => answer(`-${tag}`) - } - return { - device, - calls, - holdCallsOpen: () => { - holdOpen = true - }, - settleAll: () => { - holdOpen = false - for (const settle of settles.splice(0)) { - settle() - } - } - } -} - -async function flush(): Promise { - await new Promise((resolve) => setTimeout(resolve, 0)) -} - -describe('the lock a microphone holds', () => { - it('asks the device nothing for a hold it already has', async () => { - const { device, calls } = createTestDevice() - const lock = createMicrophoneScreenLock(device, 'orca-mic') - lock.hold() - lock.hold() - await flush() - expect(calls).toEqual(['+orca-mic']) - }) - - it('asks the device nothing for a release of a tag it never took', async () => { - const { device, calls } = createTestDevice() - const lock = createMicrophoneScreenLock(device, 'orca-mic') - lock.release() - await flush() - expect(calls).toEqual([]) - lock.hold() - lock.release() - lock.release() - await flush() - expect(calls).toEqual(['+orca-mic', '-orca-mic']) - }) - - it('never lets a slow activate land after the release that followed it', async () => { - const { device, calls, holdCallsOpen, settleAll } = createTestDevice() - const lock = createMicrophoneScreenLock(device, 'orca-mic') - holdCallsOpen() - lock.hold() - await flush() - // Release before activation settles must still deactivate last. - lock.release() - await flush() - expect(calls).toEqual(['+orca-mic']) - settleAll() - await flush() - expect(calls).toEqual(['+orca-mic', '-orca-mic']) - }) - - it('keeps working after a call the device refused', async () => { - const calls: string[] = [] - const device: ScreenLockDevice = { - activate: (tag) => { - calls.push(`+${tag}`) - return Promise.reject(new Error('no current activity')) - }, - deactivate: (tag) => { - calls.push(`-${tag}`) - return Promise.resolve() - } - } - const lock = createMicrophoneScreenLock(device, 'orca-mic') - lock.hold() - lock.release() - await flush() - // Failed wake locks must neither fail dictation nor block queued cleanup. - expect(calls).toEqual(['+orca-mic', '-orca-mic']) - lock.hold() - await flush() - expect(calls).toEqual(['+orca-mic', '-orca-mic', '+orca-mic']) - }) -}) diff --git a/mobile/src/platform/native-media-device.test.ts b/mobile/src/platform/native-media-device.test.ts deleted file mode 100644 index 4f5dc2c2c8a..00000000000 --- a/mobile/src/platform/native-media-device.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -const expo = vi.hoisted(() => ({ - launchImageLibraryAsync: vi.fn((_options: Record) => - Promise.resolve({ canceled: true }) - ), - requestMediaLibraryPermissionsAsync: vi.fn(() => Promise.resolve({ granted: true })), - getDocumentAsync: vi.fn(() => Promise.resolve({ canceled: true })), - getImageAsync: vi.fn(() => Promise.resolve(null)), - deleted: new Array(), - copiedBytes: new Array<{ uri: string; bytes: number[] }>(), - sources: new Map() -})) - -vi.mock('expo-clipboard', () => ({ getImageAsync: expo.getImageAsync })) -vi.mock('expo-document-picker', () => ({ getDocumentAsync: expo.getDocumentAsync })) -vi.mock('react-native', () => ({ Platform: { OS: 'ios' } })) -vi.mock('expo-image-picker', () => ({ - UIImagePickerPreferredAssetRepresentationMode: { Compatible: 'compatible' }, - launchImageLibraryAsync: expo.launchImageLibraryAsync, - requestMediaLibraryPermissionsAsync: expo.requestMediaLibraryPermissionsAsync -})) -vi.mock('expo-file-system', () => ({ - File: class { - readonly uri: string - readonly size = 7 - constructor(...parts: string[]) { - this.uri = parts.join('/') - } - create(): void {} - writeSync(content: string | Uint8Array): void { - if (typeof content !== 'string') { - expo.copiedBytes.push({ uri: this.uri, bytes: [...content] }) - } - } - bytesSync(): Uint8Array { - const bytes = expo.sources.get(this.uri) - if (bytes === undefined) { - throw new Error(`nothing readable at ${this.uri}`) - } - return bytes - } - delete(): void { - expo.deleted.push(this.uri) - } - }, - Paths: { cache: 'file:///cache' } -})) - -import { copyPickedMediaIntoCache } from './native-media-device' - -describe('copying a provider uri into the cache', () => { - it('deletes the file it created when the source could not be read', () => { - // Clean failed writes here because callers never learn the new file’s name. - const before = expo.deleted.length - expect(() => copyPickedMediaIntoCache('content://media/gone')).toThrow(/nothing readable/) - expect(expo.deleted.length).toBe(before + 1) - expect(expo.deleted.at(-1)?.startsWith('file:///cache/orca-media-')).toBe(true) - }) -}) diff --git a/mobile/src/platform/native-pdf-view.ts b/mobile/src/platform/pdf-viewing/native-pdf-view.ts similarity index 79% rename from mobile/src/platform/native-pdf-view.ts rename to mobile/src/platform/pdf-viewing/native-pdf-view.ts index c1627e2573a..25ee48cd393 100644 --- a/mobile/src/platform/native-pdf-view.ts +++ b/mobile/src/platform/pdf-viewing/native-pdf-view.ts @@ -2,23 +2,23 @@ import { useCallback, useLayoutEffect, useMemo, useRef } from 'react' import { Platform } from 'react-native' import { File as FsFile } from 'expo-file-system' import { useFocusEffect, useNavigation } from 'expo-router' -import { presentPdf } from '../../modules/orca-mobile-web-shell/src' -import { getRuntimePathBasename } from '../../../src/shared/cross-platform-path' -import { PDF_MIME_TYPE } from '../files/media/mobile-file-media' +import { presentPdf } from '../../../modules/orca-mobile-web-shell/src/index' +import { getRuntimePathBasename } from '../../../../src/shared/cross-platform-path' +import { PDF_MIME_TYPE } from '../../files/media/mobile-file-media' import { downloadMobileFileMedia, MobileMediaHostOutdatedError, type MobileMediaSink -} from '../files/media/mobile-file-media-download' +} from '../../files/media/mobile-file-media-download' import { pdfViewParamsSchema, type BridgePdfOpenParams -} from '../mobile-web-shell/bridge/bridge-native-verbs' -import { BridgeNativeVerbRefusedError } from '../mobile-web-shell/bridge-host-errors' -import { scopeRpcClientToExecutionHost } from '../worktree/execution-host-scoped-rpc-client' -import { useHostClient } from '../transport/connection/host-client-hooks' -import { discardUnreturnedMediaFile, freshCacheFolder } from './native-media-device' -import type { RpcClient } from '../transport/rpc/rpc-client-contract' +} from '../../mobile-web-shell/bridge/bridge-native-verbs' +import { BridgeNativeVerbRefusedError } from '../../mobile-web-shell/bridge-host-errors' +import { scopeRpcClientToExecutionHost } from '../../worktree/execution-host-scoped-rpc-client' +import { useHostClient } from '../../transport/connection/host-client-hooks' +import { discardUnreturnedMediaFile, freshCacheFolder } from '../media-picking/native-media-device' +import type { RpcClient } from '../../transport/rpc/rpc-client-contract' function pdfFileSink(file: FsFile): MobileMediaSink { file.create({ overwrite: true }) diff --git a/mobile/src/platform/pdf-opener-contract.ts b/mobile/src/platform/pdf-viewing/pdf-opener-contract.ts similarity index 100% rename from mobile/src/platform/pdf-opener-contract.ts rename to mobile/src/platform/pdf-viewing/pdf-opener-contract.ts diff --git a/mobile/src/platform/pdf-opener.ts b/mobile/src/platform/pdf-viewing/pdf-opener.ts similarity index 100% rename from mobile/src/platform/pdf-opener.ts rename to mobile/src/platform/pdf-viewing/pdf-opener.ts diff --git a/mobile/src/platform/pdf-opener.web.ts b/mobile/src/platform/pdf-viewing/pdf-opener.web.ts similarity index 84% rename from mobile/src/platform/pdf-opener.web.ts rename to mobile/src/platform/pdf-viewing/pdf-opener.web.ts index 46b9203c639..0a87ea7d194 100644 --- a/mobile/src/platform/pdf-opener.web.ts +++ b/mobile/src/platform/pdf-viewing/pdf-opener.web.ts @@ -1,7 +1,7 @@ import { useMemo } from 'react' -import { parseExecutionHostId } from '../../../src/shared/execution-host' -import { NativeVerbError, useNativeVerbs } from '../mobile-web-shell/bridge/use-native-verbs' -import { useWorkspaceExecutionHost } from '../navigation/workspace-execution-host' +import { parseExecutionHostId } from '../../../../src/shared/execution-host' +import { NativeVerbError, useNativeVerbs } from '../../mobile-web-shell/bridge/use-native-verbs' +import { useWorkspaceExecutionHost } from '../../navigation/workspace-execution-host' import { PdfOpenRefusedError, type PdfOpener } from './pdf-opener-contract' // Older shells lack the verbs, so callers get null and ask for an app update instead. diff --git a/mobile/src/platform/shell-storage.ts b/mobile/src/platform/shell-storage/shell-storage.ts similarity index 100% rename from mobile/src/platform/shell-storage.ts rename to mobile/src/platform/shell-storage/shell-storage.ts diff --git a/mobile/src/platform/shell-storage.web.ts b/mobile/src/platform/shell-storage/shell-storage.web.ts similarity index 77% rename from mobile/src/platform/shell-storage.web.ts rename to mobile/src/platform/shell-storage/shell-storage.web.ts index ff88ce7f1ba..bd53c374a62 100644 --- a/mobile/src/platform/shell-storage.web.ts +++ b/mobile/src/platform/shell-storage/shell-storage.web.ts @@ -1,5 +1,5 @@ import { useSyncExternalStore } from 'react' -import { usePageBridgeClientIfPresent } from '../transport/connection/client-context.web' +import { usePageBridgeClientIfPresent } from '../../transport/connection/client-context.web' export function useShellStorage(): Readonly> | null { const client = usePageBridgeClientIfPresent() diff --git a/mobile/src/platform/native-speech-device.ts b/mobile/src/platform/speech/native-speech-device.ts similarity index 81% rename from mobile/src/platform/native-speech-device.ts rename to mobile/src/platform/speech/native-speech-device.ts index d2e46302426..b17a8632405 100644 --- a/mobile/src/platform/native-speech-device.ts +++ b/mobile/src/platform/speech/native-speech-device.ts @@ -5,8 +5,8 @@ import { speechFinish, speechStart } from '@orca/expo-two-way-audio' -import { loadDictationEnginePreference } from '../storage/dictation-engine-preference' -import { NATIVE_AUDIO_DEVICE_SAMPLE_RATE } from './native-audio-device' +import { loadDictationEnginePreference } from '../../storage/dictation-engine-preference' +import { NATIVE_AUDIO_DEVICE_SAMPLE_RATE } from '../audio-capture/native-audio-device' import type { NativeSpeechEngine } from './native-speech' // Isolate the device module so tests drive the server with a fake engine. diff --git a/mobile/src/platform/native-speech.test.ts b/mobile/src/platform/speech/native-speech.test.ts similarity index 97% rename from mobile/src/platform/native-speech.test.ts rename to mobile/src/platform/speech/native-speech.test.ts index 62a5be12d16..af1eb75f40a 100644 --- a/mobile/src/platform/native-speech.test.ts +++ b/mobile/src/platform/speech/native-speech.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { DictationEnginePreference } from '../mobile-web-shell/bridge/bridge-speech-verbs' +import type { DictationEnginePreference } from '../../mobile-web-shell/bridge/bridge-speech-verbs' import { createNativeSpeechServer, type NativeSpeechEngine } from './native-speech' function engineWith(preference: DictationEnginePreference): NativeSpeechEngine { diff --git a/mobile/src/platform/native-speech.ts b/mobile/src/platform/speech/native-speech.ts similarity index 99% rename from mobile/src/platform/native-speech.ts rename to mobile/src/platform/speech/native-speech.ts index 463132d9866..c3aeebe2579 100644 --- a/mobile/src/platform/native-speech.ts +++ b/mobile/src/platform/speech/native-speech.ts @@ -4,7 +4,7 @@ import { type BridgeSpeechFailure, type BridgeSpeechRead, type DictationEnginePreference -} from '../mobile-web-shell/bridge/bridge-speech-verbs' +} from '../../mobile-web-shell/bridge/bridge-speech-verbs' // The shell side of native.speech.act. The recognizer itself lives in the native audio module; this keeps its events between the page's polls. diff --git a/mobile/src/platform/on-device-speech-contract.ts b/mobile/src/platform/speech/on-device-speech-contract.ts similarity index 89% rename from mobile/src/platform/on-device-speech-contract.ts rename to mobile/src/platform/speech/on-device-speech-contract.ts index 3fc61546b91..ea480c17585 100644 --- a/mobile/src/platform/on-device-speech-contract.ts +++ b/mobile/src/platform/speech/on-device-speech-contract.ts @@ -3,7 +3,7 @@ import type { BridgeSpeechRead, BridgeSpeechStartResult, BridgeSpeechStatusResult -} from '../mobile-web-shell/bridge/bridge-speech-verbs' +} from '../../mobile-web-shell/bridge/bridge-speech-verbs' // The phone's own recognizer as dictation sees it, in the phone's language. Any rejection means "use the computer". export type OnDeviceSpeech = { diff --git a/mobile/src/platform/on-device-speech-locale.ts b/mobile/src/platform/speech/on-device-speech-locale.ts similarity index 77% rename from mobile/src/platform/on-device-speech-locale.ts rename to mobile/src/platform/speech/on-device-speech-locale.ts index cf5edb0684e..ce0bde3c208 100644 --- a/mobile/src/platform/on-device-speech-locale.ts +++ b/mobile/src/platform/speech/on-device-speech-locale.ts @@ -1,4 +1,4 @@ -import { readDeviceUiLocaleTags } from '../i18n/system-ui-locale' +import { readDeviceUiLocaleTags } from '../../i18n/system-ui-locale' // The phone's first language, read the same way in the shell's settings and the page, so both ask the recognizer about one locale. export function onDeviceSpeechLocale(): string { diff --git a/mobile/src/platform/on-device-speech-locale.web.ts b/mobile/src/platform/speech/on-device-speech-locale.web.ts similarity index 100% rename from mobile/src/platform/on-device-speech-locale.web.ts rename to mobile/src/platform/speech/on-device-speech-locale.web.ts diff --git a/mobile/src/platform/on-device-speech.ts b/mobile/src/platform/speech/on-device-speech.ts similarity index 80% rename from mobile/src/platform/on-device-speech.ts rename to mobile/src/platform/speech/on-device-speech.ts index b592de40404..6151b188895 100644 --- a/mobile/src/platform/on-device-speech.ts +++ b/mobile/src/platform/speech/on-device-speech.ts @@ -1,4 +1,4 @@ -import type { DictationTerm } from '../../../src/shared/dictation-dictionary' +import type { DictationTerm } from '../../../../src/shared/dictation-dictionary' import type { OnDeviceSpeech } from './on-device-speech-contract' // The session screen is a page in the shell (on-device-speech.web.ts); native screens dictate through the computer. diff --git a/mobile/src/platform/on-device-speech.web.test.tsx b/mobile/src/platform/speech/on-device-speech.web.test.tsx similarity index 96% rename from mobile/src/platform/on-device-speech.web.test.tsx rename to mobile/src/platform/speech/on-device-speech.web.test.tsx index 2bcf77019fa..efc8742c704 100644 --- a/mobile/src/platform/on-device-speech.web.test.tsx +++ b/mobile/src/platform/speech/on-device-speech.web.test.tsx @@ -4,7 +4,7 @@ import { act, create } from 'react-test-renderer' import { describe, expect, it, vi } from 'vitest' import type { OnDeviceSpeech } from './on-device-speech-contract' -vi.mock('../mobile-web-shell/bridge/use-native-verbs', () => { +vi.mock('../../mobile-web-shell/bridge/use-native-verbs', () => { const verbs = { canRecognizeSpeech: true, speechStatus: async () => ({ status: 'ready', locale: 'de-DE', preference: 'automatic' }), diff --git a/mobile/src/platform/on-device-speech.web.ts b/mobile/src/platform/speech/on-device-speech.web.ts similarity index 90% rename from mobile/src/platform/on-device-speech.web.ts rename to mobile/src/platform/speech/on-device-speech.web.ts index fbaee133ffe..e9df5bb33ec 100644 --- a/mobile/src/platform/on-device-speech.web.ts +++ b/mobile/src/platform/speech/on-device-speech.web.ts @@ -1,7 +1,7 @@ import { useLayoutEffect, useMemo, useRef } from 'react' -import type { DictationTerm } from '../../../src/shared/dictation-dictionary' -import { createDictationTextPipeline } from '../../../src/shared/dictation-text-pipeline' -import { useNativeVerbs, type NativeVerbs } from '../mobile-web-shell/bridge/use-native-verbs' +import type { DictationTerm } from '../../../../src/shared/dictation-dictionary' +import { createDictationTextPipeline } from '../../../../src/shared/dictation-text-pipeline' +import { useNativeVerbs, type NativeVerbs } from '../../mobile-web-shell/bridge/use-native-verbs' import type { OnDeviceSpeech } from './on-device-speech-contract' import { onDeviceSpeechLocale } from './on-device-speech-locale' diff --git a/mobile/src/platform/live-input-composing-range.test.ts b/mobile/src/platform/text-input/live-input-composing-range.test.ts similarity index 100% rename from mobile/src/platform/live-input-composing-range.test.ts rename to mobile/src/platform/text-input/live-input-composing-range.test.ts diff --git a/mobile/src/platform/live-input-composing-range.ts b/mobile/src/platform/text-input/live-input-composing-range.ts similarity index 100% rename from mobile/src/platform/live-input-composing-range.ts rename to mobile/src/platform/text-input/live-input-composing-range.ts diff --git a/mobile/src/platform/text-input-content-height.ts b/mobile/src/platform/text-input/text-input-content-height.ts similarity index 100% rename from mobile/src/platform/text-input-content-height.ts rename to mobile/src/platform/text-input/text-input-content-height.ts diff --git a/mobile/src/platform/text-input-content-height.web.ts b/mobile/src/platform/text-input/text-input-content-height.web.ts similarity index 100% rename from mobile/src/platform/text-input-content-height.web.ts rename to mobile/src/platform/text-input/text-input-content-height.web.ts diff --git a/mobile/src/platform/text-input-font-size.ts b/mobile/src/platform/text-input/text-input-font-size.ts similarity index 51% rename from mobile/src/platform/text-input-font-size.ts rename to mobile/src/platform/text-input/text-input-font-size.ts index d82c626b43d..dabbc5a32d8 100644 --- a/mobile/src/platform/text-input-font-size.ts +++ b/mobile/src/platform/text-input/text-input-font-size.ts @@ -1,3 +1,3 @@ -import { typography } from '../theme/mobile-theme' +import { typography } from '../../theme/mobile-theme' export const TEXT_INPUT_FONT_SIZE = typography.bodySize diff --git a/mobile/src/platform/text-input-font-size.web.ts b/mobile/src/platform/text-input/text-input-font-size.web.ts similarity index 87% rename from mobile/src/platform/text-input-font-size.web.ts rename to mobile/src/platform/text-input/text-input-font-size.web.ts index 1cd340159d0..628738be7ea 100644 --- a/mobile/src/platform/text-input-font-size.web.ts +++ b/mobile/src/platform/text-input/text-input-font-size.web.ts @@ -1,4 +1,4 @@ -import { typography } from '../theme/mobile-theme' +import { typography } from '../../theme/mobile-theme' // iOS zooms focused inputs below 16px; share this floor with the route-closure census. export const TEXT_INPUT_FONT_SIZE_FLOOR = 16 diff --git a/mobile/src/platform/text-input-handle.ts b/mobile/src/platform/text-input/text-input-handle.ts similarity index 100% rename from mobile/src/platform/text-input-handle.ts rename to mobile/src/platform/text-input/text-input-handle.ts diff --git a/mobile/src/platform/text-input-selection-reveal.web.ts b/mobile/src/platform/text-input/text-input-selection-reveal.web.ts similarity index 100% rename from mobile/src/platform/text-input-selection-reveal.web.ts rename to mobile/src/platform/text-input/text-input-selection-reveal.web.ts diff --git a/mobile/src/platform/use-native-device-verbs.ts b/mobile/src/platform/use-native-device-verbs.ts index 647133dea35..68fb6c4575d 100644 --- a/mobile/src/platform/use-native-device-verbs.ts +++ b/mobile/src/platform/use-native-device-verbs.ts @@ -2,14 +2,17 @@ import { useEffect, useMemo } from 'react' import type { BridgeNativeVerb } from '../mobile-web-shell/bridge/bridge-native-verbs' import type { BRIDGE_STORE_VERB } from '../mobile-web-shell/bridge/bridge-store-verb' import { useMediaHandleRegistry } from '../mobile-web-shell/use-media-handle-registry' -import { createNativeAudioCapture } from './native-audio' -import { NATIVE_AUDIO_DEVICE_SAMPLE_RATE, nativeAudioDeviceEngine } from './native-audio-device' -import { createNativeSpeechServer } from './native-speech' -import { nativeSpeechDeviceEngine } from './native-speech-device' -import { serveNativeClipboardVerb } from './native-clipboard' -import { serveNativeAnnounceVerb } from './native-accessibility-announce' -import { createNativeMediaVerbServer } from './native-media' -import { discardStagedMedia, nativeMediaDeviceDeps } from './native-media-device' +import { createNativeAudioCapture } from './audio-capture/native-audio' +import { + NATIVE_AUDIO_DEVICE_SAMPLE_RATE, + nativeAudioDeviceEngine +} from './audio-capture/native-audio-device' +import { createNativeSpeechServer } from './speech/native-speech' +import { nativeSpeechDeviceEngine } from './speech/native-speech-device' +import { serveNativeClipboardVerb } from './clipboard/native-clipboard' +import { serveNativeAnnounceVerb } from './accessibility/native-accessibility-announce' +import { createNativeMediaVerbServer } from './media-picking/native-media' +import { discardStagedMedia, nativeMediaDeviceDeps } from './media-picking/native-media-device' export type NativeDeviceVerb = Exclude< BridgeNativeVerb, diff --git a/mobile/src/platform/view-measurement/page-box-contract.ts b/mobile/src/platform/view-measurement/page-box-contract.ts new file mode 100644 index 00000000000..b485b0f65a5 --- /dev/null +++ b/mobile/src/platform/view-measurement/page-box-contract.ts @@ -0,0 +1 @@ +export type PageBox = { left: number; top: number; width: number; height: number } diff --git a/mobile/src/platform/page-box.ts b/mobile/src/platform/view-measurement/page-box.ts similarity index 80% rename from mobile/src/platform/page-box.ts rename to mobile/src/platform/view-measurement/page-box.ts index 32d859854d7..1821533a492 100644 --- a/mobile/src/platform/page-box.ts +++ b/mobile/src/platform/view-measurement/page-box.ts @@ -1,6 +1,6 @@ import type { HostInstance } from 'react-native' -export type PageBox = { left: number; top: number; width: number; height: number } +import type { PageBox } from './page-box-contract' // A view's box in page coordinates, the space touch events report pageX/pageY in; null when it has none. export function measurePageBox(target: HostInstance, onBox: (box: PageBox | null) => void): void { diff --git a/mobile/src/platform/page-box.web.ts b/mobile/src/platform/view-measurement/page-box.web.ts similarity index 89% rename from mobile/src/platform/page-box.web.ts rename to mobile/src/platform/view-measurement/page-box.web.ts index 604e33f09fa..fe28a65be5f 100644 --- a/mobile/src/platform/page-box.web.ts +++ b/mobile/src/platform/view-measurement/page-box.web.ts @@ -1,7 +1,5 @@ import type { View } from 'react-native' -import type { PageBox } from './page-box' - -export type { PageBox } from './page-box' +import type { PageBox } from './page-box-contract' function hasClientRect(node: unknown): node is { getBoundingClientRect: () => DOMRect } { return ( diff --git a/mobile/src/session/MobileSessionSheets.tsx b/mobile/src/session/MobileSessionSheets.tsx index 7cf3bf558db..60257879acb 100644 --- a/mobile/src/session/MobileSessionSheets.tsx +++ b/mobile/src/session/MobileSessionSheets.tsx @@ -1,11 +1,11 @@ import { Platform } from 'react-native' import { useMobileTranslation } from '../i18n/use-mobile-translation' import { getRepoIdFromWorktreeId } from '../../../src/shared/worktree/id' -import { useClipboardWriter } from '../platform/clipboard' +import { useClipboardWriter } from '../platform/clipboard/clipboard' import { Copy, FileText, RefreshCw } from 'lucide-react-native' import { MobileSessionTerminalLayoutSheet } from '../terminal/session/MobileSessionTerminalLayoutSheet' import { QuickCommandsSheet } from '../terminal/quick-commands/QuickCommandsSheet' -import { triggerSuccess, triggerError } from '../platform/haptics' +import { triggerSuccess, triggerError } from '../platform/haptics/haptics' import { ActionSheetModal } from '../components/ActionSheetModal' import { TextInputModal } from '../components/TextInputModal' import { MobileTerminalRenameModal } from '../terminal/session/MobileTerminalRenameModal' diff --git a/mobile/src/session/MobileSessionTitleBlock.tsx b/mobile/src/session/MobileSessionTitleBlock.tsx index 59fe3bf8dc4..f1d0a9543d0 100644 --- a/mobile/src/session/MobileSessionTitleBlock.tsx +++ b/mobile/src/session/MobileSessionTitleBlock.tsx @@ -7,7 +7,7 @@ import type { ConnectionState } from '../transport/connection/connection-state' import { styles } from './mobile-session-styles' import { useMobileTranslation } from '../i18n/use-mobile-translation' import { pressedStyle } from '../theme/mobile-theme' -import { useKeyboardPersistingTaps } from '../platform/keyboard-persisting-taps' +import { useKeyboardPersistingTaps } from '../platform/keyboard/keyboard-persisting-taps' /** The session's title and status line. A tap opens a card with the full name, where it lives * and its host, as desktop's tab card does, because a sideways phone cuts the title short. */ diff --git a/mobile/src/session/__mocks__/mobile-prompt-render-mocks.ts b/mobile/src/session/__mocks__/mobile-prompt-render-mocks.ts index b9ca8ec1ad5..5e4b4233439 100644 --- a/mobile/src/session/__mocks__/mobile-prompt-render-mocks.ts +++ b/mobile/src/session/__mocks__/mobile-prompt-render-mocks.ts @@ -67,7 +67,9 @@ vi.mock('../native-chat/transcript/MobileNativeChatRowOverlaysHost', () => ({ vi.mock('../native-chat/visual/MobileNativeChatVisual', () => ({ useMobileNativeChatVisualRenderer: () => null })) -vi.mock('../../platform/use-reduced-motion', () => ({ useReducedMotionEnabled: () => null })) +vi.mock('../../platform/accessibility/use-reduced-motion', () => ({ + useReducedMotionEnabled: () => null +})) vi.mock('../native-chat/transcript/MobileNativeChatLiveLine', () => ({ MobileNativeChatLiveLine: 'LiveStatus' })) diff --git a/mobile/src/session/agent-launch/new-tab-agent-host-launch.ts b/mobile/src/session/agent-launch/new-tab-agent-host-launch.ts index 38438ae18ab..4e644a5417a 100644 --- a/mobile/src/session/agent-launch/new-tab-agent-host-launch.ts +++ b/mobile/src/session/agent-launch/new-tab-agent-host-launch.ts @@ -3,7 +3,7 @@ import type { MobileSessionTab } from '../tabs/mobile-session-tabs' import type { TuiAgent } from '../../../../src/shared/agent-launch/catalog/tui-agent' import { AGENT_LAUNCH_PLACEMENT_RUNTIME_CAPABILITY } from '../../../../src/shared/agent-launch/contracts/agent-launch-runtime-capability' import type { RpcClient } from '../../transport/rpc/rpc-client-contract' -import { triggerError, triggerSuccess } from '../../platform/haptics' +import { triggerError, triggerSuccess } from '../../platform/haptics/haptics' import { launchAgentInExistingWorkspace, reserveMobileAgentLaunch diff --git a/mobile/src/session/mobile-dictation-session-outcomes.test.tsx b/mobile/src/session/mobile-dictation-session-outcomes.test.tsx index 46d24d9a1d9..7b788142944 100644 --- a/mobile/src/session/mobile-dictation-session-outcomes.test.tsx +++ b/mobile/src/session/mobile-dictation-session-outcomes.test.tsx @@ -49,14 +49,14 @@ vi.mock('expo-router', () => { const router = { canGoBack: () => false, push: () => {}, dismissTo: () => {} } return { useFocusEffect: () => {}, useRouter: () => router, useNavigation: () => undefined } }) -vi.mock('../platform/haptics', () => ({ +vi.mock('../platform/haptics/haptics', () => ({ triggerError: () => { seen.errorHaptics += 1 }, triggerEdgeBump: () => {}, triggerSelection: () => {} })) -vi.mock('../platform/clipboard', () => { +vi.mock('../platform/clipboard/clipboard', () => { const writer = { writeText: async (text: string) => { seen.clipboard.push(text) diff --git a/mobile/src/session/mobile-structured-chat-fork.ts b/mobile/src/session/mobile-structured-chat-fork.ts index 009bde60332..10551804932 100644 --- a/mobile/src/session/mobile-structured-chat-fork.ts +++ b/mobile/src/session/mobile-structured-chat-fork.ts @@ -1,5 +1,5 @@ import { translate } from '../i18n/mobile-i18n' -import { triggerError } from '../platform/haptics' +import { triggerError } from '../platform/haptics/haptics' import { TUI_AGENT_DISPLAY_NAMES } from '../../../src/shared/agent-launch/catalog/tui-agent-display-names' import type { TuiAgent } from '../../../src/shared/agent-launch/catalog/tui-agent' import type { diff --git a/mobile/src/session/native-chat/MobileNativeChatFloatingControls.tsx b/mobile/src/session/native-chat/MobileNativeChatFloatingControls.tsx index a7b69265afa..8f6194c3e25 100644 --- a/mobile/src/session/native-chat/MobileNativeChatFloatingControls.tsx +++ b/mobile/src/session/native-chat/MobileNativeChatFloatingControls.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef } from 'react' import { Animated, Easing, Platform, Pressable, View } from 'react-native' import { ArrowDown } from 'lucide-react-native' import { SHEET_CURVE } from '../../components/sheet-motion-values' -import { useReducedMotionEnabled } from '../../platform/use-reduced-motion' +import { useReducedMotionEnabled } from '../../platform/accessibility/use-reduced-motion' import { colors, feedback } from '../../theme/mobile-theme' import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme' import { styles } from './mobile-native-chat-view-styles' diff --git a/mobile/src/session/native-chat/composer/MobileNativeChatComposerField.web.tsx b/mobile/src/session/native-chat/composer/MobileNativeChatComposerField.web.tsx index 0cf471bfc88..68dc976db86 100644 --- a/mobile/src/session/native-chat/composer/MobileNativeChatComposerField.web.tsx +++ b/mobile/src/session/native-chat/composer/MobileNativeChatComposerField.web.tsx @@ -7,10 +7,10 @@ import { useWindowDimensions, type TextInputInstance } from 'react-native' -import { useKeyboardOcclusion } from '../../../platform/keyboard-occlusion' -import { primaryModifierKey } from '../../../platform/primary-modifier-key' -import { useTextInputContentHeight } from '../../../platform/text-input-content-height' -import { useTextInputSelectionReveal } from '../../../platform/text-input-selection-reveal.web' +import { useKeyboardOcclusion } from '../../../platform/keyboard/keyboard-occlusion' +import { primaryModifierKey } from '../../../platform/keyboard/primary-modifier-key' +import { useTextInputContentHeight } from '../../../platform/text-input/text-input-content-height' +import { useTextInputSelectionReveal } from '../../../platform/text-input/text-input-selection-reveal.web' import { colors } from '../../../theme/mobile-theme' import { useMobileColorScheme } from '../../../theme/use-mobile-color-scheme' import { diff --git a/mobile/src/session/native-chat/composer/MobileNativeChatComposerMenuLayer.web.tsx b/mobile/src/session/native-chat/composer/MobileNativeChatComposerMenuLayer.web.tsx index bde29deb099..71a3f75b91e 100644 --- a/mobile/src/session/native-chat/composer/MobileNativeChatComposerMenuLayer.web.tsx +++ b/mobile/src/session/native-chat/composer/MobileNativeChatComposerMenuLayer.web.tsx @@ -1,8 +1,8 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { View } from 'react-native' -import { useKeyboardOcclusion } from '../../../platform/keyboard-occlusion' -import { useKeyboardPersistingTaps } from '../../../platform/keyboard-persisting-taps' +import { useKeyboardOcclusion } from '../../../platform/keyboard/keyboard-occlusion' +import { useKeyboardPersistingTaps } from '../../../platform/keyboard/keyboard-persisting-taps' import { useMobileNativeChatLowerRegion } from '../mobile-native-chat-lower-region-context' import { useMobileNativeChatComposerAllocation } from './MobileNativeChatComposerAllocation' import type { ComposerMenuLayerProps } from './MobileNativeChatComposerMenuLayer' diff --git a/mobile/src/session/native-chat/composer/MobileNativeChatContextUsageRing.tsx b/mobile/src/session/native-chat/composer/MobileNativeChatContextUsageRing.tsx index 34a2162a2f2..5c408b1a510 100644 --- a/mobile/src/session/native-chat/composer/MobileNativeChatContextUsageRing.tsx +++ b/mobile/src/session/native-chat/composer/MobileNativeChatContextUsageRing.tsx @@ -24,8 +24,8 @@ import { COMPOSER_CONTROL_HIT_SLOP, composerInputStyles as inputStyles } from '../../../components/composer-input/composer-input-styles' -import { useKeyboardPersistingTaps } from '../../../platform/keyboard-persisting-taps' -import { useKeyboardOcclusion } from '../../../platform/keyboard-occlusion' +import { useKeyboardPersistingTaps } from '../../../platform/keyboard/keyboard-persisting-taps' +import { useKeyboardOcclusion } from '../../../platform/keyboard/keyboard-occlusion' import { useMobileTranslation } from '../../../i18n/use-mobile-translation' import { colors, radii, spacing, typography } from '../../../theme/mobile-theme' import { themedStyleSheet } from '../../../theme/themed-styles' diff --git a/mobile/src/session/native-chat/composer/MobileNativeChatSendButton.tsx b/mobile/src/session/native-chat/composer/MobileNativeChatSendButton.tsx index 66c1ba3b1db..b2b3336b0cc 100644 --- a/mobile/src/session/native-chat/composer/MobileNativeChatSendButton.tsx +++ b/mobile/src/session/native-chat/composer/MobileNativeChatSendButton.tsx @@ -13,7 +13,7 @@ import { composerInputStyles as inputStyles } from '../../../components/composer-input/composer-input-styles' import { useMobileTranslation } from '../../../i18n/use-mobile-translation' -import { useKeyboardPersistingTaps } from '../../../platform/keyboard-persisting-taps' +import { useKeyboardPersistingTaps } from '../../../platform/keyboard/keyboard-persisting-taps' import { colors } from '../../../theme/mobile-theme' import { useMobileColorScheme } from '../../../theme/use-mobile-color-scheme' import type { MobileNativeChatSendIntent } from './mobile-native-chat-send' diff --git a/mobile/src/session/native-chat/composer/mobile-native-chat-composer-paste.ts b/mobile/src/session/native-chat/composer/mobile-native-chat-composer-paste.ts index f4708ea1c3a..ff4d644b36f 100644 --- a/mobile/src/session/native-chat/composer/mobile-native-chat-composer-paste.ts +++ b/mobile/src/session/native-chat/composer/mobile-native-chat-composer-paste.ts @@ -4,8 +4,8 @@ import { type ClipboardEventDataLike } from '../../../../../src/shared/native-chat/input/native-chat-clipboard-event' import { isNativeChatLongPaste } from '../../../../../src/shared/native-chat/input/native-chat-long-paste' -import type { ClipboardReader } from '../../../platform/clipboard' -import type { PickedMobileImage } from '../../../platform/media-picker-contract' +import type { ClipboardReader } from '../../../platform/clipboard/clipboard' +import type { PickedMobileImage } from '../../../platform/media-picking/media-picker-contract' import { encodeBase64Bytes } from '../../../transport/encryption/base64-byte-codec' /** One paste's image: the event's file, or null when WebKit left the bytes out and the shell clipboard holds them. */ diff --git a/mobile/src/session/native-chat/composer/mobile-native-chat-file-attachment.ts b/mobile/src/session/native-chat/composer/mobile-native-chat-file-attachment.ts index 261aff9b11e..fe7cb9e6520 100644 --- a/mobile/src/session/native-chat/composer/mobile-native-chat-file-attachment.ts +++ b/mobile/src/session/native-chat/composer/mobile-native-chat-file-attachment.ts @@ -3,7 +3,7 @@ import { chatTextAttachmentName } from '../../../../../src/shared/native-chat-te import { MobileFileTooLargeError, type PickedMobileFile -} from '../../../platform/media-picker-contract' +} from '../../../platform/media-picking/media-picker-contract' import type { AgentSessionAttachmentUploadCommitResult } from '../../../../../src/shared/agent-session/agent-session-attachments' export type PendingNativeChatFile = { diff --git a/mobile/src/session/native-chat/composer/mobile-native-chat-image-attachment.ts b/mobile/src/session/native-chat/composer/mobile-native-chat-image-attachment.ts index 31fd7ceaed1..15d699faf9b 100644 --- a/mobile/src/session/native-chat/composer/mobile-native-chat-image-attachment.ts +++ b/mobile/src/session/native-chat/composer/mobile-native-chat-image-attachment.ts @@ -1,7 +1,10 @@ import { saveMobileClipboardImageAsTempFile } from '../../../files/attachments/mobile-clipboard-image' import type { MobileClipboardImageRpcSender } from '../../../files/attachments/mobile-clipboard-image-operations' // Keep picker dependencies type-only so this upload module stays free of Expo/React Native at runtime. -import type { MobileImageSource, PickedMobileImage } from '../../../platform/media-picker-contract' +import type { + MobileImageSource, + PickedMobileImage +} from '../../../platform/media-picking/media-picker-contract' // path belongs to the host for sending; previewUri belongs to the phone for display. export type PendingNativeChatImage = { diff --git a/mobile/src/session/native-chat/composer/use-mobile-native-chat-file-attachments.ts b/mobile/src/session/native-chat/composer/use-mobile-native-chat-file-attachments.ts index 4a2db329673..49eee09a2d3 100644 --- a/mobile/src/session/native-chat/composer/use-mobile-native-chat-file-attachments.ts +++ b/mobile/src/session/native-chat/composer/use-mobile-native-chat-file-attachments.ts @@ -7,8 +7,8 @@ import { } from './mobile-native-chat-draft-inputs' import { useCallback } from 'react' import type { RpcClient } from '../../../transport/rpc/rpc-client-contract' -import { useMediaPicker } from '../../../platform/media-picker' -import type { PickedMobileFile } from '../../../platform/media-picker-contract' +import { useMediaPicker } from '../../../platform/media-picking/media-picker' +import type { PickedMobileFile } from '../../../platform/media-picking/media-picker-contract' import { encodeBase64Bytes } from '../../../transport/encryption/base64-byte-codec' import { agentSessionPastedTextName } from '../../../../../src/shared/agent-session/agent-session-attachments' import { uploadMobileAgentSessionAttachment } from '../../../files/attachments/mobile-agent-session-attachment-upload' diff --git a/mobile/src/session/native-chat/composer/use-mobile-native-chat-image-attachments.ts b/mobile/src/session/native-chat/composer/use-mobile-native-chat-image-attachments.ts index c5533f5492d..a6d88dae41b 100644 --- a/mobile/src/session/native-chat/composer/use-mobile-native-chat-image-attachments.ts +++ b/mobile/src/session/native-chat/composer/use-mobile-native-chat-image-attachments.ts @@ -10,7 +10,7 @@ import { import { useCallback, useMemo } from 'react' import type { RpcClient } from '../../../transport/rpc/rpc-client-contract' import type { ConnectionState } from '../../../transport/connection/connection-state' -import type { MobileImageSource } from '../../../platform/media-picker-contract' +import type { MobileImageSource } from '../../../platform/media-picking/media-picker-contract' import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment' import type { MobilePastedImages } from './mobile-native-chat-composer-paste' import { diff --git a/mobile/src/session/native-chat/composer/use-mobile-native-chat-image-upload.ts b/mobile/src/session/native-chat/composer/use-mobile-native-chat-image-upload.ts index 9e3fd851eef..8c1fe664371 100644 --- a/mobile/src/session/native-chat/composer/use-mobile-native-chat-image-upload.ts +++ b/mobile/src/session/native-chat/composer/use-mobile-native-chat-image-upload.ts @@ -8,11 +8,11 @@ import { translate } from '../../../i18n/mobile-i18n' import { CLIPBOARD_IMAGE_TOO_LARGE_ERROR } from '../../../../../src/shared/clipboard-image' import type { RpcClient } from '../../../transport/rpc/rpc-client-contract' import type { ConnectionState } from '../../../transport/connection/connection-state' -import { useMediaPicker } from '../../../platform/media-picker' +import { useMediaPicker } from '../../../platform/media-picking/media-picker' import { ImageLibraryPermissionError, type MobileImageSource -} from '../../../platform/media-picker-contract' +} from '../../../platform/media-picking/media-picker-contract' import { uploadMobileNativeChatImages, type PendingNativeChatImage @@ -21,7 +21,7 @@ import { readMobilePastedImages, type MobilePastedImages } from './mobile-native-chat-composer-paste' -import { useClipboardReader } from '../../../platform/clipboard' +import { useClipboardReader } from '../../../platform/clipboard/clipboard' import { fitMobileAgentImage } from '../../../files/attachments/mobile-agent-image-fit' type CurrentRef = { readonly current: T } diff --git a/mobile/src/session/native-chat/composer/use-mobile-native-chat-steer-chord-preview.web.ts b/mobile/src/session/native-chat/composer/use-mobile-native-chat-steer-chord-preview.web.ts index 57fb8783602..635b7b3004c 100644 --- a/mobile/src/session/native-chat/composer/use-mobile-native-chat-steer-chord-preview.web.ts +++ b/mobile/src/session/native-chat/composer/use-mobile-native-chat-steer-chord-preview.web.ts @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { primaryModifierKey } from '../../../platform/primary-modifier-key' +import { primaryModifierKey } from '../../../platform/keyboard/primary-modifier-key' import { createNativeChatSteerChordPreviewHook } from '../../../../../src/shared/native-chat/input/use-native-chat-steer-chord-preview' import type { MobileNativeChatSteerChordPreviewInput } from './use-mobile-native-chat-steer-chord-preview' diff --git a/mobile/src/session/native-chat/input/MobileNativeChatPromptCard.tsx b/mobile/src/session/native-chat/input/MobileNativeChatPromptCard.tsx index 794f8f4287a..23827476e79 100644 --- a/mobile/src/session/native-chat/input/MobileNativeChatPromptCard.tsx +++ b/mobile/src/session/native-chat/input/MobileNativeChatPromptCard.tsx @@ -10,7 +10,7 @@ import { type LayoutChangeEvent } from 'react-native' import { SHEET_CURVE, SHEET_HEIGHT_DURATION_MS } from '../../../components/sheet-motion-values' -import { useReducedMotionEnabled } from '../../../platform/use-reduced-motion' +import { useReducedMotionEnabled } from '../../../platform/accessibility/use-reduced-motion' import { colors, pressedStyle, radii, spacing, typography } from '../../../theme/mobile-theme' import { themedStyleSheet } from '../../../theme/themed-styles' import { useMobileColorScheme } from '../../../theme/use-mobile-color-scheme' diff --git a/mobile/src/session/native-chat/input/mobile-native-chat-control-swap.tsx b/mobile/src/session/native-chat/input/mobile-native-chat-control-swap.tsx index 4cd554e5c88..a7d1cd1fc87 100644 --- a/mobile/src/session/native-chat/input/mobile-native-chat-control-swap.tsx +++ b/mobile/src/session/native-chat/input/mobile-native-chat-control-swap.tsx @@ -1,7 +1,7 @@ import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react' import { Animated, Easing, Platform, StyleSheet, View } from 'react-native' import { SHEET_CURVE } from '../../../components/sheet-motion-values' -import { useReducedMotionEnabled } from '../../../platform/use-reduced-motion' +import { useReducedMotionEnabled } from '../../../platform/accessibility/use-reduced-motion' const SWAP_MS = 150 const REDUCED_SWAP_MS = 100 diff --git a/mobile/src/session/native-chat/input/use-mobile-native-chat-reply-scroll.ts b/mobile/src/session/native-chat/input/use-mobile-native-chat-reply-scroll.ts index b8434307178..bf70d53ada8 100644 --- a/mobile/src/session/native-chat/input/use-mobile-native-chat-reply-scroll.ts +++ b/mobile/src/session/native-chat/input/use-mobile-native-chat-reply-scroll.ts @@ -1,6 +1,6 @@ import { useRef } from 'react' import type { LayoutChangeEvent, ScrollViewInstance } from 'react-native' -import type { MobileTextInputHandle } from '../../../platform/text-input-handle' +import type { MobileTextInputHandle } from '../../../platform/text-input/text-input-handle' // Keep a focused reply in view when its card shrinks above the keyboard. export function useMobileNativeChatReplyScroll() { diff --git a/mobile/src/session/native-chat/transcript/MobileLinkActionSheet.tsx b/mobile/src/session/native-chat/transcript/MobileLinkActionSheet.tsx index 6ab27dc905c..6650453c091 100644 --- a/mobile/src/session/native-chat/transcript/MobileLinkActionSheet.tsx +++ b/mobile/src/session/native-chat/transcript/MobileLinkActionSheet.tsx @@ -1,9 +1,9 @@ import { useState } from 'react' import { Copy, ExternalLink, Globe } from 'lucide-react-native' import { ActionSheetModal, type ActionSheetAction } from '../../../components/ActionSheetModal' -import { openExternalLink } from '../../../platform/external-link' -import { useClipboardWriter } from '../../../platform/clipboard' -import { triggerError, triggerSuccess } from '../../../platform/haptics' +import { openExternalLink } from '../../../platform/external-links/external-link' +import { useClipboardWriter } from '../../../platform/clipboard/clipboard' +import { triggerError, triggerSuccess } from '../../../platform/haptics/haptics' import { useMobileTranslation } from '../../../i18n/use-mobile-translation' // Choices for a tapped chat link: Orca's browser on the host, the phone's browser, or the clipboard. diff --git a/mobile/src/session/native-chat/transcript/MobileNativeChatMessageActionsSheet.tsx b/mobile/src/session/native-chat/transcript/MobileNativeChatMessageActionsSheet.tsx index 56289faf059..4498fdfe8f3 100644 --- a/mobile/src/session/native-chat/transcript/MobileNativeChatMessageActionsSheet.tsx +++ b/mobile/src/session/native-chat/transcript/MobileNativeChatMessageActionsSheet.tsx @@ -4,7 +4,7 @@ import { Copy, TextSelect, X } from 'lucide-react-native' import type { NativeChatMessage } from '../../../../../src/shared/native-chat/native-chat-types' import { ActionSheetContent } from '../../../components/ActionSheetModal' import { BottomDrawer } from '../../../components/bottom-drawer/BottomDrawer' -import { useClipboardWriter } from '../../../platform/clipboard' +import { useClipboardWriter } from '../../../platform/clipboard/clipboard' import { useWindowSafeAreaInsets } from '../../../layout/side-safe-area' import { colors, spacing, typography, pressedStyle } from '../../../theme/mobile-theme' import { themedStyleSheet } from '../../../theme/themed-styles' diff --git a/mobile/src/session/native-chat/transcript/MobileNativeChatRowOverlaysHost.test.tsx b/mobile/src/session/native-chat/transcript/MobileNativeChatRowOverlaysHost.test.tsx index c6a5a70f8f6..4744881acfb 100644 --- a/mobile/src/session/native-chat/transcript/MobileNativeChatRowOverlaysHost.test.tsx +++ b/mobile/src/session/native-chat/transcript/MobileNativeChatRowOverlaysHost.test.tsx @@ -24,7 +24,7 @@ vi.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }) })) vi.mock('lucide-react-native', () => ({ X: 'X' })) -vi.mock('../../../platform/haptics', () => ({ triggerMediumImpact: vi.fn() })) +vi.mock('../../../platform/haptics/haptics', () => ({ triggerMediumImpact: vi.fn() })) vi.mock('./MobileNativeChatRewindDialog', () => ({ MobileNativeChatRewindDialog: () => null })) vi.mock('./MobileNativeChatMessageActionsSheet', () => ({ MobileNativeChatMessageActionsSheet: 'MessageActionsSheet' diff --git a/mobile/src/session/native-chat/transcript/MobileNativeChatRowOverlaysHost.tsx b/mobile/src/session/native-chat/transcript/MobileNativeChatRowOverlaysHost.tsx index 49f233a0224..feed20159c7 100644 --- a/mobile/src/session/native-chat/transcript/MobileNativeChatRowOverlaysHost.tsx +++ b/mobile/src/session/native-chat/transcript/MobileNativeChatRowOverlaysHost.tsx @@ -1,7 +1,7 @@ import { useCallback, useMemo } from 'react' import type { AgentSessionRewindSurface } from '../../../../../src/shared/agent-session/lifecycle/agent-session-rewind-request' import type { NativeChatMessage } from '../../../../../src/shared/native-chat/native-chat-types' -import { triggerMediumImpact } from '../../../platform/haptics' +import { triggerMediumImpact } from '../../../platform/haptics/haptics' import { MobileNativeChatImageViewer } from '../visual/MobileNativeChatImageViewer' import { MobileImageViewerActions } from '../visual/MobileImageViewerActions' import { MobileNativeChatMessageActionsSheet } from './MobileNativeChatMessageActionsSheet' diff --git a/mobile/src/session/native-chat/transcript/MobileNativeChatToolRun.test.tsx b/mobile/src/session/native-chat/transcript/MobileNativeChatToolRun.test.tsx index 5bd9a52d583..2971bcbcc83 100644 --- a/mobile/src/session/native-chat/transcript/MobileNativeChatToolRun.test.tsx +++ b/mobile/src/session/native-chat/transcript/MobileNativeChatToolRun.test.tsx @@ -63,7 +63,7 @@ vi.mock('lucide-react-native', () => ({ vi.mock('../../../components/MobileSourceCopyButton', () => ({ MobileSourceCopyButton: 'CopyButton' })) -vi.mock('../../../platform/external-link', () => ({ openExternalLink: vi.fn() })) +vi.mock('../../../platform/external-links/external-link', () => ({ openExternalLink: vi.fn() })) vi.mock('../../../components/inline-text-selection', () => ({ INLINE_TEXT_SELECTION: false })) import { ToolRun } from './MobileNativeChatToolRun' import { @@ -79,12 +79,6 @@ describe('ToolRun', () => { renderer = null }) - function openRun(tree: ReactTestRenderer): void { - act(() => { - tree.root.findAll((node) => String(node.type) === 'Pressable')[0]!.props.onPress() - }) - } - const textIn = (node: ReactTestInstance): string[] => node .findAll((node) => String(node.type) === 'Text') @@ -138,62 +132,5 @@ describe('ToolRun', () => { show({ mounted: true }) expect(textIn(renderer!.root)).toContain('a.ts') }) - - const box = (): ReactTestInstance => - renderer!.root.findAll((node) => String(node.type) === 'ScrollView')[0]! - const scrolled = (top: number) => ({ - nativeEvent: { - contentOffset: { y: top }, - contentSize: { height: 1_000 }, - layoutMeasurement: { height: 300 } - } - }) - - it('comes back where the reader left its box after the row is recycled', () => { - show({ mounted: true }) - press('ls') - act(() => { - box().props.onScroll(scrolled(120)) - }) - show({ mounted: false }) - show({ mounted: true }) - scrolls.to.mockClear() - act(() => { - box().props.onContentSizeChange() - }) - expect(scrolls.to).toHaveBeenCalledWith({ y: 120, animated: false }) - }) - }) - - it('does not save a follow for a later row opening when new work left the height unchanged', () => { - const live = (blocks: NativeChatBlock[]) => - createElement(ToolRun, { - blocks, - activeTurnIsWorking: true, - trailing: true - }) - const call: NativeChatBlock = { type: 'tool-call', name: 'Bash', input: { command: 'ls' } } - act(() => { - renderer = create(live([call])) - }) - openRun(renderer!) - const box = () => renderer!.root.findAll((node) => String(node.type) === 'ScrollView')[0]! - act(() => { - box().props.onContentSizeChange() - }) - act(() => { - renderer!.update(live([call, { type: 'tool-result', output: 'a.ts' }])) - }) - scrolls.toEnd.mockClear() - act(() => { - renderer!.root - .findAll((node) => String(node.type) === 'Pressable') - .findLast((node) => textIn(node).includes('ls'))! - .props.onPress() - }) - act(() => { - box().props.onContentSizeChange() - }) - expect(scrolls.toEnd).not.toHaveBeenCalled() }) }) diff --git a/mobile/src/session/native-chat/transcript/mobile-native-chat-reply-reveals.ts b/mobile/src/session/native-chat/transcript/mobile-native-chat-reply-reveals.ts index 793f68d1995..e30f74ed60b 100644 --- a/mobile/src/session/native-chat/transcript/mobile-native-chat-reply-reveals.ts +++ b/mobile/src/session/native-chat/transcript/mobile-native-chat-reply-reveals.ts @@ -6,7 +6,7 @@ import { trackNativeChatReplyRows, type NativeChatReplyReveals } from '../../../../../src/shared/native-chat/transcript/native-chat-reply-reveals' -import { useReducedMotionEnabled } from '../../../platform/use-reduced-motion' +import { useReducedMotionEnabled } from '../../../platform/accessibility/use-reduced-motion' // Retain reveal state above list remounts/view toggles so replies resume instead of replaying. export type MobileNativeChatReplyReveals = NativeChatReplyReveals & { reducedMotion: boolean } diff --git a/mobile/src/session/native-chat/visual/MobileImageViewerActions.tsx b/mobile/src/session/native-chat/visual/MobileImageViewerActions.tsx index 388794ede2e..6a2712ac632 100644 --- a/mobile/src/session/native-chat/visual/MobileImageViewerActions.tsx +++ b/mobile/src/session/native-chat/visual/MobileImageViewerActions.tsx @@ -9,8 +9,8 @@ import { BRIDGE_IMAGE_OPERATIONS, type BridgeImageOperation } from '../../../mobile-web-shell/bridge/bridge-image-verbs' -import { useImageActions } from '../../../platform/image-actions' -import { ImageActionRefusedError } from '../../../platform/image-actions-contract' +import { useImageActions } from '../../../platform/image-actions/image-actions' +import { ImageActionRefusedError } from '../../../platform/image-actions/image-actions-contract' import { colors, feedback } from '../../../theme/mobile-theme' import type { ImageViewerActionsProps } from './MobileNativeChatImageViewer' diff --git a/mobile/src/session/native-chat/visual/MobileNativeChatImageViewer.tsx b/mobile/src/session/native-chat/visual/MobileNativeChatImageViewer.tsx index 89a6486544d..ca0f1eee382 100644 --- a/mobile/src/session/native-chat/visual/MobileNativeChatImageViewer.tsx +++ b/mobile/src/session/native-chat/visual/MobileNativeChatImageViewer.tsx @@ -17,7 +17,7 @@ import { useMobileTranslation } from '../../../i18n/use-mobile-translation' import type { PanZoomSize } from '../../../../../src/shared/pan-zoom-view' import { MobilePanZoomImage } from '../../../components/MobilePanZoomImage' import { REDUCED_MOTION_FADE_MS, SHEET_CURVE } from '../../../components/sheet-motion-values' -import { useReducedMotionEnabled } from '../../../platform/use-reduced-motion' +import { useReducedMotionEnabled } from '../../../platform/accessibility/use-reduced-motion' const VIEWER_EASING = Easing.bezier(...SHEET_CURVE) const VIEWER_OPEN_MS = 250 diff --git a/mobile/src/session/tabs/MobileSessionTabBar.tsx b/mobile/src/session/tabs/MobileSessionTabBar.tsx index bd630336b30..324de33fd37 100644 --- a/mobile/src/session/tabs/MobileSessionTabBar.tsx +++ b/mobile/src/session/tabs/MobileSessionTabBar.tsx @@ -10,14 +10,14 @@ import Animated, { withSequence, withTiming } from 'react-native-reanimated' -import { triggerMediumImpact, triggerSelection } from '../../platform/haptics' +import { triggerMediumImpact, triggerSelection } from '../../platform/haptics/haptics' import { useMobileTranslation } from '../../i18n/use-mobile-translation' import { AgentStateDot } from '../../components/AgentStateDot' import { ArrivalFade } from '../../components/ArrivalFade' import { useDrawerReducedMotion } from '../../components/bottom-drawer/bottom-drawer-constants' import { SHEET_CURVE } from '../../components/sheet-motion-values' -import { useReducedMotionEnabled } from '../../platform/use-reduced-motion' -import { useKeyboardPersistingTaps } from '../../platform/keyboard-persisting-taps' +import { useReducedMotionEnabled } from '../../platform/accessibility/use-reduced-motion' +import { useKeyboardPersistingTaps } from '../../platform/keyboard/keyboard-persisting-taps' import { useNow } from '../../hooks/use-now' import { agentStateLabel, type AgentDotState } from '../../worktree/agent-row-display' import { colors, feedback, radii, spacing } from '../../theme/mobile-theme' diff --git a/mobile/src/session/tabs/use-mobile-session-tab-switching.ts b/mobile/src/session/tabs/use-mobile-session-tab-switching.ts index 56472266ef8..aab5fc76f65 100644 --- a/mobile/src/session/tabs/use-mobile-session-tab-switching.ts +++ b/mobile/src/session/tabs/use-mobile-session-tab-switching.ts @@ -1,7 +1,7 @@ import { keepsTerminalPaneVisible } from '../../terminal/session/mobile-terminal-pane-layout' import { useCallback } from 'react' import { STARTUP_CWD_FALLBACK_SENTENCE } from '../../../../src/shared/startup-cwd-fallback' -import { triggerSelection } from '../../platform/haptics' +import { triggerSelection } from '../../platform/haptics/haptics' import type { RpcResponse } from '../../transport/rpc/rpc-wire-types' import { isCachedActiveDocumentRetap } from '../../files/session-documents/mobile-session-active-document-retap' import { activateMobileSessionTab } from './mobile-session-tab-activation' diff --git a/mobile/src/session/use-mobile-session-dictation.ts b/mobile/src/session/use-mobile-session-dictation.ts index 4bbdfad37d7..578646a336d 100644 --- a/mobile/src/session/use-mobile-session-dictation.ts +++ b/mobile/src/session/use-mobile-session-dictation.ts @@ -9,8 +9,8 @@ import { import { useFocusEffect, useNavigation } from 'expo-router' import { HOST_SESSION_ROUTE } from '../navigation/host-stack-navigation' import { useMobileSessionDictationSetup } from './use-mobile-session-dictation-setup' -import { triggerError } from '../platform/haptics' -import { useClipboardWriter } from '../platform/clipboard' +import { triggerError } from '../platform/haptics/haptics' +import { useClipboardWriter } from '../platform/clipboard/clipboard' import { useMobileDictationDestination, useMobileDictationPreviews diff --git a/mobile/src/session/use-mobile-session-lifecycle.ts b/mobile/src/session/use-mobile-session-lifecycle.ts index a86475eeda6..827381e268f 100644 --- a/mobile/src/session/use-mobile-session-lifecycle.ts +++ b/mobile/src/session/use-mobile-session-lifecycle.ts @@ -1,4 +1,4 @@ -import { useShellStorage } from '../platform/shell-storage' +import { useShellStorage } from '../platform/shell-storage/shell-storage' import { useDirectionPadAvailable } from '../layout/use-shell-side-rail' import { settleSessionPreferenceRead } from './session-preference-read' import { useEffect, useRef, useCallback, useState } from 'react' diff --git a/mobile/src/session/use-mobile-session-preference-focus.ts b/mobile/src/session/use-mobile-session-preference-focus.ts index c58786ee2b1..367a2707832 100644 --- a/mobile/src/session/use-mobile-session-preference-focus.ts +++ b/mobile/src/session/use-mobile-session-preference-focus.ts @@ -1,4 +1,4 @@ -import { useShellStorage } from '../platform/shell-storage' +import { useShellStorage } from '../platform/shell-storage/shell-storage' import { settleSessionPreferenceRead } from './session-preference-read' import { useCallback, useState } from 'react' import { useFocusEffect } from 'expo-router' diff --git a/mobile/src/session/use-mobile-session-presentation.ts b/mobile/src/session/use-mobile-session-presentation.ts index bb0c0901ab6..ec60508398d 100644 --- a/mobile/src/session/use-mobile-session-presentation.ts +++ b/mobile/src/session/use-mobile-session-presentation.ts @@ -9,7 +9,7 @@ import { useMobileTranslation } from '../i18n/use-mobile-translation' import { useInitialSessionTerminalAutoCreate } from '../terminal/session/use-initial-session-terminal-autocreate' import { MOBILE_SESSION_STATUS_LABELS } from '../transport/connection/mobile-session-connection-copy' import type { MobileSessionBulkCloseModel } from './tabs/use-mobile-session-bulk-close' -import { useReducedMotionEnabled } from '../platform/use-reduced-motion' +import { useReducedMotionEnabled } from '../platform/accessibility/use-reduced-motion' import { useDirectionPadAvailable, useShellSideRail } from '../layout/use-shell-side-rail' import { useCalmConnectionState } from '../transport/connection/calm-connection-state' diff --git a/mobile/src/settings/voice-transcribe-on-section.tsx b/mobile/src/settings/voice-transcribe-on-section.tsx index d12f0073842..695a122e19b 100644 --- a/mobile/src/settings/voice-transcribe-on-section.tsx +++ b/mobile/src/settings/voice-transcribe-on-section.tsx @@ -12,8 +12,8 @@ import type { Translate } from '../i18n/mobile-i18n' import { getUiIntlLocale } from '../i18n/ui-intl-locale' import { useMobileTranslation } from '../i18n/use-mobile-translation' import type { DictationEnginePreference } from '../mobile-web-shell/bridge/bridge-speech-verbs' -import { isSpeechBusyError } from '../platform/native-speech' -import { onDeviceSpeechLocale } from '../platform/on-device-speech-locale' +import { isSpeechBusyError } from '../platform/speech/native-speech' +import { onDeviceSpeechLocale } from '../platform/speech/on-device-speech-locale' import { DEFAULT_DICTATION_ENGINE_PREFERENCE, loadDictationEnginePreference, diff --git a/mobile/src/source-control/MobileCommitDetailPage.tsx b/mobile/src/source-control/MobileCommitDetailPage.tsx index 043836947d0..3570ef1d95a 100644 --- a/mobile/src/source-control/MobileCommitDetailPage.tsx +++ b/mobile/src/source-control/MobileCommitDetailPage.tsx @@ -18,9 +18,9 @@ import { themedStyleSheet } from '../theme/themed-styles' import { useMobileColorScheme } from '../theme/use-mobile-color-scheme' import { useMobileTranslation } from '../i18n/use-mobile-translation' import { translate } from '../i18n/mobile-i18n' -import { useClipboardWriter } from '../platform/clipboard' -import { openExternalLink } from '../platform/external-link' -import { triggerSuccess } from '../platform/haptics' +import { useClipboardWriter } from '../platform/clipboard/clipboard' +import { openExternalLink } from '../platform/external-links/external-link' +import { triggerSuccess } from '../platform/haptics/haptics' import type { RpcClient } from '../transport/rpc/rpc-client-contract' import type { ConnectionState } from '../transport/connection/connection-state' import { STATUS_LABELS } from '../../../src/shared/git/status/git-status-labels' diff --git a/mobile/src/source-control/MobileCommitsScreen.test.tsx b/mobile/src/source-control/MobileCommitsScreen.test.tsx index 579ab21745b..36162642745 100644 --- a/mobile/src/source-control/MobileCommitsScreen.test.tsx +++ b/mobile/src/source-control/MobileCommitsScreen.test.tsx @@ -81,9 +81,11 @@ vi.mock('../components/list/ListGroup', async () => { SectionHeader: ({ title }: { title: string }) => h('Text', null, title) } }) -vi.mock('../platform/clipboard', () => ({ useClipboardWriter: () => ({ writeText: vi.fn() }) })) -vi.mock('../platform/external-link', () => ({ openExternalLink: vi.fn() })) -vi.mock('../platform/haptics', () => ({ triggerSuccess: vi.fn() })) +vi.mock('../platform/clipboard/clipboard', () => ({ + useClipboardWriter: () => ({ writeText: vi.fn() }) +})) +vi.mock('../platform/external-links/external-link', () => ({ openExternalLink: vi.fn() })) +vi.mock('../platform/haptics/haptics', () => ({ triggerSuccess: vi.fn() })) const transport = vi.hoisted( (): { forceReconnect: ((hostId: string) => Promise) | null } => ({ forceReconnect: () => Promise.resolve() diff --git a/mobile/src/source-control/MobileSourceControlContent.tsx b/mobile/src/source-control/MobileSourceControlContent.tsx index 70aed9111f6..36a901ee4a2 100644 --- a/mobile/src/source-control/MobileSourceControlContent.tsx +++ b/mobile/src/source-control/MobileSourceControlContent.tsx @@ -3,7 +3,7 @@ import { ActivityIndicator, Animated, Easing, Text, View } from 'react-native' import { colors, spacing } from '../theme/mobile-theme' import { useMobileColorScheme } from '../theme/use-mobile-color-scheme' import { useMobileTranslation } from '../i18n/use-mobile-translation' -import { useReducedMotionEnabled } from '../platform/use-reduced-motion' +import { useReducedMotionEnabled } from '../platform/accessibility/use-reduced-motion' import { SHEET_CURVE } from '../components/sheet-motion-values' import { MobileSourceControlChangesHeader } from './MobileSourceControlChangesHeader' import { MobileCommitFailurePanel } from './MobileCommitFailurePanel' diff --git a/mobile/src/source-control/folder/use-folder-create-flow.test.tsx b/mobile/src/source-control/folder/use-folder-create-flow.test.tsx index 91ec50c2ecd..94ddbd94cc8 100644 --- a/mobile/src/source-control/folder/use-folder-create-flow.test.tsx +++ b/mobile/src/source-control/folder/use-folder-create-flow.test.tsx @@ -38,11 +38,11 @@ vi.mock('../../components/TransientToast', () => ({ showToast: (text: string) => seen.toasts.push(text) }) })) -vi.mock('../../platform/haptics', () => ({ +vi.mock('../../platform/haptics/haptics', () => ({ triggerSuccess: () => (seen.haptics += 1), triggerError: () => (seen.haptics += 1) })) -vi.mock('../../platform/external-link', () => ({ openExternalLink: () => {} })) +vi.mock('../../platform/external-links/external-link', () => ({ openExternalLink: () => {} })) vi.mock('../../transport/connection/host-client-hooks', () => ({ useHostClient: () => ({ client: null, state: 'connected' }) })) diff --git a/mobile/src/source-control/folder/use-folder-create-flow.tsx b/mobile/src/source-control/folder/use-folder-create-flow.tsx index 899a510ea38..c2e4651e615 100644 --- a/mobile/src/source-control/folder/use-folder-create-flow.tsx +++ b/mobile/src/source-control/folder/use-folder-create-flow.tsx @@ -6,8 +6,8 @@ import { NewWorktreeModalController } from '../../components/NewWorktreeModalCon import type { MobileWorkspaceRepo } from '../../components/new-worktree-modal-types' import { TransientToast, useTransientToast } from '../../components/TransientToast' import { useMobileTranslation } from '../../i18n/use-mobile-translation' -import { openExternalLink } from '../../platform/external-link' -import { triggerError, triggerSuccess } from '../../platform/haptics' +import { openExternalLink } from '../../platform/external-links/external-link' +import { triggerError, triggerSuccess } from '../../platform/haptics/haptics' import { useHostClient } from '../../transport/connection/host-client-hooks' import type { RpcClient } from '../../transport/rpc/rpc-client-contract' import type { FolderListRow } from './mobile-folder-attached-worktrees' diff --git a/mobile/src/source-control/mobile-source-control-oversized-stage-all.test.tsx b/mobile/src/source-control/mobile-source-control-oversized-stage-all.test.tsx deleted file mode 100644 index 680730b022a..00000000000 --- a/mobile/src/source-control/mobile-source-control-oversized-stage-all.test.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import { createElement, useRef } from 'react' -import { act, create } from 'react-test-renderer' -import { describe, expect, it, vi } from 'vitest' -import { createFakeBridgePortPair } from '../mobile-web-shell/bridge/bridge-port-pair-test-harness' -// Mock native Flow/haptics imports; retain the real bridge-to-error path. -vi.mock('react-native', () => ({ - Platform: { OS: 'ios', select: (options: Record) => options.ios }, - StyleSheet: { create: (value: unknown) => value, hairlineWidth: 1 } -})) -// Mock expo-router through the native routing seam to obtain a checked RouteHandoff. -vi.mock('expo-router', () => ({ - useRouter: () => ({ - back: () => undefined, - push: () => undefined, - replace: () => undefined, - navigate: () => undefined, - dismissTo: () => undefined, - prefetch: () => undefined, - canGoBack: () => false, - setParams: () => undefined - }) -})) -vi.mock('../platform/haptics', () => ({ - triggerSuccess: () => undefined, - triggerError: () => undefined, - triggerSelection: () => undefined, - triggerWarning: () => undefined, - triggerImpact: () => undefined -})) - -import { useRouteHandoff } from '../navigation/route-handoff' -import { useMobileGitRequests } from './use-mobile-git-requests' -import { useMobileSourceControlRunners } from './use-mobile-source-control-runners' - -// Exercise the real oversized-frame refusal through the screen’s runner: the old shell silently dropped it and left busy state forever. -// Checking only the bridge or only a hand-thrown runner error would miss whether the screen actually displays this refusal. - -const PATH_COUNT = 12_000 -const STAGEABLE_PATHS = Array.from( - { length: PATH_COUNT }, - (_, index) => `packages/app/src/features/some/deeply/nested/module/file-${index}-abcdefghij.ts` -) - -function idleRunnerParams() { - return { - hostId: 'host-a', - worktreeId: 'wt-1', - status: null, - branchLabel: 'main', - commitMessage: '', - stagedEntries: [], - generatingMessage: false, - unstageablePaths: [], - sendCommitRequest: vi.fn(), - runGitSyncSteps: vi.fn(), - loadStatus: vi.fn().mockResolvedValue(true), - setBusyAction: vi.fn(), - setCommitMessage: vi.fn(), - setGeneratingMessage: vi.fn(), - setShowActionSheet: vi.fn(), - setLocalBranches: vi.fn(), - setShowBranchPicker: vi.fn(), - setCreatedPrUrl: vi.fn(), - setCreatedPrWarning: vi.fn(), - recordCommitFailure: vi.fn() - } -} - -describe('stage-all over the outbound frame cap', () => { - it('reports the oversized action and suggests fewer files, then stops being busy', async () => { - const pair = createFakeBridgePortPair() - await pair.flush() - const setActionError = vi.fn() - const setBusyAction = vi.fn() - - // Use a holder: TypeScript cannot prove Probe ran and would keep a local binding narrowed to null. - const captured: { stageAll: (() => Promise) | null } = { stageAll: null } - function Probe(): null { - const router = useRouteHandoff() - const mountedRef = useRef(true) - const busyActionRef = useRef(null) - const { sendGitRequest } = useMobileGitRequests({ - client: pair.client, - connState: 'connected', - worktreeId: 'wt-1' - }) - captured.stageAll = useMobileSourceControlRunners({ - ...idleRunnerParams(), - client: pair.client, - router, - stageablePaths: STAGEABLE_PATHS, - sendGitRequest, - mountedRef, - busyActionRef, - setBusyAction, - setActionError - }).stageAll - return null - } - await act(async () => { - create(createElement(Probe)) - }) - const run = captured.stageAll - if (!run) { - throw new Error('the runners hook never rendered') - } - await act(async () => { - await run() - }) - - const message = setActionError.mock.calls.map(([value]) => value).findLast(Boolean) - expect(message).toBeTruthy() - expect(message).toMatch(/[a-z]{4}/) - expect(message).not.toMatch(/^bridge_/) - expect(message).toContain('fewer files') - expect(setBusyAction.mock.calls.map(([value]) => value)).toEqual(['stage-all', null]) - }) -}) diff --git a/mobile/src/source-control/review/use-mobile-diff-review-comment-actions.ts b/mobile/src/source-control/review/use-mobile-diff-review-comment-actions.ts index a9d92e9dfd2..fc3e699a3d8 100644 --- a/mobile/src/source-control/review/use-mobile-diff-review-comment-actions.ts +++ b/mobile/src/source-control/review/use-mobile-diff-review-comment-actions.ts @@ -1,7 +1,7 @@ import { useCallback, useRef, type Dispatch, type SetStateAction } from 'react' import { translate } from '../../i18n/mobile-i18n' import type { DiffComment, MobileDiffReviewState } from '../../../../src/shared/diff-comment-types' -import { triggerError, triggerSuccess } from '../../platform/haptics' +import { triggerError, triggerSuccess } from '../../platform/haptics/haptics' import type { ConnectionState } from '../../transport/connection/connection-state' import type { RpcClient } from '../../transport/rpc/rpc-client-contract' import { interpretOrThrowRefusalMessage } from '../../transport/rpc/rpc-refusal-message' diff --git a/mobile/src/source-control/review/use-mobile-diff-review-git-actions.ts b/mobile/src/source-control/review/use-mobile-diff-review-git-actions.ts index 4096bdeec05..23f8deb4a01 100644 --- a/mobile/src/source-control/review/use-mobile-diff-review-git-actions.ts +++ b/mobile/src/source-control/review/use-mobile-diff-review-git-actions.ts @@ -8,7 +8,7 @@ import { reviewGitBulkUnstage } from './mobile-diff-review-git-operations' import { getUnstageablePaths } from '../mobile-git-status' -import { triggerError, triggerSuccess } from '../../platform/haptics' +import { triggerError, triggerSuccess } from '../../platform/haptics/haptics' import type { MobileDiffReviewQueueItem } from './mobile-diff-review-queue' import type { GitMutationMethod } from './mobile-diff-review-screen-model' diff --git a/mobile/src/source-control/review/use-mobile-diff-review-github.ts b/mobile/src/source-control/review/use-mobile-diff-review-github.ts index 7b3ad054e98..a21650f7e12 100644 --- a/mobile/src/source-control/review/use-mobile-diff-review-github.ts +++ b/mobile/src/source-control/review/use-mobile-diff-review-github.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { translate } from '../../i18n/mobile-i18n' import type { ConnectionState } from '../../transport/connection/connection-state' import type { RpcClient } from '../../transport/rpc/rpc-client-contract' -import { triggerError, triggerSuccess } from '../../platform/haptics' +import { triggerError, triggerSuccess } from '../../platform/haptics/haptics' import { fetchSetPRFileViewed } from './github-pr-review-line-mutations' import { useHostMutation } from '../../query/host-mutation' import { prCommentTarget, prCommentWrite } from './pr-comment-writes' diff --git a/mobile/src/source-control/review/use-mobile-diff-review-send-actions.ts b/mobile/src/source-control/review/use-mobile-diff-review-send-actions.ts index 503eec21acc..d2317edbbcf 100644 --- a/mobile/src/source-control/review/use-mobile-diff-review-send-actions.ts +++ b/mobile/src/source-control/review/use-mobile-diff-review-send-actions.ts @@ -3,8 +3,8 @@ import { translate } from '../../i18n/mobile-i18n' import type { DiffComment, MobileDiffReviewState } from '../../../../src/shared/diff-comment-types' import type { ConnectionState } from '../../transport/connection/connection-state' import type { RpcClient } from '../../transport/rpc/rpc-client-contract' -import { useClipboardWriter } from '../../platform/clipboard' -import { triggerSuccess } from '../../platform/haptics' +import { useClipboardWriter } from '../../platform/clipboard/clipboard' +import { triggerSuccess } from '../../platform/haptics/haptics' import { formatDiffComments } from '../../../../src/shared/diff-comments-format' import { clearSentMobileDiffComments, markMobileDiffCommentsSent } from './mobile-diff-comment-edit' import { reviewTerminalListRead, reviewTerminalSendRun } from './mobile-review-terminal-operations' diff --git a/mobile/src/source-control/review/use-mobile-pr-ai-triage.ts b/mobile/src/source-control/review/use-mobile-pr-ai-triage.ts index 27b9135a907..74a89e7c149 100644 --- a/mobile/src/source-control/review/use-mobile-pr-ai-triage.ts +++ b/mobile/src/source-control/review/use-mobile-pr-ai-triage.ts @@ -1,7 +1,7 @@ import { useCallback, useRef, useState } from 'react' import type { ConnectionState } from '../../transport/connection/connection-state' import type { RpcClient } from '../../transport/rpc/rpc-client-contract' -import { triggerError, triggerSuccess } from '../../platform/haptics' +import { triggerError, triggerSuccess } from '../../platform/haptics/haptics' import { useMobileTranslation } from '../../i18n/use-mobile-translation' import { launchAgentWithPrompt, promptedLaunchNotice } from './pr-ai-triage-launch' import type { LaunchedAgentOpens } from '../../session/agent-launch/launched-agent-opener' diff --git a/mobile/src/source-control/review/use-mobile-pr-comment-actions.ts b/mobile/src/source-control/review/use-mobile-pr-comment-actions.ts index de7ca0658c3..ca7c5efd7c3 100644 --- a/mobile/src/source-control/review/use-mobile-pr-comment-actions.ts +++ b/mobile/src/source-control/review/use-mobile-pr-comment-actions.ts @@ -4,7 +4,7 @@ import type { ConnectionState } from '../../transport/connection/connection-stat import type { RpcClient } from '../../transport/rpc/rpc-client-contract' import type { GitHubPrRepoSlug } from './github-pr-repo-slug' import { useHostMutation } from '../../query/host-mutation' -import { triggerError, triggerSuccess } from '../../platform/haptics' +import { triggerError, triggerSuccess } from '../../platform/haptics/haptics' import { useMobileTranslation } from '../../i18n/use-mobile-translation' import { buildAddRootCommentParams, diff --git a/mobile/src/source-control/review/use-mobile-pr-text-action.ts b/mobile/src/source-control/review/use-mobile-pr-text-action.ts index c77b49bc46f..94b992bea8f 100644 --- a/mobile/src/source-control/review/use-mobile-pr-text-action.ts +++ b/mobile/src/source-control/review/use-mobile-pr-text-action.ts @@ -6,7 +6,7 @@ import type { RpcClient } from '../../transport/rpc/rpc-client-contract' import type { GitHubPrRepoSlug } from './github-pr-repo-slug' import { defineHostMutation, useHostMutation } from '../../query/host-mutation' import { fetchUpdatePRBody, fetchUpdatePRTitle } from './github-pr-mutations' -import { triggerError, triggerSuccess } from '../../platform/haptics' +import { triggerError, triggerSuccess } from '../../platform/haptics/haptics' import { useMobileTranslation } from '../../i18n/use-mobile-translation' import { buildUpdatePRBodyParams, diff --git a/mobile/src/source-control/review/use-mobile-session-diff-comments.ts b/mobile/src/source-control/review/use-mobile-session-diff-comments.ts index 451d6c0a667..bcd6c9efbff 100644 --- a/mobile/src/source-control/review/use-mobile-session-diff-comments.ts +++ b/mobile/src/source-control/review/use-mobile-session-diff-comments.ts @@ -1,12 +1,12 @@ import { useEffect, useCallback, useMemo, useRef, useState } from 'react' import { formatDiffComments } from '../../../../src/shared/diff-comments-format' import { translate } from '../../i18n/mobile-i18n' -import { useClipboardWriter } from '../../platform/clipboard' +import { useClipboardWriter } from '../../platform/clipboard/clipboard' import { interpretOrThrowRefusalMessage } from '../../transport/rpc/rpc-refusal-message' import type { SendRequestOptions } from '../../transport/rpc/unvalidated-rpc-request-port' import { sessionWorktreeRecordRead } from '../../worktree/session-worktree-record-operations' import { sessionWorktreeNotesWrite } from './mobile-review-notes-operations' -import { triggerSelection, triggerSuccess, triggerError } from '../../platform/haptics' +import { triggerSelection, triggerSuccess, triggerError } from '../../platform/haptics/haptics' import { addMobileDiffComment, normalizeMobileDiffComments, diff --git a/mobile/src/source-control/use-mobile-commit-failure-recovery.ts b/mobile/src/source-control/use-mobile-commit-failure-recovery.ts index 3defee675d8..045c78c8de9 100644 --- a/mobile/src/source-control/use-mobile-commit-failure-recovery.ts +++ b/mobile/src/source-control/use-mobile-commit-failure-recovery.ts @@ -1,7 +1,7 @@ import { useCallback, useMemo, useRef, useState } from 'react' import type { ConnectionState } from '../transport/connection/connection-state' import type { RpcClient } from '../transport/rpc/rpc-client-contract' -import { triggerError, triggerSuccess } from '../platform/haptics' +import { triggerError, triggerSuccess } from '../platform/haptics/haptics' import { translate } from '../i18n/mobile-i18n' import { useHostProtocolGates } from '../components/host-protocol-gates-context' import { launchAgentWithPrompt, promptedLaunchNotice } from './review/pr-ai-triage-launch' diff --git a/mobile/src/source-control/use-mobile-commit-message-generation.test.ts b/mobile/src/source-control/use-mobile-commit-message-generation.test.ts index 6714b1255c7..480a48333b9 100644 --- a/mobile/src/source-control/use-mobile-commit-message-generation.test.ts +++ b/mobile/src/source-control/use-mobile-commit-message-generation.test.ts @@ -3,7 +3,7 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, describe, expect, it, vi } from 'vitest' import type { MobileGenerateCommitMessageResult } from './mobile-commit-message-ai' -vi.mock('../platform/haptics', () => ({ triggerError: vi.fn(), triggerSuccess: vi.fn() })) +vi.mock('../platform/haptics/haptics', () => ({ triggerError: vi.fn(), triggerSuccess: vi.fn() })) const reply = vi.hoisted(() => { const held: { resolve: ((result: MobileGenerateCommitMessageResult) => void) | null diff --git a/mobile/src/source-control/use-mobile-commit-message-generation.ts b/mobile/src/source-control/use-mobile-commit-message-generation.ts index c6e31b7c1c8..0f70a938a4d 100644 --- a/mobile/src/source-control/use-mobile-commit-message-generation.ts +++ b/mobile/src/source-control/use-mobile-commit-message-generation.ts @@ -7,7 +7,7 @@ import { type SetStateAction } from 'react' import type { RpcClient } from '../transport/rpc/rpc-client-contract' -import { triggerError, triggerSuccess } from '../platform/haptics' +import { triggerError, triggerSuccess } from '../platform/haptics/haptics' import { translate } from '../i18n/mobile-i18n' import { cancelMobileCommitMessage, requestMobileCommitMessage } from './mobile-commit-message-ai' import { textGenerationErrorText } from './text-generation-failure-copy' diff --git a/mobile/src/source-control/use-mobile-source-control-commit-runners.ts b/mobile/src/source-control/use-mobile-source-control-commit-runners.ts index 48bcfadf5bd..224c5c611f4 100644 --- a/mobile/src/source-control/use-mobile-source-control-commit-runners.ts +++ b/mobile/src/source-control/use-mobile-source-control-commit-runners.ts @@ -1,6 +1,6 @@ import { useCallback, type Dispatch, type MutableRefObject, type SetStateAction } from 'react' import { translate } from '../i18n/mobile-i18n' -import { triggerError, triggerSuccess } from '../platform/haptics' +import { triggerError, triggerSuccess } from '../platform/haptics/haptics' import type { LoadStatusOptions } from './mobile-source-control-screen-state' import type { MobileCommitFailureRecovery, diff --git a/mobile/src/source-control/use-mobile-source-control-keyboard-lift.ts b/mobile/src/source-control/use-mobile-source-control-keyboard-lift.ts index 233d4cb078c..0030eb46a22 100644 --- a/mobile/src/source-control/use-mobile-source-control-keyboard-lift.ts +++ b/mobile/src/source-control/use-mobile-source-control-keyboard-lift.ts @@ -1,4 +1,4 @@ -import { useKeyboardOcclusion } from '../platform/keyboard-occlusion' +import { useKeyboardOcclusion } from '../platform/keyboard/keyboard-occlusion' // Use the platform occlusion seam: react-native-web Keyboard never fires, so pages measure visualViewport. export function useMobileSourceControlKeyboardLift(): number { diff --git a/mobile/src/source-control/use-mobile-source-control-runners.ts b/mobile/src/source-control/use-mobile-source-control-runners.ts index 956899af2a6..5a0700c7c1c 100644 --- a/mobile/src/source-control/use-mobile-source-control-runners.ts +++ b/mobile/src/source-control/use-mobile-source-control-runners.ts @@ -2,7 +2,7 @@ import { useCallback, type Dispatch, type MutableRefObject, type SetStateAction import { translate } from '../i18n/mobile-i18n' import type { RouteHandoff } from '../navigation/route-handoff' import type { RpcClient } from '../transport/rpc/rpc-client-contract' -import { triggerError, triggerSuccess } from '../platform/haptics' +import { triggerError, triggerSuccess } from '../platform/haptics/haptics' import { useMobileCommitMessageGeneration } from './use-mobile-commit-message-generation' import { useMobileSourceControlCommitRunners } from './use-mobile-source-control-commit-runners' import { useMobileSourceControlActionSheetRunners } from './use-mobile-source-control-action-sheet-runners' diff --git a/mobile/src/tasks/item-detail/mobile-tasks-item-actions.tsx b/mobile/src/tasks/item-detail/mobile-tasks-item-actions.tsx index 0e8cc216012..6626aa1e05d 100644 --- a/mobile/src/tasks/item-detail/mobile-tasks-item-actions.tsx +++ b/mobile/src/tasks/item-detail/mobile-tasks-item-actions.tsx @@ -12,7 +12,7 @@ import { MoreHorizontal } from 'lucide-react-native' import { colors } from '../../theme/mobile-theme' -import { openExternalLink } from '../../platform/external-link' +import { openExternalLink } from '../../platform/external-links/external-link' import { styles } from '../mobile-tasks-legacy-styles' import { taskExternalOpenLabel, diff --git a/mobile/src/tasks/item-detail/mobile-tasks-item-detail-content.tsx b/mobile/src/tasks/item-detail/mobile-tasks-item-detail-content.tsx index 0885b5550e4..5b0bf1b61b7 100644 --- a/mobile/src/tasks/item-detail/mobile-tasks-item-detail-content.tsx +++ b/mobile/src/tasks/item-detail/mobile-tasks-item-detail-content.tsx @@ -3,7 +3,7 @@ import { translate } from '../../i18n/mobile-i18n' import { isBrokenCheck } from '../../../../src/shared/source-control/checks/pr-checks-fix-prompt' import { View, Text, Pressable } from 'react-native' import { colors, pressedStyle } from '../../theme/mobile-theme' -import { openExternalLink } from '../../platform/external-link' +import { openExternalLink } from '../../platform/external-links/external-link' import { ExternalLink } from 'lucide-react-native' import { TextField } from '../../components/composer-input/TextField' import { styles, getGitLabPipelineStatusStyle } from '../mobile-tasks-legacy-styles' diff --git a/mobile/src/tasks/item-detail/use-mobile-tasks-hosted-comment-review-actions.tsx b/mobile/src/tasks/item-detail/use-mobile-tasks-hosted-comment-review-actions.tsx index ecbac3e8f7b..afbd07cb4f1 100644 --- a/mobile/src/tasks/item-detail/use-mobile-tasks-hosted-comment-review-actions.tsx +++ b/mobile/src/tasks/item-detail/use-mobile-tasks-hosted-comment-review-actions.tsx @@ -1,6 +1,6 @@ import { refreshMobileTaskPRChecks } from '../hosted-review/mobile-task-pr-check-refresh' import { githubTaskKey, sameGitHubTask, requireGitHubTaskTarget } from './github-task-identity' -import { useClipboardWriter } from '../../platform/clipboard' +import { useClipboardWriter } from '../../platform/clipboard/clipboard' import type { HostedMetadataActionsModel } from './use-mobile-tasks-hosted-metadata-actions' import { scheduleMobileTaskCopyFeedbackReset } from '../screen/mobile-task-copy-feedback-timer' import { useCallback } from 'react' diff --git a/mobile/src/tasks/project-board/mobile-tasks-project-detail-drawer.tsx b/mobile/src/tasks/project-board/mobile-tasks-project-detail-drawer.tsx index 134bddf4bed..900c88630b5 100644 --- a/mobile/src/tasks/project-board/mobile-tasks-project-detail-drawer.tsx +++ b/mobile/src/tasks/project-board/mobile-tasks-project-detail-drawer.tsx @@ -2,7 +2,7 @@ import type { ConnectionPresentationModel } from '../screen-state/use-mobile-tas import { translate } from '../../i18n/mobile-i18n' import { BottomDrawer } from '../../components/bottom-drawer/BottomDrawer' import { View, Text, Pressable, ActivityIndicator } from 'react-native' -import { openExternalLink } from '../../platform/external-link' +import { openExternalLink } from '../../platform/external-links/external-link' import { ExternalLink, Copy, Plus, RefreshCw, X, GitBranch } from 'lucide-react-native' import { colors, pressedStyle } from '../../theme/mobile-theme' import { TaskProviderLogo } from '../../components/TaskProviderLogo' diff --git a/mobile/src/tasks/project-board/mobile-tasks-project-review-panels.tsx b/mobile/src/tasks/project-board/mobile-tasks-project-review-panels.tsx index dabf6ac39af..c175c4c7c6a 100644 --- a/mobile/src/tasks/project-board/mobile-tasks-project-review-panels.tsx +++ b/mobile/src/tasks/project-board/mobile-tasks-project-review-panels.tsx @@ -13,7 +13,7 @@ import { View, Text, ActivityIndicator, Pressable } from 'react-native' import { colors, pressedStyle } from '../../theme/mobile-theme' import { Check, ExternalLink } from 'lucide-react-native' import { TextField } from '../../components/composer-input/TextField' -import { openExternalLink } from '../../platform/external-link' +import { openExternalLink } from '../../platform/external-links/external-link' import { styles } from '../mobile-tasks-legacy-styles' export function renderMobileTasksProjectReviewPanels(model: ConnectionPresentationModel) { diff --git a/mobile/src/tasks/screen/mobile-tasks-filter-sheet.tsx b/mobile/src/tasks/screen/mobile-tasks-filter-sheet.tsx index d0e8ed6e75b..ceedb8f0816 100644 --- a/mobile/src/tasks/screen/mobile-tasks-filter-sheet.tsx +++ b/mobile/src/tasks/screen/mobile-tasks-filter-sheet.tsx @@ -2,7 +2,7 @@ import type { ReactNode } from 'react' import type { ConnectionPresentationModel } from '../screen-state/use-mobile-tasks-connection-presentation' import { translate as t } from '../../i18n/mobile-i18n' import { BottomDrawer } from '../../components/bottom-drawer/BottomDrawer' -import { openExternalLink } from '../../platform/external-link' +import { openExternalLink } from '../../platform/external-links/external-link' import { Text, View } from 'react-native' import type { PickerOption } from '../../components/PickerModal' import { styles } from '../mobile-tasks-legacy-styles' diff --git a/mobile/src/tasks/task-list/mobile-tasks-provider-item-list.tsx b/mobile/src/tasks/task-list/mobile-tasks-provider-item-list.tsx index 1bc3efbaa89..4b00a279a8b 100644 --- a/mobile/src/tasks/task-list/mobile-tasks-provider-item-list.tsx +++ b/mobile/src/tasks/task-list/mobile-tasks-provider-item-list.tsx @@ -3,7 +3,7 @@ import { translate } from '../../i18n/mobile-i18n' import { View, ActivityIndicator, Text, FlatList, Pressable } from 'react-native' import { colors, spacing, type MobileColorScheme, pressedStyle } from '../../theme/mobile-theme' import { ChevronLeft, ChevronRight, ExternalLink, GitBranch } from 'lucide-react-native' -import { openExternalLink } from '../../platform/external-link' +import { openExternalLink } from '../../platform/external-links/external-link' import { getHostedReviewSignalTone, getHostedReviewLabel, diff --git a/mobile/src/tasks/workspace-creation/mobile-tasks-create-drawers.tsx b/mobile/src/tasks/workspace-creation/mobile-tasks-create-drawers.tsx index a12ace81ec4..429c44e20ed 100644 --- a/mobile/src/tasks/workspace-creation/mobile-tasks-create-drawers.tsx +++ b/mobile/src/tasks/workspace-creation/mobile-tasks-create-drawers.tsx @@ -8,7 +8,7 @@ import { ChevronDown, ExternalLink, Lock } from 'lucide-react-native' import { MessageBox } from '../../components/composer-input/MessageBox' import { TextField } from '../../components/composer-input/TextField' import { PickerModal } from '../../components/PickerModal' -import { openExternalLink } from '../../platform/external-link' +import { openExternalLink } from '../../platform/external-links/external-link' import { styles } from '../mobile-tasks-legacy-styles' import { getRepoBadgeColor } from '../repositories/mobile-tasks-repository-presentation' import type { RepoSummary, LinearTeam } from '../mobile-tasks-provider-detail-types' diff --git a/mobile/src/terminal/quick-commands/QuickCommandRow.tsx b/mobile/src/terminal/quick-commands/QuickCommandRow.tsx index 5cfc56b24e4..ef034cd5aad 100644 --- a/mobile/src/terminal/quick-commands/QuickCommandRow.tsx +++ b/mobile/src/terminal/quick-commands/QuickCommandRow.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from 'react' import { View, Text, Pressable, StyleSheet } from 'react-native' -import { useClipboardWriter } from '../../platform/clipboard' -import { triggerError } from '../../platform/haptics' +import { useClipboardWriter } from '../../platform/clipboard/clipboard' +import { triggerError } from '../../platform/haptics/haptics' import { Check, Copy, Pencil, Play, Trash2 } from 'lucide-react-native' import { colors, radii, spacing, typography } from '../../theme/mobile-theme' import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme' diff --git a/mobile/src/terminal/quick-commands/mobile-session-quick-command-launch.ts b/mobile/src/terminal/quick-commands/mobile-session-quick-command-launch.ts index 6a8f270e4ab..bb0c5edb0e0 100644 --- a/mobile/src/terminal/quick-commands/mobile-session-quick-command-launch.ts +++ b/mobile/src/terminal/quick-commands/mobile-session-quick-command-launch.ts @@ -1,6 +1,6 @@ import { buildMobileQuickCommandLaunch, type MobileQuickCommandLaunch } from '../quick-commands' -import { triggerError } from '../../platform/haptics' +import { triggerError } from '../../platform/haptics/haptics' import { translate } from '../../i18n/mobile-i18n' import type { TerminalQuickCommand } from '../../../../src/shared/terminal/quick-commands/terminal-quick-command-types' import type { MobileNewTabAgentOption } from '../../session/agent-launch/mobile-new-tab-agent-options' diff --git a/mobile/src/terminal/session/MobileSessionAccessoryKeys.tsx b/mobile/src/terminal/session/MobileSessionAccessoryKeys.tsx index 49d2a259dce..fadfc6fab54 100644 --- a/mobile/src/terminal/session/MobileSessionAccessoryKeys.tsx +++ b/mobile/src/terminal/session/MobileSessionAccessoryKeys.tsx @@ -7,7 +7,7 @@ import { Plus, Smartphone } from 'lucide-react-native' -import { triggerMediumImpact } from '../../platform/haptics' +import { triggerMediumImpact } from '../../platform/haptics/haptics' import { createTerminalLiveAccessoryInput } from '../terminal-live-accessory-input' import { TERMINAL_ACCESSORY_REPEAT_DELAY_MS } from '../terminal-accessory-repeat' import { getVisibleTerminalAccessoryKeys } from '../terminal-accessory-layout' @@ -18,7 +18,7 @@ import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme' import { useMobileTranslation } from '../../i18n/use-mobile-translation' import { colors } from '../../theme/mobile-theme' import { styles as sessionStyles } from '../../session/mobile-session-styles' -import { useKeyboardPersistingTaps } from '../../platform/keyboard-persisting-taps' +import { useKeyboardPersistingTaps } from '../../platform/keyboard/keyboard-persisting-taps' import type { MobileSessionController } from '../../session/use-mobile-session-controller' import { useMobileSessionKeyboardLift } from './use-mobile-session-keyboard-lift' import { diff --git a/mobile/src/terminal/session/TerminalMoreKeysSheet.tsx b/mobile/src/terminal/session/TerminalMoreKeysSheet.tsx index df03af5a62e..4478aa75a2d 100644 --- a/mobile/src/terminal/session/TerminalMoreKeysSheet.tsx +++ b/mobile/src/terminal/session/TerminalMoreKeysSheet.tsx @@ -4,7 +4,7 @@ import { ClipboardPaste, FileUp, ImagePlus, Monitor, Plus, Smartphone } from 'lu import type { LucideIcon } from 'lucide-react-native' import { BottomDrawer } from '../../components/bottom-drawer/BottomDrawer' import { SectionHeader } from '../../components/list/ListGroup' -import { triggerMediumImpact } from '../../platform/haptics' +import { triggerMediumImpact } from '../../platform/haptics/haptics' import type { TerminalAccessoryKey } from '../terminal-accessory-keys' import { TERMINAL_SHORTCUT_SPECIAL_KEYS } from '../terminal-accessory-keys' import type { CustomKey } from '../terminal-custom-keys' diff --git a/mobile/src/terminal/session/mobile-session-terminal-create-feedback.ts b/mobile/src/terminal/session/mobile-session-terminal-create-feedback.ts index d4a85ec9f55..cac26da7eb5 100644 --- a/mobile/src/terminal/session/mobile-session-terminal-create-feedback.ts +++ b/mobile/src/terminal/session/mobile-session-terminal-create-feedback.ts @@ -1,4 +1,4 @@ -import { triggerError } from '../../platform/haptics' +import { triggerError } from '../../platform/haptics/haptics' import { terminalCreateCopy } from './mobile-session-terminal-create-copy' import type { MobileSessionAttachmentsModel } from '../../files/attachments/use-mobile-session-attachments' import type { MobileSessionTerminalCreateOptions } from './mobile-session-terminal-create-request' diff --git a/mobile/src/terminal/session/use-mobile-session-accessory-selection.ts b/mobile/src/terminal/session/use-mobile-session-accessory-selection.ts index 8d82b194027..fd4059f41da 100644 --- a/mobile/src/terminal/session/use-mobile-session-accessory-selection.ts +++ b/mobile/src/terminal/session/use-mobile-session-accessory-selection.ts @@ -6,7 +6,7 @@ import { createTerminalAccessoryRepeatController, createTerminalAccessoryRepeatSender } from '../terminal-accessory-repeat' -import { useClipboardReader, useClipboardWriter } from '../../platform/clipboard' +import { useClipboardReader, useClipboardWriter } from '../../platform/clipboard/clipboard' import { translate } from '../../i18n/mobile-i18n' import { newTabRepoListRead, @@ -17,7 +17,7 @@ import { triggerSuccess, triggerError, triggerEdgeBump -} from '../../platform/haptics' +} from '../../platform/haptics/haptics' import { sameTerminalKeyboardAvoidanceMetrics, type TerminalKeyboardAvoidanceMetrics, diff --git a/mobile/src/terminal/session/use-mobile-session-keyboard-lift.ts b/mobile/src/terminal/session/use-mobile-session-keyboard-lift.ts index ee3b0121461..1e4bfc6b030 100644 --- a/mobile/src/terminal/session/use-mobile-session-keyboard-lift.ts +++ b/mobile/src/terminal/session/use-mobile-session-keyboard-lift.ts @@ -1,4 +1,4 @@ -import { useKeyboardOcclusion } from '../../platform/keyboard-occlusion' +import { useKeyboardOcclusion } from '../../platform/keyboard/keyboard-occlusion' import { hostOs } from '../../platform/host-os' import { computeActiveTerminalKeyboardLift } from '../terminal-keyboard-avoidance-lift' import type { MobileSessionController } from '../../session/use-mobile-session-controller' diff --git a/mobile/src/terminal/session/use-mobile-session-keyboard-state.ts b/mobile/src/terminal/session/use-mobile-session-keyboard-state.ts index 785f282bb4f..e7e9b0146f0 100644 --- a/mobile/src/terminal/session/use-mobile-session-keyboard-state.ts +++ b/mobile/src/terminal/session/use-mobile-session-keyboard-state.ts @@ -1,5 +1,8 @@ import { useEffect, useCallback } from 'react' -import { currentSoftKeyboardHeight, subscribeSoftKeyboard } from '../../platform/keyboard-occlusion' +import { + currentSoftKeyboardHeight, + subscribeSoftKeyboard +} from '../../platform/keyboard/keyboard-occlusion' import { useTerminalViewportRefit } from '../terminal-viewport-refit' import { removeCustomKey, type CustomKey } from '../terminal-custom-keys' import { useWorkspaceExecutionHost } from '../../navigation/workspace-execution-host' diff --git a/mobile/src/terminal/session/use-mobile-session-terminal-create-actions.ts b/mobile/src/terminal/session/use-mobile-session-terminal-create-actions.ts index cb0f22fc01a..65183baf4c6 100644 --- a/mobile/src/terminal/session/use-mobile-session-terminal-create-actions.ts +++ b/mobile/src/terminal/session/use-mobile-session-terminal-create-actions.ts @@ -1,6 +1,6 @@ import type { RpcSuccess } from '../../transport/rpc/rpc-wire-types' import { sessionTabCreateTerminal } from '../../session/tabs/mobile-session-tab-write-operations' -import { triggerSuccess, triggerError } from '../../platform/haptics' +import { triggerSuccess, triggerError } from '../../platform/haptics/haptics' import { terminalCreateCopy } from './mobile-session-terminal-create-copy' import { createMobileSessionTerminalCreateFeedback } from './mobile-session-terminal-create-feedback' import { buildTerminalSendParams } from '../terminal-send-request' diff --git a/mobile/src/terminal/session/use-mobile-session-terminal-runtime.ts b/mobile/src/terminal/session/use-mobile-session-terminal-runtime.ts index 35405bc89a3..b09eee00f15 100644 --- a/mobile/src/terminal/session/use-mobile-session-terminal-runtime.ts +++ b/mobile/src/terminal/session/use-mobile-session-terminal-runtime.ts @@ -7,7 +7,7 @@ import type { PendingSessionSelection } from '../../session/tabs/pending-session import { useState, useRef, useCallback } from 'react' import type { TerminalFrame } from '../terminal-webview-messages' import type { Keyboard } from 'react-native' -import type { MobileTextInputHandle } from '../../platform/text-input-handle' +import type { MobileTextInputHandle } from '../../platform/text-input/text-input-handle' import { useFocusEffect } from 'expo-router' import type { RpcClient } from '../../transport/rpc/rpc-client-contract' import type { ConnectionState } from '../../transport/connection/connection-state' diff --git a/mobile/src/terminal/session/use-mobile-session-terminal-send-actions.ts b/mobile/src/terminal/session/use-mobile-session-terminal-send-actions.ts index edb2ba3e1ae..0aeac5734a7 100644 --- a/mobile/src/terminal/session/use-mobile-session-terminal-send-actions.ts +++ b/mobile/src/terminal/session/use-mobile-session-terminal-send-actions.ts @@ -1,8 +1,8 @@ import { useCallback } from 'react' import { Keyboard } from 'react-native' import { translate } from '../../i18n/mobile-i18n' -import { triggerError } from '../../platform/haptics' -import { currentSoftKeyboardHeight } from '../../platform/keyboard-occlusion' +import { triggerError } from '../../platform/haptics/haptics' +import { currentSoftKeyboardHeight } from '../../platform/keyboard/keyboard-occlusion' import type { createTerminalLiveAccessoryInput } from '../terminal-live-accessory-input' import { sendTerminalLiveAccessoryRawBytes } from '../terminal-live-accessory-raw-send' import { diff --git a/mobile/src/terminal/session/use-mobile-terminal-paste.ts b/mobile/src/terminal/session/use-mobile-terminal-paste.ts index 8d61401c0b3..4d929ff8e50 100644 --- a/mobile/src/terminal/session/use-mobile-terminal-paste.ts +++ b/mobile/src/terminal/session/use-mobile-terminal-paste.ts @@ -6,7 +6,7 @@ import { import { useCallback, type RefObject } from 'react' import { translate } from '../../i18n/mobile-i18n' import { terminalInputSend } from '../mobile-terminal-operations' -import { useClipboardReader } from '../../platform/clipboard' +import { useClipboardReader } from '../../platform/clipboard/clipboard' import type { TerminalModes } from '../terminal-webview-contract' import type { RpcClient } from '../../transport/rpc/rpc-client-contract' import type { ConnectionState } from '../../transport/connection/connection-state' diff --git a/mobile/src/terminal/session/use-session-terminal-key-rail.ts b/mobile/src/terminal/session/use-session-terminal-key-rail.ts index c7806a45aac..ab62a8b364b 100644 --- a/mobile/src/terminal/session/use-session-terminal-key-rail.ts +++ b/mobile/src/terminal/session/use-session-terminal-key-rail.ts @@ -8,7 +8,7 @@ import { isTerminalPhoneDisplayMode } from './terminal-phone-display-mode' import { isMobileTerminalTabRunningAgent } from './mobile-terminal-tab-agent' import { useShellTerminalKeyRail } from './shell-terminal-key-rail' import type { MobileSessionController } from '../../session/use-mobile-session-controller' -import { useKeyboardOcclusion } from '../../platform/keyboard-occlusion' +import { useKeyboardOcclusion } from '../../platform/keyboard/keyboard-occlusion' const ESC = '\x1b' const CTRL_C = '\x03' diff --git a/mobile/src/terminal/use-terminal-live-input-click.test.ts b/mobile/src/terminal/use-terminal-live-input-click.test.ts index 059167df485..ad9d95e82a4 100644 --- a/mobile/src/terminal/use-terminal-live-input-click.test.ts +++ b/mobile/src/terminal/use-terminal-live-input-click.test.ts @@ -5,7 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { TerminalLiveInputSender } from './terminal-live-input-sender' import { useTerminalLiveInputCommit } from './use-terminal-live-input-commit' -vi.mock('../platform/live-input-composing-range', () => ({ +vi.mock('../platform/text-input/live-input-composing-range', () => ({ reportedLiveInputComposing: (isComposing: boolean | undefined) => isComposing })) diff --git a/mobile/src/terminal/use-terminal-live-input-commit.test.ts b/mobile/src/terminal/use-terminal-live-input-commit.test.ts index 9f45a1c1abc..a1a34afeb1f 100644 --- a/mobile/src/terminal/use-terminal-live-input-commit.test.ts +++ b/mobile/src/terminal/use-terminal-live-input-commit.test.ts @@ -5,7 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { TerminalLiveInputSender } from './terminal-live-input-sender' import { useTerminalLiveInputCommit } from './use-terminal-live-input-commit' -vi.mock('../platform/live-input-composing-range', () => ({ +vi.mock('../platform/text-input/live-input-composing-range', () => ({ reportedLiveInputComposing: (isComposing: boolean | undefined) => isComposing })) diff --git a/mobile/src/terminal/use-terminal-live-input-commit.ts b/mobile/src/terminal/use-terminal-live-input-commit.ts index a51d9bfab1e..9ab23403bdb 100644 --- a/mobile/src/terminal/use-terminal-live-input-commit.ts +++ b/mobile/src/terminal/use-terminal-live-input-commit.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, type RefObject } from 'react' import type { TextInputInstance } from 'react-native' -import { reportedLiveInputComposing } from '../platform/live-input-composing-range' +import { reportedLiveInputComposing } from '../platform/text-input/live-input-composing-range' import { getTerminalLiveSpecialKeyDecision } from './terminal-live-text-commit' import type { TerminalLiveAccessoryInput } from './terminal-live-accessory-input' import type { TerminalLiveInputSender } from './terminal-live-input-sender' diff --git a/mobile/src/terminal/use-terminal-live-input-focus.ts b/mobile/src/terminal/use-terminal-live-input-focus.ts index cd9608745a5..63d03346d10 100644 --- a/mobile/src/terminal/use-terminal-live-input-focus.ts +++ b/mobile/src/terminal/use-terminal-live-input-focus.ts @@ -1,5 +1,5 @@ import { useCallback, useLayoutEffect, useRef, type RefObject } from 'react' -import { currentSoftKeyboardHeight } from '../platform/keyboard-occlusion' +import { currentSoftKeyboardHeight } from '../platform/keyboard/keyboard-occlusion' import { clearTerminalLiveInputFocusTimer, focusTerminalLiveInputTarget, diff --git a/mobile/web-entry/index.tsx b/mobile/web-entry/index.tsx index df11b8b7952..dc5f74447fb 100644 --- a/mobile/web-entry/index.tsx +++ b/mobile/web-entry/index.tsx @@ -38,10 +38,10 @@ import { startPageLocaleCatalogs } from '../src/i18n/page-locale-catalog-source' import { getUiIntlLocale } from '../src/i18n/ui-intl-locale' import { readDeviceUiLocaleTags, resolveUiLocaleFromTags } from '../src/i18n/system-ui-locale' import { publishPageHostProfile } from '../src/mobile-web-shell/bridge/page-host-profile' -import { publishExternalLinkOpener } from '../src/platform/external-link.web' -import { publishHapticsNotifier } from '../src/platform/haptics.web' -import { startBlurringOnKeyboardClose } from '../src/platform/keyboard-close-blur.web' -import { publishShellKeyboardSource } from '../src/platform/keyboard-occlusion.web' +import { publishExternalLinkOpener } from '../src/platform/external-links/external-link.web' +import { publishHapticsNotifier } from '../src/platform/haptics/haptics.web' +import { startBlurringOnKeyboardClose } from '../src/platform/keyboard/keyboard-close-blur.web' +import { publishShellKeyboardSource } from '../src/platform/keyboard/keyboard-occlusion.web' // Select the page-client provider explicitly; the builder aliases extensionless screen imports to this same web module. import { RpcClientProvider } from '../src/transport/connection/client-context.web' import { OrcaQueryProvider } from '../src/query/orca-query-provider.web' diff --git a/mobile/web-entry/web-overrides.json b/mobile/web-entry/web-overrides.json index a624a12ad27..15667e1d838 100644 --- a/mobile/web-entry/web-overrides.json +++ b/mobile/web-entry/web-overrides.json @@ -15,7 +15,7 @@ }, { "file": "packages/expo-two-way-audio/src/ExpoTwoWayAudioModule.web.ts", - "reason": "Vendored with the module, not added for Route A. Nothing on the page reaches it any more: dictation's capture went behind src/platform/dictation-capture.web.ts, which asks the shell for the microphone, so the only importer of @orca/expo-two-way-audio is the native half of that seam. The file stays because it ships with the vendored module, and it answers denied microphone permission, which is what a browser outside the shell can honestly say." + "reason": "Vendored with the module, not added for Route A. Nothing on the page reaches it any more: dictation's capture went behind src/platform/audio-capture/dictation-capture.web.ts, which asks the shell for the microphone, so the only importer of @orca/expo-two-way-audio is the native half of that seam. The file stays because it ships with the vendored module, and it answers denied microphone permission, which is what a browser outside the shell can honestly say." }, { "file": "src/navigation/use-back-claim.web.ts", @@ -58,15 +58,15 @@ "reason": "The shell renders this page for this route, so the page has no shell to mount inside itself. Its native file reaches OrcaMobileWebShellView, whose requireNativeViewManager call runs at import and throws in a browser." }, { - "file": "src/platform/text-input-font-size.web.ts", + "file": "src/platform/text-input/text-input-font-size.web.ts", "reason": "iOS zooms the page on focus of any text input under 16px and does not zoom back out, which leaves the document at a scale other than 1 for a whole typing session. keyboard-occlusion.web.ts reads a scale other than 1 as 'not a keyboard' on purpose, because geometry cannot separate a zoom from a keyboard, so the commit bar and the note composer would never lift on the platform they were written for. The web file raises the app's 14px body size to that floor; the native file is the body size, so a phone renders exactly what it rendered before. maximum-scale=1 on the viewport meta was the other fix and was rejected: Android WebView honours it and it would take deliberate pinch zoom away from low-vision users." }, { - "file": "src/platform/text-input-content-height.web.ts", + "file": "src/platform/text-input/text-input-content-height.web.ts", "reason": "react-native-web renders a multiline TextInput as a