diff --git a/.github/workflows/pr-test-loc.yml b/.github/workflows/pr-test-loc.yml index 438951ee3fa..a884aedc636 100644 --- a/.github/workflows/pr-test-loc.yml +++ b/.github/workflows/pr-test-loc.yml @@ -23,14 +23,20 @@ jobs: timeout-minutes: 2 steps: # Why no checkout: the Files API already has per-file additions/deletions. + # Why the default branch and never pull//head: this job holds a write-scoped + # GITHUB_TOKEN, so it may only execute reviewed code. A PR that edits these + # scripts takes effect once merged. - name: Count test vs non-test LoC env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + TRUSTED_REF: ${{ github.event.repository.default_branch }} run: | + set -euo pipefail for script in pr-test-loc-table.mjs pr-test-loc-summary.mjs; do - gh api "repos/${GITHUB_REPOSITORY}/contents/.github/scripts/${script}?ref=pull/${{ github.event.pull_request.number }}/head" \ + gh api "repos/${GITHUB_REPOSITORY}/contents/.github/scripts/${script}?ref=${TRUSTED_REF}" \ --jq .content | base64 --decode > "$RUNNER_TEMP/${script}" done - node "$RUNNER_TEMP/pr-test-loc-summary.mjs" --update-pr "${{ github.event.pull_request.number }}" + node "$RUNNER_TEMP/pr-test-loc-summary.mjs" --update-pr "$PR_NUMBER" diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index c44b2abaa56..6826ecfb731 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -270,10 +270,7 @@ jobs: src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts \ src/main/providers/__tests__/shell-ready-framework-example.test.ts \ src/main/pty/omp-shell-wrapper.node-pty.test.ts \ - src/main/terminal-history-fish-session.node-pty.test.ts \ - src/main/zsh-scoped-histfile.live-shell.test.ts \ src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \ - src/shared/startup-shell-portability.live-shell.test.ts \ src/shared/posix-command-path-lookup.test.ts test: @@ -312,10 +309,7 @@ jobs: --exclude=src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts \ --exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \ --exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \ - --exclude=src/main/terminal-history-fish-session.node-pty.test.ts \ - --exclude=src/main/zsh-scoped-histfile.live-shell.test.ts \ --exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \ - --exclude=src/shared/startup-shell-portability.live-shell.test.ts \ --exclude=src/shared/posix-command-path-lookup.test.ts \ --exclude=tests/e2e/cross-version-wire/** \ --shard=${{ matrix.shard }}/${{ matrix.shard_total }} diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index aafe497a1c3..d7af7e7e4d4 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -88,13 +88,11 @@ inline src/relay/git-handler.ts inline src/relay/pty-handler.ts inline src/relay/relay.ts inline src/relay/workspace-space-scan.ts -inline src/renderer/src/components/GitHubItemDialog.tsx inline src/renderer/src/components/GitLabItemDialog.tsx inline src/renderer/src/components/JiraIssueWorkspace.tsx inline src/renderer/src/components/LinearIssueWorkspace.tsx inline src/renderer/src/components/LinearItemDrawer.tsx inline src/renderer/src/components/NewWorkspaceComposerCard.tsx -inline src/renderer/src/components/PullRequestPage.tsx inline src/renderer/src/components/TaskPage.tsx inline src/renderer/src/components/Terminal.tsx inline src/renderer/src/components/UpdateCard.tsx diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index cba44dc05cc..e91aee76a78 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -943,10 +943,10 @@ "providers": ["lan", "tailscale", "cloud-relay"], "coveredPlatforms": ["macos"], "coveredProviders": ["lan", "tailscale", "cloud-relay"], - "coverageNotes": "Deterministic TypeScript tests cover shared close-code policy, foreground retry timers, direct-winner cancellation, and concurrent LAN/Tailscale authentication. Physical iOS/Android radios, GFE, and production relay recovery remain live-test gaps.", + "coverageNotes": "Deterministic TypeScript tests cover shared close-code policy, foreground retry timers, direct-winner cancellation, credential-gated Relay presentation, and concurrent LAN/Tailscale authentication. Physical iOS/Android radios, GFE, and production relay recovery remain live-test gaps.", "motivatingLinks": ["https://github.com/stablyai/orca-cloud/pull/96"], - "invariant": "A foregrounded paired phone must recover from relay HOST_OFFLINE without a foreground or network-change signal, while direct recovery must select the first authenticated configured LAN or Tailscale endpoint without serial timeout delays. Backgrounding, direct success, or stop must cancel pending work, and losing probes must close without affecting the winner.", - "oracle": "Inject deterministic relay close codes, random bytes, fake timers, and independently controlled direct clients. Require HOST_OFFLINE to replace any faster transport timer with one 5-15 second retry, require no retry before the selected delay, race all unique non-relay endpoints, select the first authenticated path, close every loser exactly once, and retain no retry after direct connectivity wins.", + "invariant": "A foregrounded paired phone must recover from relay HOST_OFFLINE without a foreground or network-change signal, while direct recovery must select the first authenticated configured LAN or Tailscale endpoint without serial timeout delays. Relay recovery presentation starts only for a failed active Relay or a dial backed by an eligible credential, and remains pending through a genuine failed-dial cooldown. Backgrounding, direct success, or stop must cancel pending work, and losing probes must close without affecting the winner.", + "oracle": "Inject deterministic relay close codes, missing and expired credentials, delayed credential reads, random bytes, fake timers, and independently controlled direct clients. Require HOST_OFFLINE to replace any faster transport timer with one 5-15 second retry, require no retry before the selected delay, keep Relay presentation absent when no socket can open, clear it across lifecycle races, retain it through real dial cooldowns, race all unique non-relay endpoints, select the first authenticated path, close every loser exactly once, and retain no retry after direct connectivity wins.", "commands": [ "pnpm --dir mobile exec vitest run --root .. mobile/src/transport/mobile-direct-endpoint-probe.test.ts mobile/src/transport/mobile-relay-reconnect-controller.test.ts mobile/src/transport/mobile-endpoint-supervisor.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/shared/mobile-relay-close-codes.test.ts --reporter=dot" @@ -975,7 +975,10 @@ { "file": "mobile/src/transport/mobile-endpoint-supervisor.test.ts", "assertions": [ - "a foregrounded supervisor retries HOST_OFFLINE without an external lifecycle signal" + "a foregrounded supervisor retries HOST_OFFLINE without an external lifecycle signal", + "a missing or expired credential never presents a Relay connection that cannot start", + "backgrounding during credential selection cannot publish a stale Relay recovery path", + "a genuine failed Relay dial remains pending through its retry cooldown" ] }, { @@ -985,21 +988,21 @@ ], "evidenceRuns": [ { - "date": "2026-07-25", + "date": "2026-08-16", "runner": "local", "platform": "macos", "command": "pnpm --dir mobile exec vitest run --root .. mobile/src/transport/mobile-direct-endpoint-probe.test.ts mobile/src/transport/mobile-relay-reconnect-controller.test.ts mobile/src/transport/mobile-endpoint-supervisor.test.ts", "result": "passed", - "durationSeconds": 0.89, - "summary": "Three focused mobile transport files passed with 39 assertions." + "durationSeconds": 1.23, + "summary": "Three focused mobile transport files passed with 54 assertions." }, { - "date": "2026-07-25", + "date": "2026-08-16", "runner": "local", "platform": "macos", "command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/mobile-relay-close-codes.test.ts --reporter=dot", "result": "passed", - "durationSeconds": 0.19, + "durationSeconds": 0.17, "summary": "The shared close-code contract passed with five assertions." } ], @@ -1130,7 +1133,8 @@ "file": "mobile/src/transport/stable-logical-rpc-client.test.ts", "assertions": [ "a delivery-unknown mutation is not replayed onto a replacement Relay", - "Relay recovery path changes publish and remain pending between failed dials" + "Relay recovery path changes publish and remain pending between failed dials", + "a connected path clears recovery state before any later disconnect" ] }, { @@ -1148,8 +1152,8 @@ "platform": "macos", "command": "pnpm --dir mobile exec vitest run --root .. mobile/src/transport/client-context.test.ts mobile/src/transport/host-open-recovery.test.tsx mobile/src/transport/host-open-retry-scheduler.test.ts mobile/src/transport/rpc-session-liveness-watchdog.test.ts mobile/src/transport/rpc-session-liveness-integration.test.ts mobile/src/transport/mobile-relay-rpc-session.test.ts mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts mobile/src/transport/mobile-endpoint-nudge-router.test.ts mobile/src/transport/mobile-relay-e2ee-link.test.ts mobile/src/transport/stable-logical-rpc-client.test.ts mobile/src/transport/connection-health.test.ts", "result": "passed", - "durationSeconds": 0.74, - "summary": "Eleven focused files passed 84 deterministic provider lifecycle, path presentation, direct/Relay liveness, traffic, and delivery-ambiguity tests." + "durationSeconds": 0.77, + "summary": "Eleven focused files passed 85 deterministic provider lifecycle, path presentation, direct/Relay liveness, traffic, and delivery-ambiguity tests." } ], "runtimeBudget": { diff --git a/config/scripts/computer-use-windows-horizontal-scroll.test.mjs b/config/scripts/computer-use-windows-horizontal-scroll.test.mjs new file mode 100644 index 00000000000..3d76908daac --- /dev/null +++ b/config/scripts/computer-use-windows-horizontal-scroll.test.mjs @@ -0,0 +1,48 @@ +import { readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +const projectDir = resolve(import.meta.dirname, '../..') + +function source(path) { + return readFileSync(join(projectDir, path), 'utf8') +} + +function sourceBetween(contents, startMarker, endMarker) { + const start = contents.indexOf(startMarker) + const end = contents.indexOf(endMarker, start + startMarker.length) + if (start === -1 || end === -1) { + throw new Error(`Missing source boundary: ${startMarker} → ${endMarker}`) + } + return contents.slice(start, end) +} + +describe('Windows computer-use horizontal scroll', () => { + it('routes left and right through the horizontal wheel with native signs', () => { + const windows = source('native/computer-use-windows/runtime.ps1') + const mouseEvents = sourceBetween(windows, '$MouseEvents = @{', 'function Write-OrcaJson') + const scroll = sourceBetween(windows, ' "scroll" {', ' "drag" {') + const left = sourceBetween( + scroll, + '} elseif ($Operation.direction -eq "left") {', + '} elseif ($Operation.direction -eq "right") {' + ) + const right = sourceBetween( + scroll, + '} elseif ($Operation.direction -eq "right") {', + '} elseif ($Operation.direction -ne "up") {' + ) + + expect(mouseEvents).toContain('HorizontalWheel = 0x01000') + expect(scroll).toContain('$mouseEvent = $MouseEvents.Wheel') + expect(left).toContain('$mouseEvent = $MouseEvents.HorizontalWheel') + expect(left).toContain('$delta = -1 * $delta') + expect(right).toContain('$mouseEvent = $MouseEvents.HorizontalWheel') + expect(right).not.toContain('$delta = -1 * $delta') + expect(scroll).toContain( + '[OrcaDesktopWin32]::mouse_event($mouseEvent, 0, 0, $delta, [UIntPtr]::Zero)' + ) + expect(scroll).not.toContain('mouse_event($MouseEvents.Wheel') + expect(scroll).toContain('throw "unsupported scroll direction: $($Operation.direction)"') + }) +}) diff --git a/config/scripts/pr-test-loc-summary.test.mjs b/config/scripts/pr-test-loc-summary.test.mjs index 715b918bb32..14801ddc85d 100644 --- a/config/scripts/pr-test-loc-summary.test.mjs +++ b/config/scripts/pr-test-loc-summary.test.mjs @@ -15,6 +15,11 @@ import { const projectDir = resolve(import.meta.dirname, '../..') const locScript = join(projectDir, '.github/scripts/pr-test-loc-summary.mjs') +const locWorkflow = parse( + readFileSync(join(projectDir, '.github/workflows/pr-test-loc.yml'), 'utf8') +) +const locJob = locWorkflow.jobs.loc +const locStep = locJob.steps[0] const tempDirs = [] function runLoc(args, { env } = {}) { @@ -178,20 +183,36 @@ describe('PR test LoC summary', () => { }) it('is a no-checkout GitHub-hosted PR workflow', () => { - const workflow = parse( - readFileSync(join(projectDir, '.github/workflows/pr-test-loc.yml'), 'utf8') - ) - const locJob = workflow.jobs.loc - const serialized = JSON.stringify(workflow) - expect(locJob['runs-on']).toBe('ubuntu-latest') expect(locJob.steps).toHaveLength(1) - expect(locJob.steps[0].run).toContain('gh api') - expect(locJob.steps[0].run).toContain('pr-test-loc-table.mjs') - expect(locJob.steps[0].run).toContain('pr-test-loc-summary.mjs') - expect(locJob.steps[0].run).toContain('--update-pr') - expect(workflow.permissions['pull-requests']).toBe('write') - expect(serialized).not.toContain('actions/checkout') - expect(serialized).not.toContain('self-hosted') + expect(locStep.run).toContain('gh api') + expect(locStep.run).toContain('pr-test-loc-table.mjs') + expect(locStep.run).toContain('pr-test-loc-summary.mjs') + expect(locStep.run).toContain('--update-pr') + expect(locWorkflow.permissions['pull-requests']).toBe('write') + expect(JSON.stringify(locWorkflow)).not.toContain('actions/checkout') + expect(JSON.stringify(locWorkflow)).not.toContain('self-hosted') + }) + + // The write-scoped GITHUB_TOKEN makes any PR-authored code a privilege escalation. + it('executes only default-branch script code, never pull-request head code', () => { + const serialized = JSON.stringify(locWorkflow) + + expect(locStep.env.TRUSTED_REF).toBe('${{ github.event.repository.default_branch }}') + expect(locStep.run).toContain('?ref=${TRUSTED_REF}') + expect(serialized).not.toContain('pull_request_target') + expect(serialized).not.toContain('pull/') + expect(serialized).not.toContain('pull_request.head') + expect(serialized).not.toContain('/merge') + }) + + it('passes event data through env instead of interpolating it into the shell', () => { + expect(locStep.env.PR_NUMBER).toBe('${{ github.event.pull_request.number }}') + expect(locStep.run).toContain('--update-pr "$PR_NUMBER"') + expect(locStep.run).not.toContain('${{') + }) + + it('fails the step when a script download fails instead of running a truncated file', () => { + expect(locStep.run).toContain('set -euo pipefail') }) }) diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index 536059fe4cc..e934d1d836d 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -15,7 +15,6 @@ const shellContractFiles = [ 'src/main/providers/local-pty-shell-ready-zsh-zdotdir-discovery.test.ts', 'src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts', 'src/main/providers/__tests__/shell-ready-framework-example.test.ts', - 'src/main/zsh-scoped-histfile.live-shell.test.ts', 'src/shared/posix-command-path-lookup.test.ts' ] const patchedNodePtyContractFiles = [ diff --git a/config/scripts/verify-agent-hook-stdin-lifecycle.mjs b/config/scripts/verify-agent-hook-stdin-lifecycle.mjs index 187b5aa3fc2..8f594ff9506 100644 --- a/config/scripts/verify-agent-hook-stdin-lifecycle.mjs +++ b/config/scripts/verify-agent-hook-stdin-lifecycle.mjs @@ -320,7 +320,7 @@ async function verifyInstalledLauncher(home, payload) { ) if ( !command || - !command.includes('"$HOME/.orca/agent-hooks/claude-hook.sh"') || + !command.includes('"${HOME-}/.orca/agent-hooks/claude-hook.sh"') || !command.includes('] && [ -r ') || !command.includes('else { command -p cat') ) { diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index d7e251d0d86..7ccdd5acf2d 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -14,6 +14,7 @@ "../src/main/agent-hooks/managed-agent-hook-registry.ts", "../src/main/agent-hooks/managed-hook-script-refresh.ts", "../src/main/agent-hooks/runtime-home-hook-command.ts", + "../src/main/agent-hooks/windows-powershell-hook-launcher.ts", "../src/main/amp/agent-status-plugin-source.ts", "../src/main/amp/hook-service.ts", "../src/main/amp/managed-plugin-install-status.ts", diff --git a/mobile/app.json b/mobile/app.json index 34cc8de3be5..6b6d43f8f0e 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Orca", "slug": "orca-mobile", - "version": "0.0.43", + "version": "0.0.44", "orientation": "default", "icon": "./assets/icon.png", "userInterfaceStyle": "automatic", @@ -75,7 +75,7 @@ "allowBackup": false, "permissions": ["RECORD_AUDIO", "MODIFY_AUDIO_SETTINGS"], "package": "com.stably.orca.mobile", - "versionCode": 12 + "versionCode": 13 }, "plugins": [ "expo-router", diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 811211f07c3..6cff5a841fd 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -208,7 +208,6 @@ import { confirmsMirroredTabSelection, type AppliedSnapshotMarker } from '../../../../src/session/session-tab-snapshot-gate' -import { hasPendingTerminalHandleRecoveryNeed } from '../../../../src/session/pending-terminal-handle-recovery' import { createInitialSessionAutoCreateState, useInitialSessionTerminalAutoCreate, @@ -255,6 +254,7 @@ import type { } from '../../../../src/session/mobile-session-tabs-stream-health' import { useMobileSessionTabsFetchReporting } from '../../../../src/session/use-mobile-session-tabs-fetch-reporting' import { useMobileSessionTabsReconciliation } from '../../../../src/session/use-mobile-session-tabs-reconciliation' +import { PendingTerminalHandleRecoveryContextCache } from '../../../../src/session/pending-terminal-handle-recovery' import { getRepoIdFromMobileWorktreeId, getActiveTabIdForHandle, @@ -938,6 +938,9 @@ export default function SessionScreen() { const pendingBrowserFocusPageIdRef = useRef(null) const switchSessionTabRef = useRef<((tab: MobileSessionTab) => void) | null>(null) const pendingTerminalActivationAttemptRef = useRef(null) + const [parkedPendingTerminalContext, setParkedPendingTerminalContext] = useState( + null + ) // Why: route the terminal URL tap through a ref so it runs the current handleCreateBrowser closure (the memoized one may hold a null-client render). const handleCreateBrowserRef = useRef<((rawUrl?: string) => Promise) | null>(null) @@ -2286,16 +2289,25 @@ export default function SessionScreen() { () => closedTabTombstonesRef.current.size > 0 || pendingBrowserFocusPageIdRef.current !== null || - // Why: a pending-handle terminal only turns ready in a fresh snapshot. A live - // stream otherwise parks the poll, and a host that mints the handle without - // republishing strands the pane on its spinner forever (STA-4256). - hasPendingTerminalHandleRecoveryNeed(sessionTabsRef.current, activeSessionTabIdRef.current) || // Why: a chat-covered handle that ran out of rearms and left `terminal.list` // was reminted by a desktop graph reload. Only a fresh tab snapshot carries // the replacement handle, so force one instead of holding the composer locked. nativeChatStream.hasTabsRecoveryNeed(), [nativeChatStream] ) + const pendingTerminalRecoveryContextCache = useMemo( + () => new PendingTerminalHandleRecoveryContextCache(), + [] + ) + const getPendingTerminalRecoveryContextKey = useCallback( + () => + pendingTerminalRecoveryContextCache.read( + sessionTabsRef.current, + activeSessionTabIdRef.current + ), + [pendingTerminalRecoveryContextCache] + ) + const pendingTerminalRecoveryContextKey = getPendingTerminalRecoveryContextKey() const getSessionTabsApplicationRevision = useCallback( () => appliedSessionTabsRevisionRef.current, [] @@ -2304,18 +2316,25 @@ export default function SessionScreen() { worktreeId, diagnosticsRef: terminalDiagnosticsRef }) - const { fetchSessionTabs, ensureSessionTabs, fetchPendingBrowserSessionTabs } = - useMobileSessionTabsReconciliation({ - client, - connState, - worktreeId, - applySessionTabs, - consumeAcceptedSessionTabs, - fetchTerminals, - hasRecoveryNeed: hasSessionTabsRecoveryNeed, - getApplicationRevision: getSessionTabsApplicationRevision, - ...sessionTabsFetchReporting - }) + const { + fetchSessionTabs, + ensureSessionTabs, + fetchPendingBrowserSessionTabs, + retryPendingTerminalRecovery + } = useMobileSessionTabsReconciliation({ + client, + connState, + worktreeId, + applySessionTabs, + consumeAcceptedSessionTabs, + fetchTerminals, + hasRecoveryNeed: hasSessionTabsRecoveryNeed, + pendingTerminalRecoveryContextKey, + getPendingTerminalRecoveryContextKey, + onPendingTerminalRecoveryParked: setParkedPendingTerminalContext, + getApplicationRevision: getSessionTabsApplicationRevision, + ...sessionTabsFetchReporting + }) useEffect(() => { if (connState === 'connected') { @@ -4077,6 +4096,9 @@ export default function SessionScreen() { activeSessionTab?.type === 'terminal' && typeof activeSessionTab.terminal !== 'string' ? activeSessionTab : null + const isPendingTerminalRecoveryParked = + pendingTerminalRecoveryContextKey !== null && + pendingTerminalRecoveryContextKey === parkedPendingTerminalContext useEffect(() => { if (!client || connState !== 'connected' || !activePendingTerminalTab) { @@ -4596,10 +4618,27 @@ export default function SessionScreen() { ) : activePendingTerminalTab ? ( - + {!isPendingTerminalRecoveryParked && ( + + )} - {activePendingTerminalTab.title || 'Loading terminal'} + {isPendingTerminalRecoveryParked + ? 'Terminal is taking longer than expected' + : activePendingTerminalTab.title || 'Loading terminal'} + {isPendingTerminalRecoveryParked && ( + [ + styles.createButton, + pressed && styles.newTerminalButtonPressed + ]} + onPress={() => void retryPendingTerminalRecovery()} + > + Retry + + )} ) : ( { ) }) - it('counts a pending-handle active terminal as a tabs recovery need (STA-4256)', () => { - const recoveryNeed = sliceBetween( - 'const hasSessionTabsRecoveryNeed = useCallback(', - 'const getSessionTabsApplicationRevision =' + it('wires pending-handle recovery through its bounded context (STA-4256)', () => { + const applySessionTabs = sliceBetween( + 'const applySessionTabs = useCallback(', + 'const consumeAcceptedSessionTabs = useCallback(' + ) + const recoveryContext = sliceBetween( + 'const pendingTerminalRecoveryContextCache = useMemo(', + 'const getSessionTabsApplicationRevision' ) - expect(recoveryNeed).toContain('hasPendingTerminalHandleRecoveryNeed(') - expect(recoveryNeed).toContain('sessionTabsRef.current') - expect(recoveryNeed).toContain('activeSessionTabIdRef.current') - // The predicate only reaches the poll loop through this hook's option. + const tabsRefWrite = 'sessionTabsRef.current = nextTabs' + const tabsStateWrite = 'setSessionTabs((prev)' + const activeRefWrite = 'activeSessionTabIdRef.current = active?.id ?? null' + const activeStateWrite = 'setActiveSessionTabId(active?.id ?? null)' + for (const write of [tabsRefWrite, tabsStateWrite, activeRefWrite, activeStateWrite]) { + expect(applySessionTabs).toContain(write) + } + expect(applySessionTabs.indexOf(tabsRefWrite)).toBeLessThan( + applySessionTabs.indexOf(tabsStateWrite) + ) + expect(applySessionTabs.indexOf(activeRefWrite)).toBeLessThan( + applySessionTabs.indexOf(activeStateWrite) + ) + expect(recoveryContext).toContain('() => new PendingTerminalHandleRecoveryContextCache()') + expect(recoveryContext).toContain('sessionTabsRef.current,') + expect(recoveryContext).toContain('activeSessionTabIdRef.current') + expect(recoveryContext).toContain( + 'const pendingTerminalRecoveryContextKey = getPendingTerminalRecoveryContextKey()' + ) expect(source).toContain('hasRecoveryNeed: hasSessionTabsRecoveryNeed') + expect(source).toContain('getPendingTerminalRecoveryContextKey,') + expect(source).toContain('onPendingTerminalRecoveryParked: setParkedPendingTerminalContext') + expect(source).toContain('retryPendingTerminalRecovery()') }) }) diff --git a/mobile/src/session/mobile-session-tabs-stream-health.test.ts b/mobile/src/session/mobile-session-tabs-stream-health.test.ts index 954a3417bab..399128bb511 100644 --- a/mobile/src/session/mobile-session-tabs-stream-health.test.ts +++ b/mobile/src/session/mobile-session-tabs-stream-health.test.ts @@ -134,6 +134,29 @@ describe('MobileSessionTabsStreamHealth', () => { expect(harness.apply).toHaveBeenCalledTimes(2) }) + it('coalesces repeated retries and releases the retry cohort after failure', async () => { + const harness = makeHarness() + harness.controller.setReconciliationActive(true) + + const halfOpen = harness.controller.requestReconciliation() + const retry = harness.controller.retryReconciliation() + const repeatedRetry = harness.controller.retryReconciliation() + expect(harness.sendRequest).toHaveBeenCalledTimes(2) + expect(repeatedRetry).toBe(retry) + + harness.requests[1]!.resolve(failure()) + await retry + const nextRetry = harness.controller.retryReconciliation() + expect(harness.sendRequest).toHaveBeenCalledTimes(3) + expect(nextRetry).not.toBe(retry) + + harness.requests[2]!.resolve(success(result(3))) + await nextRetry + harness.requests[0]!.resolve(success(result(1))) + await halfOpen + expect(harness.apply).toHaveBeenCalledTimes(1) + }) + it('starts distinct pre- and post-snapshot lists and discards the stale barrier', async () => { const harness = makeHarness() harness.controller.setReconciliationActive(true) diff --git a/mobile/src/session/mobile-session-tabs-stream-health.ts b/mobile/src/session/mobile-session-tabs-stream-health.ts index 944c7de9412..66dfd21ddb3 100644 --- a/mobile/src/session/mobile-session-tabs-stream-health.ts +++ b/mobile/src/session/mobile-session-tabs-stream-health.ts @@ -19,6 +19,7 @@ type RequestOwner = { type RequestCohort = { promise: Promise resolve: () => void + retry: boolean } type ControllerOptions = { @@ -31,6 +32,7 @@ type ControllerOptions = { source: SessionTabsStreamSource ) => void hasRecoveryNeed: () => boolean + allowRecoveryPoll?: () => boolean getApplicationRevision?: () => number onFetchStarted?: () => void onFetchSucceeded?: (result: Result) => void @@ -84,16 +86,35 @@ export class MobileSessionTabsStreamHealth { return this.requestReconciliation() } + retryReconciliation(): Promise { + this.syncGeneration() + const currentRequest = this.inFlight.get(`${this.generation}:${this.barrier}`) + if (currentRequest?.retry) { + return currentRequest.promise + } + this.requirementRevision += 1 + this.barrier += currentRequest ? 1 : 0 + return this.startCurrentRequest(true) + } + poll(): Promise | null { this.syncGeneration() - if ( - !this.reconciliationActive || - (this.health === 'live' && - !this.options.hasRecoveryNeed() && - this.requirementRevision <= this.satisfiedRevision) - ) { + if (!this.reconciliationActive) { return null } + const recoveryNeeded = this.options.hasRecoveryNeed() + if (this.health === 'live') { + if (!recoveryNeeded && this.requirementRevision <= this.satisfiedRevision) { + return null + } + const currentRequest = this.inFlight.get(`${this.generation}:${this.barrier}`) + if (currentRequest) { + return currentRequest.promise + } + if (recoveryNeeded && this.options.allowRecoveryPoll?.() === false) { + return null + } + } return this.ensureReconciliation() } @@ -186,7 +207,7 @@ export class MobileSessionTabsStreamHealth { this.requirementRevision += 1 } - private startCurrentRequest(): Promise { + private startCurrentRequest(retry = false): Promise { if (this.disposed || !this.reconciliationActive) { return Promise.resolve() } @@ -199,7 +220,7 @@ export class MobileSessionTabsStreamHealth { const promise = new Promise((resolve) => { resolveRequest = resolve }) - const cohort = { promise, resolve: resolveRequest } + const cohort = { promise, resolve: resolveRequest, retry } this.inFlight.set(key, cohort) this.runCohortRequest(key, cohort) return promise diff --git a/mobile/src/session/pending-terminal-handle-recovery-context-cache.test.ts b/mobile/src/session/pending-terminal-handle-recovery-context-cache.test.ts new file mode 100644 index 00000000000..0ba91449462 --- /dev/null +++ b/mobile/src/session/pending-terminal-handle-recovery-context-cache.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' +import type { MobileSessionTab } from './mobile-session-route-types' +import { PendingTerminalHandleRecoveryContextCache } from './pending-terminal-handle-recovery' + +function terminalTab(id: string, terminal: string | null): MobileSessionTab { + return { + type: 'terminal', + id, + title: 'zsh', + parentTabId: 'parent', + leafId: 'leaf', + status: terminal === null ? 'pending-handle' : 'ready', + terminal, + isActive: true + } +} + +describe('PendingTerminalHandleRecoveryContextCache', () => { + it('reads authoritative tab refs before the next render', () => { + const cache = new PendingTerminalHandleRecoveryContextCache() + const pendingTabs = [terminalTab('terminal-a', null)] + const readyTabs = [terminalTab('terminal-a', 'pty-a')] + + expect(cache.read(pendingTabs, 'terminal-a')).not.toBeNull() + expect(cache.read(readyTabs, 'terminal-a')).toBeNull() + expect(cache.read(pendingTabs, null)).toBeNull() + expect(cache.read(pendingTabs, 'terminal-a')).not.toBeNull() + }) + + it('recomputes when the pending terminal identity changes', () => { + const cache = new PendingTerminalHandleRecoveryContextCache() + const first = terminalTab('terminal-a', null) + const parentChanged = { ...first, parentTabId: 'other-parent' } + const leafChanged = { ...first, leafId: 'other-leaf' } + const collisionLeft = { ...first, parentTabId: 'ab', leafId: 'c' } + const collisionRight = { ...first, parentTabId: 'a', leafId: 'bc' } + const firstKey = cache.read([first], first.id) + + expect(cache.read([parentChanged], parentChanged.id)).not.toBe(firstKey) + expect(cache.read([leafChanged], leafChanged.id)).not.toBe(firstKey) + const collisionLeftKey = cache.read([collisionLeft], collisionLeft.id) + expect(cache.read([collisionRight], collisionRight.id)).not.toBe(collisionLeftKey) + }) + + it('does not rescan an unchanged context on the polling hot path', () => { + let skippedTabIdReads = 0 + const skippedTab = terminalTab('terminal-b', 'pty-b') + Object.defineProperty(skippedTab, 'id', { + get: () => { + skippedTabIdReads += 1 + return 'terminal-b' + } + }) + const tabs = [skippedTab, terminalTab('terminal-a', null)] + const cache = new PendingTerminalHandleRecoveryContextCache() + + const contextKey = cache.read(tabs, 'terminal-a') + const readsAfterFirstLookup = skippedTabIdReads + expect(readsAfterFirstLookup).toBeGreaterThan(0) + expect(cache.read(tabs, 'terminal-a')).toBe(contextKey) + expect(skippedTabIdReads).toBe(readsAfterFirstLookup) + }) +}) diff --git a/mobile/src/session/pending-terminal-handle-recovery-poll.test.ts b/mobile/src/session/pending-terminal-handle-recovery-poll.test.ts new file mode 100644 index 00000000000..d5b1df752fb --- /dev/null +++ b/mobile/src/session/pending-terminal-handle-recovery-poll.test.ts @@ -0,0 +1,371 @@ +import { createElement, Suspense } from 'react' +import { act, create } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import type { RpcSuccess } from '../transport/types' +import type { SessionTabsApplyOutcome } from './mobile-session-tabs-stream-health' +import { PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS } from './pending-terminal-handle-recovery' +import { useMobileSessionTabsReconciliation } from './use-mobile-session-tabs-reconciliation' + +type TestResult = { + type?: 'snapshot' | 'updated' + snapshotVersion: number + tabs: string[] +} + +type HarnessProps = { + onParked?: (key: string | null) => void + suspend?: Promise +} + +const lifecycle = vi.hoisted(() => ({ + appState: 'active', + focused: true, + listeners: new Set<(state: string) => void>() +})) + +vi.mock('react-native', () => ({ + AppState: { + get currentState() { + return lifecycle.appState + }, + addEventListener(_event: string, listener: (state: string) => void) { + lifecycle.listeners.add(listener) + return { remove: () => lifecycle.listeners.delete(listener) } + } + } +})) + +vi.mock('expo-router', async () => { + const React = await import('react') + return { + useFocusEffect(effect: () => void | (() => void)): void { + React.useEffect(() => (lifecycle.focused ? effect() : undefined), [effect, lifecycle.focused]) + } + } +}) + +function result(type?: TestResult['type']): TestResult { + return { snapshotVersion: 1, tabs: ['pending-terminal'], ...(type ? { type } : {}) } +} + +function makeHarness() { + const requestTimes: number[] = [] + const parkedContexts: (string | null)[] = [] + let contextKey: string | null = 'terminal-a' + let otherRecoveryNeeded = false + let connState: 'connected' | 'disconnected' = 'connected' + let streamListener: ((payload: unknown) => void) | null = null + let actions: ReturnType> | null = + null + const sendRequest = vi.fn(async (): Promise => { + requestTimes.push(Date.now()) + return { + id: `list-${requestTimes.length}`, + ok: true, + result: result(), + _meta: { runtimeId: 'runtime-1' } + } + }) + const subscribe = vi.fn( + (_method: string, _params: unknown, listener: (payload: unknown) => void) => { + streamListener = listener + return () => {} + } + ) + const client = { sendRequest, subscribe } as unknown as RpcClient + const consumeAcceptedSessionTabs = () => {} + const fetchTerminals = async () => {} + const getPendingTerminalRecoveryContextKey = () => contextKey + const hasRecoveryNeed = () => otherRecoveryNeeded + const onPendingTerminalRecoveryParked = (key: string | null) => parkedContexts.push(key) + const applySessionTabs = (value: TestResult): SessionTabsApplyOutcome => ({ + accepted: true, + effectiveTabs: value.tabs + }) + + function Harness({ onParked = onPendingTerminalRecoveryParked, suspend }: HarnessProps): null { + actions = useMobileSessionTabsReconciliation({ + client, + connState, + worktreeId: 'repo::worktree', + applySessionTabs, + consumeAcceptedSessionTabs, + fetchTerminals, + hasRecoveryNeed, + pendingTerminalRecoveryContextKey: contextKey, + getPendingTerminalRecoveryContextKey, + onPendingTerminalRecoveryParked: onParked + }) + if (suspend) { + throw suspend + } + return null + } + + return { + Harness, + actions: () => actions, + emit(payload: TestResult) { + streamListener?.(payload) + }, + parkedContexts, + requestTimes, + sendRequest, + setConnectionState(value: typeof connState) { + connState = value + }, + setContextKey(value: string | null) { + contextKey = value + }, + setOtherRecoveryNeeded(value: boolean) { + otherRecoveryNeeded = value + } + } +} + +async function flush(): Promise { + for (let turn = 0; turn < 8; turn += 1) { + await Promise.resolve() + } +} + +// react-test-renderer's types do not resolve in the type-aware lint project, so both +// ReactTestRenderer and ReturnType degrade to `any` and poison this union. +// Name the two methods this suite actually uses instead. +type MountedHarness = { + unmount: () => void + update: (element: ReturnType) => void +} + +describe('bounded pending-handle reconciliation cadence', () => { + let renderer: MountedHarness | null = null + + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(0) + lifecycle.appState = 'active' + lifecycle.focused = true + lifecycle.listeners.clear() + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + vi.useRealTimers() + }) + + async function mount(harness: ReturnType): Promise { + await act(async () => { + renderer = create(createElement(harness.Harness)) + await flush() + }) + await act(async () => { + harness.emit(result('snapshot')) + await flush() + harness.emit(result('updated')) + await flush() + }) + harness.sendRequest.mockClear() + harness.requestTimes.length = 0 + harness.parkedContexts.length = 0 + } + + async function advance(milliseconds: number): Promise { + await act(async () => { + await vi.advanceTimersByTimeAsync(milliseconds) + }) + } + + async function setAppState(state: string): Promise { + lifecycle.appState = state + await act(async () => { + for (const listener of lifecycle.listeners) { + listener(state) + } + await flush() + }) + } + + it('drops the main baseline from 1800 lists per hour to five, then parks', async () => { + const harness = makeHarness() + await mount(harness) + + await advance(3_600_000) + + expect(harness.requestTimes).toEqual([2000, 4000, 6000, 8000, 10_000]) + expect(harness.parkedContexts).toContain('terminal-a') + }) + + it('resets for active-tab and pending-terminal context changes, but not ordinary ticks', async () => { + const harness = makeHarness() + await mount(harness) + await advance(12_000) + expect(harness.requestTimes).toHaveLength(PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS) + + harness.setContextKey(null) + await advance(2000) + const afterActiveTabChange = harness.requestTimes.length + harness.setContextKey('terminal-a') + await advance(2000) + expect(harness.requestTimes).toHaveLength(afterActiveTabChange + 1) + + harness.setContextKey('terminal-b') + await advance(2000) + expect(harness.requestTimes).toHaveLength(afterActiveTabChange + 2) + }) + + it('resets when the active tab changes away and back between poll ticks', async () => { + const harness = makeHarness() + await mount(harness) + await advance(12_000) + + harness.setContextKey(null) + await act(async () => { + renderer?.update(createElement(harness.Harness)) + await flush() + }) + expect(harness.parkedContexts.at(-1)).toBeNull() + harness.setContextKey('terminal-a') + await act(async () => { + renderer?.update(createElement(harness.Harness)) + await flush() + }) + expect(harness.parkedContexts.at(-1)).toBeNull() + await advance(2000) + + expect(harness.requestTimes).toHaveLength(PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS + 1) + }) + + it('keeps the parked state across unrelated rerenders', async () => { + const harness = makeHarness() + await mount(harness) + await advance(12_000) + expect(harness.parkedContexts.at(-1)).toBe('terminal-a') + + await act(async () => { + renderer?.update(createElement(harness.Harness)) + await flush() + }) + + expect(harness.parkedContexts.at(-1)).toBe('terminal-a') + await advance(2000) + expect(harness.requestTimes).toHaveLength(PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS) + }) + + it('does not publish a parked callback from a suspended render', async () => { + const harness = makeHarness() + const committedCallback = vi.fn() + const suspendedCallback = vi.fn() + const neverCommits = new Promise(() => {}) + await act(async () => { + renderer = create( + createElement( + Suspense, + { fallback: null }, + createElement(harness.Harness, { onParked: committedCallback }) + ) + ) + await flush() + }) + const committedActions = harness.actions() + committedCallback.mockClear() + + await act(async () => { + renderer?.update( + createElement( + Suspense, + { fallback: null }, + createElement(harness.Harness, { + onParked: suspendedCallback, + suspend: neverCommits + }) + ) + ) + await flush() + }) + await act(async () => { + await committedActions?.retryPendingTerminalRecovery() + }) + + expect(committedCallback).toHaveBeenCalledWith(null) + expect(suspendedCallback).not.toHaveBeenCalled() + }) + + it('publishes the committed parked callback before a same-commit context reset', async () => { + const harness = makeHarness() + const previousCallback = vi.fn() + const committedCallback = vi.fn() + await act(async () => { + renderer = create(createElement(harness.Harness, { onParked: previousCallback })) + await flush() + }) + previousCallback.mockClear() + + harness.setContextKey('terminal-b') + await act(async () => { + renderer?.update(createElement(harness.Harness, { onParked: committedCallback })) + await flush() + }) + + expect(committedCallback).toHaveBeenCalledWith(null) + expect(previousCallback).not.toHaveBeenCalled() + }) + + it('keeps other recovery sources live while the pending-terminal budget is parked', async () => { + const harness = makeHarness() + await mount(harness) + await advance(12_000) + expect(harness.requestTimes).toEqual([2000, 4000, 6000, 8000, 10_000]) + + harness.setOtherRecoveryNeeded(true) + await advance(14_000) + expect(harness.requestTimes).toEqual([ + 2000, 4000, 6000, 8000, 10_000, 14_000, 16_000, 18_000, 20_000, 22_000, 24_000, 26_000 + ]) + + harness.setOtherRecoveryNeeded(false) + await advance(2000) + expect(harness.requestTimes).toHaveLength(PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS + 7) + }) + + it('resets after foregrounding, reconnecting, and explicit retry', async () => { + const harness = makeHarness() + await mount(harness) + await advance(12_000) + + await setAppState('background') + await setAppState('active') + expect(harness.requestTimes).toHaveLength(PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS + 1) + await advance(2000) + expect(harness.requestTimes).toHaveLength(PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS + 2) + await advance(10_000) + + harness.setConnectionState('disconnected') + await act(async () => { + renderer?.update(createElement(harness.Harness)) + await flush() + }) + harness.setConnectionState('connected') + await act(async () => { + renderer?.update(createElement(harness.Harness)) + await flush() + }) + await act(async () => { + harness.emit(result('updated')) + await flush() + }) + const afterReconnect = harness.requestTimes.length + await advance(2000) + expect(harness.requestTimes).toHaveLength(afterReconnect + 1) + + await advance(10_000) + const beforeRetry = harness.requestTimes.length + await act(async () => { + await harness.actions()?.retryPendingTerminalRecovery() + }) + expect(harness.requestTimes).toHaveLength(beforeRetry + 1) + await advance(2000) + expect(harness.requestTimes).toHaveLength(beforeRetry + 2) + }) +}) diff --git a/mobile/src/session/pending-terminal-handle-recovery.test.ts b/mobile/src/session/pending-terminal-handle-recovery.test.ts index 1945cb72114..3fcb73e9c11 100644 --- a/mobile/src/session/pending-terminal-handle-recovery.test.ts +++ b/mobile/src/session/pending-terminal-handle-recovery.test.ts @@ -2,7 +2,12 @@ import { describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' import type { RpcResponse } from '../transport/types' import type { MobileSessionTab, SessionTabsResult } from './mobile-session-route-types' -import { hasPendingTerminalHandleRecoveryNeed } from './pending-terminal-handle-recovery' +import { + getPendingTerminalHandleRecoveryContextKey, + hasPendingTerminalHandleRecoveryNeed, + PendingTerminalHandleRecoveryBudget, + PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS +} from './pending-terminal-handle-recovery' import { MobileSessionTabsStreamHealth, type SessionTabsApplyOutcome @@ -62,6 +67,38 @@ describe('hasPendingTerminalHandleRecoveryNeed', () => { expect(hasPendingTerminalHandleRecoveryNeed([terminalTab(null)], null)).toBe(false) expect(hasPendingTerminalHandleRecoveryNeed([terminalTab(null)], 'gone')).toBe(false) }) + + it('keeps recovery contexts distinct when tab identifiers contain separators', () => { + const first = { ...terminalTab(null), id: 'a:b', parentTabId: 'c' } + const second = { ...terminalTab(null), id: 'a', parentTabId: 'b:c' } + + expect(getPendingTerminalHandleRecoveryContextKey([first], first.id)).not.toBe( + getPendingTerminalHandleRecoveryContextKey([second], second.id) + ) + }) +}) + +describe('PendingTerminalHandleRecoveryBudget', () => { + it('parks after the bounded attempt window and resets for a new terminal', () => { + const budget = new PendingTerminalHandleRecoveryBudget() + for (let attempt = 1; attempt <= PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS; attempt += 1) { + expect(budget.take('terminal-a')).toEqual({ + allowed: true, + parked: false + }) + } + expect(budget.take('terminal-a')).toEqual({ allowed: false, parked: true }) + expect(budget.take('terminal-b')).toEqual({ allowed: true, parked: false }) + }) + + it('resets the current terminal budget explicitly', () => { + const budget = new PendingTerminalHandleRecoveryBudget() + for (let attempt = 0; attempt < PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS; attempt += 1) { + budget.take('terminal-a') + } + budget.reset() + expect(budget.take('terminal-a')).toEqual({ allowed: true, parked: false }) + }) }) /** @@ -82,6 +119,7 @@ describe('pending-handle recovery through MobileSessionTabsStreamHealth', () => const client = { sendRequest, getGeneration: () => 1 } as unknown as RpcClient let tabs: MobileSessionTab[] = [] let activeTabId: string | null = null + const recoveryBudget = new PendingTerminalHandleRecoveryBudget() const controller = new MobileSessionTabsStreamHealth({ client, scope: 'id:repo::worktree', @@ -91,7 +129,9 @@ describe('pending-handle recovery through MobileSessionTabsStreamHealth', () => return { accepted: true, effectiveTabs: result.tabs } }, consumeAccepted: () => {}, - hasRecoveryNeed: () => hasPendingTerminalHandleRecoveryNeed(tabs, activeTabId) + hasRecoveryNeed: () => hasPendingTerminalHandleRecoveryNeed(tabs, activeTabId), + allowRecoveryPoll: () => + recoveryBudget.take(getPendingTerminalHandleRecoveryContextKey(tabs, activeTabId)).allowed }) return { controller, @@ -139,6 +179,88 @@ describe('pending-handle recovery through MobileSessionTabsStreamHealth', () => }) }) + it('parks the live poll after five pending-handle lists', async () => { + const harness = makeHarness() + await driveToLiveStream(harness, null) + + for (let attempt = 0; attempt < PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS; attempt += 1) { + const request = harness.controller.poll() + expect(request).not.toBeNull() + await settle() + harness.resolveNext(snapshot(null)) + await request + } + + expect(harness.sendRequest).toHaveBeenCalledTimes(PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS) + expect(harness.controller.poll()).toBeNull() + await settle() + expect(harness.sendRequest).toHaveBeenCalledTimes(PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS) + }) + + it('does not spend live fallback attempts while a slow request is coalesced', async () => { + const harness = makeHarness() + await driveToLiveStream(harness, null) + + for (let attempt = 0; attempt < PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS; attempt += 1) { + const request = harness.controller.poll() + expect(request).not.toBeNull() + for (let joinedTick = 0; joinedTick < 5; joinedTick += 1) { + expect(harness.controller.poll()).toBe(request) + } + expect(harness.sendRequest).toHaveBeenCalledTimes(attempt + 1) + harness.resolveNext(snapshot(null)) + await request + } + + expect(harness.controller.poll()).toBeNull() + expect(harness.sendRequest).toHaveBeenCalledTimes(PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS) + }) + + it.each(['probing', 'degraded'] as const)( + 'keeps polling a pending handle while stream health is %s', + async (health) => { + const harness = makeHarness() + harness.controller.setReconciliationActive(true) + const subscription = harness.controller.beginSubscription() + if (health === 'degraded') { + subscription.listener({ ...snapshot(null), type: 'end' }) + } + + for (let attempt = 0; attempt < PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS + 2; attempt += 1) { + const request = harness.controller.poll() + expect(request).not.toBeNull() + await settle() + harness.resolveNext(snapshot(null)) + await request + } + + expect(harness.sendRequest).toHaveBeenCalledTimes( + PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS + 2 + ) + } + ) + + it('starts a fresh retry and fences the half-open request result', async () => { + const harness = makeHarness() + await driveToLiveStream(harness, null) + + const halfOpen = harness.controller.poll() + expect(harness.sendRequest).toHaveBeenCalledTimes(1) + const retry = harness.controller.retryReconciliation() + expect(retry).not.toBe(halfOpen) + expect(harness.sendRequest).toHaveBeenCalledTimes(2) + expect(harness.controller.retryReconciliation()).toBe(retry) + expect(harness.sendRequest).toHaveBeenCalledTimes(2) + + harness.resolveNext(snapshot('stale-handle')) + await halfOpen + expect(harness.readTabs()[0]).toMatchObject({ terminal: null }) + + harness.resolveNext(snapshot('fresh-handle')) + await retry + expect(harness.readTabs()[0]).toMatchObject({ terminal: 'fresh-handle' }) + }) + it('stops polling once a fresh snapshot carries the handle', async () => { const harness = makeHarness() await driveToLiveStream(harness, null) diff --git a/mobile/src/session/pending-terminal-handle-recovery.ts b/mobile/src/session/pending-terminal-handle-recovery.ts index c3a2a850193..c0ec81a76fb 100644 --- a/mobile/src/session/pending-terminal-handle-recovery.ts +++ b/mobile/src/session/pending-terminal-handle-recovery.ts @@ -1,5 +1,74 @@ import type { MobileSessionTab } from './mobile-session-route-types' +export const PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS = 5 + +export type PendingTerminalHandleRecoveryAttempt = { + allowed: boolean + parked: boolean +} + +export class PendingTerminalHandleRecoveryBudget { + private contextKey: string | null = null + private remaining = PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS + + observeContext(contextKey: string | null): void { + if (contextKey === this.contextKey) { + return + } + this.contextKey = contextKey + this.remaining = PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS + } + + take(contextKey: string | null): PendingTerminalHandleRecoveryAttempt { + this.observeContext(contextKey) + if (contextKey === null) { + return { allowed: false, parked: false } + } + if (this.remaining === 0) { + return { allowed: false, parked: true } + } + this.remaining -= 1 + return { allowed: true, parked: false } + } + + reset(): void { + this.contextKey = null + this.remaining = PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS + } +} + +export class PendingTerminalHandleRecoveryContextCache { + private activeTabId: string | null = null + private contextKey: string | null = null + private initialized = false + private tabs: readonly MobileSessionTab[] | null = null + + read(tabs: readonly MobileSessionTab[], activeTabId: string | null): string | null { + if (this.initialized && tabs === this.tabs && activeTabId === this.activeTabId) { + return this.contextKey + } + this.initialized = true + this.tabs = tabs + this.activeTabId = activeTabId + this.contextKey = getPendingTerminalHandleRecoveryContextKey(tabs, activeTabId) + return this.contextKey + } +} + +export function getPendingTerminalHandleRecoveryContextKey( + tabs: readonly MobileSessionTab[], + activeTabId: string | null +): string | null { + if (activeTabId === null) { + return null + } + const active = tabs.find((tab) => tab.id === activeTabId) + if (active?.type !== 'terminal' || typeof active.terminal === 'string') { + return null + } + return JSON.stringify([active.id, active.parentTabId, active.leafId ?? null]) +} + /** * Whether the tab the user is looking at is a terminal the host published * without a PTY handle (`status: 'pending-handle'`). The session screen renders @@ -16,9 +85,5 @@ export function hasPendingTerminalHandleRecoveryNeed( tabs: readonly MobileSessionTab[], activeTabId: string | null ): boolean { - if (activeTabId === null) { - return false - } - const active = tabs.find((tab) => tab.id === activeTabId) - return active?.type === 'terminal' && typeof active.terminal !== 'string' + return getPendingTerminalHandleRecoveryContextKey(tabs, activeTabId) !== null } diff --git a/mobile/src/session/use-mobile-session-tabs-reconciliation.ts b/mobile/src/session/use-mobile-session-tabs-reconciliation.ts index 504d6c26fd1..277fc77ac47 100644 --- a/mobile/src/session/use-mobile-session-tabs-reconciliation.ts +++ b/mobile/src/session/use-mobile-session-tabs-reconciliation.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo } from 'react' +import { useCallback, useEffect, useMemo, useRef } from 'react' import { AppState } from 'react-native' import { useFocusEffect } from 'expo-router' import type { RpcClient } from '../transport/rpc-client' @@ -8,6 +8,7 @@ import { type SessionTabsApplyOutcome, type SessionTabsStreamSource } from './mobile-session-tabs-stream-health' +import { PendingTerminalHandleRecoveryBudget } from './pending-terminal-handle-recovery' type Params = { client: RpcClient | null @@ -21,6 +22,9 @@ type Params = { ) => void fetchTerminals: () => Promise hasRecoveryNeed: () => boolean + pendingTerminalRecoveryContextKey?: string | null + getPendingTerminalRecoveryContextKey?: () => string | null + onPendingTerminalRecoveryParked?: (contextKey: string | null) => void getApplicationRevision?: () => number onFetchStarted?: () => void onFetchSucceeded?: (result: Result) => void @@ -32,6 +36,7 @@ type ResultActions = { fetchSessionTabs: () => Promise ensureSessionTabs: () => Promise fetchPendingBrowserSessionTabs: () => Promise + retryPendingTerminalRecovery: () => Promise } const resolved = Promise.resolve() @@ -44,12 +49,46 @@ export function useMobileSessionTabsReconciliation({ consumeAcceptedSessionTabs, fetchTerminals, hasRecoveryNeed, + pendingTerminalRecoveryContextKey, + getPendingTerminalRecoveryContextKey, + onPendingTerminalRecoveryParked, getApplicationRevision, onFetchStarted, onFetchSucceeded, onFetchFailed, onFetchErrored }: Params): ResultActions { + const pendingTerminalRecoveryBudget = useMemo(() => new PendingTerminalHandleRecoveryBudget(), []) + const onPendingTerminalRecoveryParkedRef = useRef(onPendingTerminalRecoveryParked) + // Why: only poll/reset callbacks read this, and they run after commit — so writing it in + // render would let a discarded render leak a callback that never mounted. + useEffect(() => { + onPendingTerminalRecoveryParkedRef.current = onPendingTerminalRecoveryParked + }) + const combinedHasRecoveryNeed = useCallback(() => { + const contextKey = getPendingTerminalRecoveryContextKey?.() ?? null + pendingTerminalRecoveryBudget.observeContext(contextKey) + return hasRecoveryNeed() || contextKey !== null + }, [getPendingTerminalRecoveryContextKey, hasRecoveryNeed, pendingTerminalRecoveryBudget]) + const allowRecoveryPoll = useCallback(() => { + if (hasRecoveryNeed()) { + return true + } + const contextKey = getPendingTerminalRecoveryContextKey?.() ?? null + const attempt = pendingTerminalRecoveryBudget.take(contextKey) + if (attempt.parked) { + onPendingTerminalRecoveryParkedRef.current?.(contextKey) + } + return attempt.allowed + }, [getPendingTerminalRecoveryContextKey, hasRecoveryNeed, pendingTerminalRecoveryBudget]) + const resetPendingTerminalRecovery = useCallback(() => { + pendingTerminalRecoveryBudget.reset() + onPendingTerminalRecoveryParkedRef.current?.(null) + }, [pendingTerminalRecoveryBudget]) + useEffect(() => { + pendingTerminalRecoveryBudget.observeContext(pendingTerminalRecoveryContextKey ?? null) + onPendingTerminalRecoveryParkedRef.current?.(null) + }, [pendingTerminalRecoveryBudget, pendingTerminalRecoveryContextKey]) const controller = useMemo( () => client @@ -58,7 +97,8 @@ export function useMobileSessionTabsReconciliation({ scope: `id:${worktreeId}`, apply: applySessionTabs, consumeAccepted: consumeAcceptedSessionTabs, - hasRecoveryNeed, + hasRecoveryNeed: combinedHasRecoveryNeed, + allowRecoveryPoll: getPendingTerminalRecoveryContextKey ? allowRecoveryPoll : undefined, getApplicationRevision, onFetchStarted, onFetchSucceeded, @@ -71,7 +111,9 @@ export function useMobileSessionTabsReconciliation({ client, consumeAcceptedSessionTabs, getApplicationRevision, - hasRecoveryNeed, + allowRecoveryPoll, + combinedHasRecoveryNeed, + getPendingTerminalRecoveryContextKey, onFetchErrored, onFetchFailed, onFetchStarted, @@ -91,6 +133,7 @@ export function useMobileSessionTabsReconciliation({ if (!client || !controller || connState !== 'connected') { return } + resetPendingTerminalRecovery() const subscription = controller.beginSubscription() const unsubscribe = client.subscribe( 'session.tabs.subscribe', @@ -101,7 +144,7 @@ export function useMobileSessionTabsReconciliation({ subscription.cancel() unsubscribe() } - }, [client, connState, controller, worktreeId]) + }, [client, connState, controller, resetPendingTerminalRecovery, worktreeId]) useFocusEffect( useCallback(() => { @@ -123,19 +166,21 @@ export function useMobileSessionTabsReconciliation({ } const appStateSubscription = AppState.addEventListener('change', (state) => { if (state === 'active') { + resetPendingTerminalRecovery() refresh(true) } else { controller.setReconciliationActive(false) } }) const interval = setInterval(() => refresh(false), 2000) + resetPendingTerminalRecovery() refresh(true) return () => { controller.setReconciliationActive(false) clearInterval(interval) appStateSubscription.remove() } - }, [connState, controller, fetchTerminals]) + }, [connState, controller, fetchTerminals, resetPendingTerminalRecovery]) ) return { @@ -150,6 +195,10 @@ export function useMobileSessionTabsReconciliation({ fetchPendingBrowserSessionTabs: useCallback( () => controller?.requestPendingRecovery() ?? resolved, [controller] - ) + ), + retryPendingTerminalRecovery: useCallback(() => { + resetPendingTerminalRecovery() + return controller?.retryReconciliation() ?? resolved + }, [controller, resetPendingTerminalRecovery]) } } diff --git a/mobile/src/terminal/terminal-webview-html.ts b/mobile/src/terminal/terminal-webview-html.ts index c3ba29bb542..8d79ecfab67 100644 --- a/mobile/src/terminal/terminal-webview-html.ts +++ b/mobile/src/terminal/terminal-webview-html.ts @@ -752,7 +752,8 @@ ${TERMINAL_WEBGL_RECOVERY_JS} attachWebglAddon(true); if (window.Unicode11Addon && window.Unicode11Addon.Unicode11Addon) try { term.loadAddon(new window.Unicode11Addon.Unicode11Addon()); term.unicode.activeVersion = '11'; } catch (e) {} if (typeof replayData === 'string' && replayData.length > 0) { - enqueueWrite(replayData); + // Why no trailing reset: the snapshot pen belongs to the live host TUI receiving later output. + enqueueWrite(ESC + '[0m' + replayData); } // Why: reset eviction tracking + attach observers for the new term. diff --git a/mobile/src/terminal/terminal-webview-init-surface.test.ts b/mobile/src/terminal/terminal-webview-init-surface.test.ts index fdef4f5b4df..b32d7e6858e 100644 --- a/mobile/src/terminal/terminal-webview-init-surface.test.ts +++ b/mobile/src/terminal/terminal-webview-init-surface.test.ts @@ -26,7 +26,7 @@ type RegisteredWindowListener = { type: string } -function makeTerminal(writeCallbacks: Array<() => void>) { +function makeTerminal(writeCallbacks: Array<() => void>, writes: string[]) { const terminal = { cols: 80, rows: 24, @@ -45,7 +45,8 @@ function makeTerminal(writeCallbacks: Array<() => void>) { getLine: () => null } }, - write(_data: string, callback?: () => void) { + write(data: string, callback?: () => void) { + writes.push(data) if (callback) { writeCallbacks.push(callback) } @@ -93,6 +94,7 @@ describe('terminal WebView init surface replacement', () => { let terminalOptions: TerminalOptions[] let terminals: TerminalStub[] let writeCallbacks: Array<() => void> + let writes: string[] beforeEach(() => { animationFrames = [] @@ -100,6 +102,7 @@ describe('terminal WebView init surface replacement', () => { terminalOptions = [] terminals = [] writeCallbacks = [] + writes = [] const addWindowEventListener = window.addEventListener.bind(window) vi.spyOn(window, 'addEventListener').mockImplementation((( type: string, @@ -121,7 +124,7 @@ describe('terminal WebView init surface replacement', () => { } webWindow.Terminal = function (options: TerminalOptions) { terminalOptions.push(options) - const terminal = makeTerminal(writeCallbacks) + const terminal = makeTerminal(writeCallbacks, writes) terminals.push(terminal) return terminal } as unknown as new (options: TerminalOptions) => TerminalStub @@ -153,6 +156,13 @@ describe('terminal WebView init surface replacement', () => { } }) + it('grounds the initial replay without clearing the host live pen', () => { + dispatchInit(80, '\x1b[1mBOLD-RUN-LEFT-OPEN') + animationFrames.shift()?.() + + expect(writes).toEqual(['\x1b[0m\x1b[1mBOLD-RUN-LEFT-OPEN']) + }) + it('commits only the newest surface when phone-fit init calls overlap', () => { // Why: restored terminals can receive desktop scrollback, a phone resize, // and phone scrollback before any xterm replay callback has completed. diff --git a/mobile/src/terminal/terminal-webview-text-zoom.test.ts b/mobile/src/terminal/terminal-webview-text-zoom.test.ts index 09aeb82d3f7..cf16a93c0e6 100644 --- a/mobile/src/terminal/terminal-webview-text-zoom.test.ts +++ b/mobile/src/terminal/terminal-webview-text-zoom.test.ts @@ -144,7 +144,7 @@ describe('TerminalWebView text zoom', () => { expect(terminalHtmlSource).toContain('window.Unicode11Addon.Unicode11Addon') const open = terminalHtmlSource.indexOf('term.open(surface)') const unicode = terminalHtmlSource.indexOf("term.unicode.activeVersion = '11'") - const replay = terminalHtmlSource.indexOf('enqueueWrite(replayData)') + const replay = terminalHtmlSource.indexOf("enqueueWrite(ESC + '[0m' + replayData)") expect(open).toBeGreaterThanOrEqual(0) expect(unicode).toBeGreaterThan(open) expect(replay).toBeGreaterThan(unicode) diff --git a/mobile/src/transport/logical-client-connection-path.ts b/mobile/src/transport/logical-client-connection-path.ts index eb6cd4a06a6..cec69bc0118 100644 --- a/mobile/src/transport/logical-client-connection-path.ts +++ b/mobile/src/transport/logical-client-connection-path.ts @@ -17,8 +17,9 @@ export class LogicalClientConnectionPath { }) } - clearMigrationAfterConnected(): void { + clearAfterConnected(): void { this.migration = null + this.recovery = null } setRecovery(path: MobileConnectionPath | null): void { diff --git a/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts b/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts index 82ae186987f..affa4d12636 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts @@ -67,6 +67,7 @@ export class FakeLogicalClient extends FakeSession implements StableLogicalRpcCl private path: MobileConnectionPath private recoveryPath: MobileConnectionPath | null = null private generation = 1 + private readonly pathListeners = new Set<() => void>() constructor(state: ConnectionState, path: MobileConnectionPath) { super(state) @@ -90,17 +91,28 @@ export class FakeLogicalClient extends FakeSession implements StableLogicalRpcCl throw new Error('migration superseded') } this.path = path + this.recoveryPath = null this.generation += 1 + // Connected-state publication carries the migration cleanup. this.publishState('connected') } ) suspendActiveSession = vi.fn(() => this.publishState('disconnected')) getActivePath = () => this.path - getPendingPath = () => this.recoveryPath + getPendingPath = () => (this.getState() === 'connected' ? null : this.recoveryPath) setRecoveryPath = vi.fn((path: MobileConnectionPath | null) => { + const previous = this.getPendingPath() this.recoveryPath = path + if (previous !== this.getPendingPath()) { + for (const listener of this.pathListeners) { + listener() + } + } + }) + onConnectionPathChange = vi.fn((listener: () => void) => { + this.pathListeners.add(listener) + return () => this.pathListeners.delete(listener) }) - onConnectionPathChange = vi.fn(() => () => {}) getGeneration = () => this.generation } diff --git a/mobile/src/transport/mobile-endpoint-supervisor.test.ts b/mobile/src/transport/mobile-endpoint-supervisor.test.ts index 1f5686f2617..96a60f6e11d 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor.test.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor.test.ts @@ -91,6 +91,45 @@ describe('mobile endpoint supervisor', () => { supervisor.stop() }) + it('does not announce Relay when credential selection finishes after backgrounding', async () => { + const logical = new FakeLogicalClient('reconnecting', 'lan') + let finishCredentialRead: ((value: MobileRelayCredentialBundle) => void) | undefined + const credentialReadPending = new Promise((resolve) => { + finishCredentialRead = resolve + }) + const readBundle = vi + .fn() + .mockResolvedValueOnce(null) + .mockReturnValueOnce(credentialReadPending) + const deps = dependencies({ readBundle }) + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + + const starting = supervisor.start() + await vi.waitFor(() => expect(readBundle).toHaveBeenCalledTimes(2)) + supervisor.setForeground(false) + finishCredentialRead?.(bundle) + await starting + + expect(deps.openRelay).not.toHaveBeenCalled() + expect(logical.setRecoveryPath).not.toHaveBeenCalledWith('relay') + expect(logical.getPendingPath()).toBeNull() + supervisor.stop() + }) + + it('does not announce Relay without a dialable credential', async () => { + const logical = new FakeLogicalClient('reconnecting', 'lan') + const expired = { ...bundle, current: { ...bundle.current, expiresAt: Date.now() - 1 } } + const deps = dependencies({ readBundle: vi.fn(async () => expired) }) + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + + await supervisor.start() + + expect(deps.openRelay).not.toHaveBeenCalled() + expect(logical.setRecoveryPath).not.toHaveBeenCalledWith('relay') + expect(logical.getPendingPath()).toBeNull() + supervisor.stop() + }) + it('keeps Relay pending through a failed dial cooldown and clears it on stop', async () => { const logical = new FakeLogicalClient('reconnecting', 'lan') const deps = dependencies({ @@ -205,6 +244,7 @@ describe('mobile endpoint supervisor', () => { logical.publishState('disconnected') expect(openRelay).toHaveBeenCalledOnce() + expect(logical.getPendingPath()).toBe('relay') await vi.advanceTimersByTimeAsync(249) expect(openRelay).toHaveBeenCalledOnce() await vi.advanceTimersByTimeAsync(1) @@ -212,6 +252,22 @@ describe('mobile endpoint supervisor', () => { supervisor.stop() }) + it('does not announce recovery when an active Relay drops after credential expiry', async () => { + const logical = new FakeLogicalClient('connected', 'relay') + const expired = { ...bundle, current: { ...bundle.current, expiresAt: Date.now() - 1 } } + const deps = dependencies({ readBundle: vi.fn(async () => expired) }) + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + await supervisor.start() + + logical.publishState('disconnected') + await vi.advanceTimersByTimeAsync(0) + + expect(deps.openRelay).not.toHaveBeenCalled() + expect(logical.setRecoveryPath).not.toHaveBeenCalledWith('relay') + expect(logical.getPendingPath()).toBeNull() + supervisor.stop() + }) + it('escalates backoff when relay sessions connect and then drop repeatedly', async () => { const logical = new FakeLogicalClient('disconnected', 'lan') const openRelay = vi.fn(() => new FakeRelaySession('connected', new RelayOuterError(4408))) @@ -450,6 +506,7 @@ describe('mobile endpoint supervisor', () => { expect(openRelay).toHaveBeenCalledOnce() expect(deps.writeBundle).not.toHaveBeenCalled() + expect(logical.getPendingPath()).toBeNull() logical.publishState('connected') await vi.waitFor(() => expect(deps.writeBundle).toHaveBeenCalledOnce()) diff --git a/mobile/src/transport/mobile-endpoint-supervisor.ts b/mobile/src/transport/mobile-endpoint-supervisor.ts index 5f737282972..8292c871e35 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor.ts @@ -17,6 +17,7 @@ import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bund import { MobileEndpointNudgeRouter } from './mobile-endpoint-nudge-router' import { MobileRelayDirectGraceTimer } from './mobile-relay-direct-grace-timer' import { MobileRelaySessionEstablisher } from './mobile-relay-session-establisher' +import * as recoveryPresentation from './mobile-relay-recovery-presentation' import type { StableLogicalRpcClient } from './stable-logical-rpc-client' import type { ForegroundNudgeReason, HostProfile } from './types' @@ -146,7 +147,6 @@ export class MobileEndpointSupervisor { } this.unsubscribeState = this.logical.onStateChange((state) => { if (state === 'connected') { - this.logical.setRecoveryPath(null) this.directGrace.clear() if (this.logical.getActivePath() !== 'relay') { void this.rotateCredentialIfNeeded(this.relayReconnect.resetForDirectConnection()) @@ -155,16 +155,13 @@ export class MobileEndpointSupervisor { } else { // Why: the direct client enters reconnecting after its first failed // dial and may never publish disconnected while its retry loop lives. - if (this.relayReconnect.needsRecovery(state)) { - this.logical.setRecoveryPath('relay') - } + recoveryPresentation.onActiveFailure(this.logical, this.relayReconnect, state, this.bundle) this.relayReconnect.handleStateFailure(this.logical, state) } }) if (this.relayReconnect.needsRecovery(this.logical.getState())) { // Why: the first direct dial can fail while encrypted relay credentials // are still loading, before the supervisor subscribes to state changes. - this.logical.setRecoveryPath('relay') await this.recoverRelay() } else { this.directProbe.schedule() @@ -176,9 +173,6 @@ export class MobileEndpointSupervisor { const wasForeground = this.foreground this.foreground = foreground if (foreground) { - if (this.relayReconnect.needsRecovery(this.logical.getState())) { - this.logical.setRecoveryPath('relay') - } this.relayReconnect.handleForeground(this.logical, wasForeground) this.directProbe.schedule(0) this.directGrace.arm() @@ -193,13 +187,12 @@ export class MobileEndpointSupervisor { } } - nudge(reason: ForegroundNudgeReason): void { - this.nudgeRouter.nudge(reason) - } + nudge = (reason: ForegroundNudgeReason): void => this.nudgeRouter.nudge(reason) stop(): void { this.stopped = true this.unsubscribeState?.() + this.unsubscribeState = null this.directProbe.clear() this.relayReconnect.clear() this.leaseRotation.clear() @@ -244,7 +237,6 @@ export class MobileEndpointSupervisor { return } this.operationInFlight = true - let lastError: Error | null = null let retryAfterOperation = false try { const selection = await selectDialableRelayCredentials({ @@ -255,6 +247,7 @@ export class MobileEndpointSupervisor { }) this.bundle = selection.bundle if (selection.credentials.length === 0) { + this.logical.setRecoveryPath(null) // Why: "expired" vs "missing" separates a sleep-past-expiry phone // (needs re-pair or LAN) from a Keychain failure in field reports. this.logRelay( @@ -270,6 +263,12 @@ export class MobileEndpointSupervisor { } return } + const recoveryNeeded = + forceReplacement || this.relayReconnect.needsRecovery(this.logical.getState()) + if (this.stopped || !this.foreground || !recoveryNeeded) { + return + } + this.logical.setRecoveryPath('relay') const dialed = await this.sessionEstablisher.dialEligible(selection.credentials) if (dialed.outcome === 'established') { // Why: a fresh socket satisfies any replacement intent queued mid-dial. @@ -278,15 +277,16 @@ export class MobileEndpointSupervisor { return } if (dialed.outcome === 'aborted') { + this.logical.setRecoveryPath(null) // Why: direct won the race or the supervisor went inactive — not a // failure; booking backoff would delay the next genuine recovery. return } - lastError = dialed.error // Why: cleanup may happen while a relay dial is awaiting the network; // record its outcome without recreating a foreground retry timer. const scheduleRetry = (!forceReplacement || ownsRecovery) && this.foreground && !this.stopped - this.relayReconnect.registerFailure(lastError, scheduleRetry) + this.relayReconnect.registerFailure(dialed.error, scheduleRetry) + recoveryPresentation.clearIfCredentialBlocked(this.logical, this.relayReconnect) if (ownsRecovery) { suspendRelayIfStillConnected(this.relayReconnect, this.logical) } diff --git a/mobile/src/transport/mobile-relay-reconnect-controller.ts b/mobile/src/transport/mobile-relay-reconnect-controller.ts index 2955819ea4a..ff67750c0e0 100644 --- a/mobile/src/transport/mobile-relay-reconnect-controller.ts +++ b/mobile/src/transport/mobile-relay-reconnect-controller.ts @@ -7,9 +7,12 @@ import type { MobileRelayRpcSession } from './mobile-relay-rpc-session' import { MobileE2EEAuthenticationError } from './mobile-e2ee-v2-physical-channel' import { RelayOuterError } from './mobile-relay-e2ee-link' import { RELAY_STABLE_CONNECTION_MS, RelayRetryDelays } from './mobile-relay-retry-delays' +import { RelayCredentialEligibility } from './relay-credential-eligibility' import type { StableLogicalRpcClient } from './stable-logical-rpc-client' import type { ConnectionState, ForegroundNudgeReason } from './types' +type RelayCredentialLease = { expiresAt: number; version: number } + export type RelayReconnectDependencies = { now: () => number randomBytes: (length: number) => Uint8Array @@ -28,7 +31,7 @@ export class RelayReconnectController { private recoveryGate: RecoveryGate | null = null private gateReprobePending = false private gateReprobeStreak = 0 - private readonly rejectedCredentialVersions = new Set() + private readonly credentials: RelayCredentialEligibility private readonly delays: RelayRetryDelays constructor( @@ -36,6 +39,7 @@ export class RelayReconnectController { private readonly onRetry: (forceReplacement?: boolean) => void ) { this.delays = new RelayRetryDelays(dependencies.randomBytes) + this.credentials = new RelayCredentialEligibility(dependencies.now) } handleForeground(logical: StableLogicalRpcClient, wasForeground: boolean): void { @@ -105,7 +109,7 @@ export class RelayReconnectController { resetForDirectConnection(): boolean { const needsCredentialRefresh = - this.recoveryGate === 'fresh-credential' || this.rejectedCredentialVersions.size > 0 + this.recoveryGate === 'fresh-credential' || this.credentials.hasRejected() this.activeSession = null this.activeRelayConnectedAt = null if (needsCredentialRefresh) { @@ -126,22 +130,22 @@ export class RelayReconnectController { completeCredentialRefresh(): void { if (this.recoveryGate === 'fresh-credential') { - this.rejectedCredentialVersions.clear() + this.credentials.clearRejected() this.reset() } } - eligibleCredentials( + blocksUntilFreshCredential = (): boolean => this.recoveryGate === 'fresh-credential' + + hasDialableCredential(...credentials: (RelayCredentialLease | null | undefined)[]): boolean { + return this.credentials.hasDialable(...credentials) + } + + eligibleCredentials( ...credentials: Array ): T[] { - const eligible = credentials.filter((credential): credential is T => - Boolean( - credential && - credential.expiresAt > this.dependencies.now() && - !this.rejectedCredentialVersions.has(credential.version) - ) - ) - if (eligible.length === 0 && this.rejectedCredentialVersions.size > 0) { + const eligible = this.credentials.eligible(...credentials) + if (eligible.length === 0 && this.credentials.hasRejected()) { this.recoveryGate = 'fresh-credential' this.clearTimer() this.scheduleGateReprobe() @@ -152,7 +156,7 @@ export class RelayReconnectController { // For callers that found no dialable credential at all: keep a slow retry // alive so a later durable write can recover. armCredentialReprobe(): void { - if (this.rejectedCredentialVersions.size > 0) { + if (this.credentials.hasRejected()) { this.recoveryGate = 'fresh-credential' this.clearTimer() this.scheduleGateReprobe() @@ -176,14 +180,12 @@ export class RelayReconnectController { // A durable bundle whose current version is not rejected reopens the gate. acceptFreshCredential(version: number): void { - if (this.recoveryGate === 'fresh-credential' && !this.rejectedCredentialVersions.has(version)) { + if (this.recoveryGate === 'fresh-credential' && !this.credentials.isRejected(version)) { this.liftGate() } } - recordRejectedCredential(version: number): void { - this.rejectedCredentialVersions.add(version) - } + recordRejectedCredential = (version: number): void => this.credentials.recordRejected(version) registerActiveFailure(logical: StableLogicalRpcClient): void { if (logical.getActivePath() !== 'relay') { diff --git a/mobile/src/transport/mobile-relay-recovery-presentation.ts b/mobile/src/transport/mobile-relay-recovery-presentation.ts new file mode 100644 index 00000000000..549e8f07a82 --- /dev/null +++ b/mobile/src/transport/mobile-relay-recovery-presentation.ts @@ -0,0 +1,29 @@ +import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle' +import type { RelayReconnectController } from './mobile-relay-reconnect-controller' +import type { StableLogicalRpcClient } from './stable-logical-rpc-client' +import type { ConnectionState } from './types' + +export function onActiveFailure( + logical: StableLogicalRpcClient, + controller: RelayReconnectController, + state: ConnectionState, + bundle: MobileRelayCredentialBundle | null +): void { + if ( + controller.needsRecovery(state) && + logical.getActivePath() === 'relay' && + bundle && + controller.hasDialableCredential(bundle.current, bundle.grace) + ) { + logical.setRecoveryPath('relay') + } +} + +export function clearIfCredentialBlocked( + logical: StableLogicalRpcClient, + controller: RelayReconnectController +): void { + if (controller.blocksUntilFreshCredential()) { + logical.setRecoveryPath(null) + } +} diff --git a/mobile/src/transport/mobile-relay-runtime-failover.test.ts b/mobile/src/transport/mobile-relay-runtime-failover.test.ts index 56a3540153b..9ef0ea70c04 100644 --- a/mobile/src/transport/mobile-relay-runtime-failover.test.ts +++ b/mobile/src/transport/mobile-relay-runtime-failover.test.ts @@ -92,6 +92,7 @@ class FakeLogicalClient extends FakeSession implements StableLogicalRpcClient { private path: MobileConnectionPath private recoveryPath: MobileConnectionPath | null = null private generation = 1 + private readonly pathListeners = new Set<() => void>() constructor(state: ConnectionState, path: MobileConnectionPath) { super(state) @@ -106,15 +107,25 @@ class FakeLogicalClient extends FakeSession implements StableLogicalRpcClient { this.path = path this.recoveryPath = null this.generation += 1 + // Connected-state publication carries the migration cleanup. this.publishState('connected') }) suspendActiveSession = vi.fn(() => this.publishState('disconnected')) getActivePath = () => this.path - getPendingPath = () => this.recoveryPath + getPendingPath = () => (this.getState() === 'connected' ? null : this.recoveryPath) setRecoveryPath = vi.fn((path: MobileConnectionPath | null) => { + const previous = this.getPendingPath() this.recoveryPath = path + if (previous !== this.getPendingPath()) { + for (const listener of this.pathListeners) { + listener() + } + } + }) + onConnectionPathChange = vi.fn((listener: () => void) => { + this.pathListeners.add(listener) + return () => this.pathListeners.delete(listener) }) - onConnectionPathChange = vi.fn(() => () => {}) getGeneration = () => this.generation } @@ -247,12 +258,15 @@ describe('relay runtime recovery without direct connectivity', () => { await supervisor.start() await vi.advanceTimersByTimeAsync(0) expect(deps.openRelay).not.toHaveBeenCalled() + expect(logical.setRecoveryPath).not.toHaveBeenCalledWith('relay') + expect(logical.getPendingPath()).toBeNull() readBundle.mockImplementation(async () => bundleWith(3, Number.MAX_SAFE_INTEGER)) // Pre-fix, an expired bundle produced a silent no-op with nothing scheduled. await vi.advanceTimersByTimeAsync(60_000) expect(deps.openRelay).toHaveBeenCalledOnce() + expect(logical.setRecoveryPath).toHaveBeenCalledWith('relay') expect(logical.getActivePath()).toBe('relay') supervisor.stop() }) diff --git a/mobile/src/transport/relay-credential-eligibility.ts b/mobile/src/transport/relay-credential-eligibility.ts new file mode 100644 index 00000000000..e0c831975c9 --- /dev/null +++ b/mobile/src/transport/relay-credential-eligibility.ts @@ -0,0 +1,41 @@ +type RelayCredentialLease = { expiresAt: number; version: number } + +export class RelayCredentialEligibility { + private readonly rejectedVersions = new Set() + + constructor(private readonly now: () => number) {} + + hasRejected(): boolean { + return this.rejectedVersions.size > 0 + } + + clearRejected(): void { + this.rejectedVersions.clear() + } + + isRejected(version: number): boolean { + return this.rejectedVersions.has(version) + } + + recordRejected(version: number): void { + this.rejectedVersions.add(version) + } + + hasDialable(...credentials: (RelayCredentialLease | null | undefined)[]): boolean { + return credentials.some((credential) => this.isDialable(credential)) + } + + eligible(...credentials: (T | null | undefined)[]): T[] { + return credentials.filter((credential): credential is T => this.isDialable(credential)) + } + + private isDialable( + credential: T | null | undefined + ): credential is T { + return Boolean( + credential && + credential.expiresAt > this.now() && + !this.rejectedVersions.has(credential.version) + ) + } +} diff --git a/mobile/src/transport/stable-logical-rpc-client.test.ts b/mobile/src/transport/stable-logical-rpc-client.test.ts index 40829e17c6e..132530440ce 100644 --- a/mobile/src/transport/stable-logical-rpc-client.test.ts +++ b/mobile/src/transport/stable-logical-rpc-client.test.ts @@ -302,6 +302,17 @@ describe('stable logical RPC client', () => { expect(paths).toEqual(['relay', null]) }) + it('does not revive a stale recovery path after a connection later drops', () => { + const direct = new FakeSession('reconnecting') + const client = createStableLogicalRpcClient(direct, 'tailscale') + + client.setRecoveryPath('relay') + direct.setState('connected') + direct.setState('reconnecting') + + expect(client.getPendingPath()).toBeNull() + }) + it('drops the pending path when the previous session recovers mid-dial', async () => { const direct = new FakeSession('connected') const replacement = new FakeSession('connecting') diff --git a/mobile/src/transport/stable-logical-rpc-client.ts b/mobile/src/transport/stable-logical-rpc-client.ts index 5e3571704b3..0a1062eeadc 100644 --- a/mobile/src/transport/stable-logical-rpc-client.ts +++ b/mobile/src/transport/stable-logical-rpc-client.ts @@ -261,7 +261,7 @@ export function createStableLogicalRpcClient( } pendingRequests.clear() state = nextSession.getState() - connectionPath.clearMigrationAfterConnected() + connectionPath.clearAfterConnected() for (const listener of stateListeners) { listener(state) } @@ -321,6 +321,9 @@ export function createStableLogicalRpcClient( return } state = next + if (next === 'connected') { + connectionPath.clearAfterConnected() + } for (const listener of stateListeners) { listener(next) } diff --git a/native/computer-use-windows/runtime.ps1 b/native/computer-use-windows/runtime.ps1 index 2aa581901e6..4b68525c7c6 100644 --- a/native/computer-use-windows/runtime.ps1 +++ b/native/computer-use-windows/runtime.ps1 @@ -179,6 +179,7 @@ $MouseEvents = @{ MiddleDown = 0x0020 MiddleUp = 0x0040 Wheel = 0x0800 + HorizontalWheel = 0x01000 } function Write-OrcaJson($Payload) { @@ -1235,16 +1236,22 @@ function Invoke-OrcaOperation($Operation) { } "scroll" { $delta = 120 * [int][Math]::Ceiling((Get-OrcaPositiveNumber $Operation.pages "pages")) - if ($Operation.direction -eq "down" -or $Operation.direction -eq "right") { + $mouseEvent = $MouseEvents.Wheel + if ($Operation.direction -eq "down") { $delta = -1 * $delta - } elseif ($Operation.direction -ne "up" -and $Operation.direction -ne "left") { + } elseif ($Operation.direction -eq "left") { + $mouseEvent = $MouseEvents.HorizontalWheel + $delta = -1 * $delta + } elseif ($Operation.direction -eq "right") { + $mouseEvent = $MouseEvents.HorizontalWheel + } elseif ($Operation.direction -ne "up") { throw "unsupported scroll direction: $($Operation.direction)" } $point = Get-OrcaElementScreenPoint $element if ($null -eq $point) { $point = Get-OrcaScreenPoint $Operation $windowFrame } [void][OrcaDesktopWin32]::SetForegroundWindow($handle) [void][OrcaDesktopWin32]::SetCursorPos([int]$point.x, [int]$point.y) - [OrcaDesktopWin32]::mouse_event($MouseEvents.Wheel, 0, 0, $delta, [UIntPtr]::Zero) + [OrcaDesktopWin32]::mouse_event($mouseEvent, 0, 0, $delta, [UIntPtr]::Zero) $action = [pscustomobject]@{ path = "synthetic"; actionName = "scroll"; fallbackReason = $null } } "drag" { diff --git a/src/cli/index-terminal-list-host-scope.test.ts b/src/cli/index-terminal-list-host-scope.test.ts new file mode 100644 index 00000000000..b38cd71451f --- /dev/null +++ b/src/cli/index-terminal-list-host-scope.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it, vi } from 'vitest' + +const { + callMock, + runtimeClientConstructorMock, + serveOrcaAppMock, + getDefaultUserDataPathMock, + addEnvironmentFromPairingCodeMock, + listEnvironmentsMock, + spawnMock +} = vi.hoisted(() => ({ + callMock: vi.fn(), + runtimeClientConstructorMock: vi.fn(), + serveOrcaAppMock: vi.fn(), + getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'), + addEnvironmentFromPairingCodeMock: vi.fn(), + listEnvironmentsMock: vi.fn(), + spawnMock: vi.fn() +})) + +vi.mock('./runtime-client', async () => { + const { createRuntimeClientModuleMock } = await import('./index-test-harness.js') + return createRuntimeClientModuleMock({ + callMock, + runtimeClientConstructorMock, + serveOrcaAppMock, + getDefaultUserDataPathMock + }) +}) + +vi.mock('./runtime/environments', () => ({ + addEnvironmentFromPairingCode: addEnvironmentFromPairingCodeMock, + listEnvironments: listEnvironmentsMock, + removeEnvironment: vi.fn(), + resolveEnvironment: vi.fn() +})) + +vi.mock('child_process', async () => { + const { createChildProcessModuleMock } = await import('./index-test-harness.js') + return createChildProcessModuleMock(spawnMock) +}) + +import { main } from './index' +import { okFixture, queueFixtures } from './test-fixtures' +import { useWorktreeAwarenessEnvironment } from './index-test-harness' + +const REMOTE_ROW = { + handle: 'term_remote', + ptyId: 'ssh:box-1@@pty-7', + worktreeId: 'repo-ssh::/remote/wt', + worktreePath: '/remote/wt', + branch: 'main', + tabId: 'tab-1', + leafId: 'leaf-1', + title: 'worker', + connected: true, + writable: true, + lastOutputAt: null, + preview: '', + executionHostId: 'ssh:box-1' +} + +describe('orca terminal list host scope', () => { + useWorktreeAwarenessEnvironment({ + callMock, + serveOrcaAppMock, + getDefaultUserDataPathMock, + addEnvironmentFromPairingCodeMock, + listEnvironmentsMock, + spawnMock + }) + + it('keeps the execution host and scope in --json output', async () => { + queueFixtures( + callMock, + okFixture('req_terminal_list', { + terminals: [REMOTE_ROW], + totalCount: 1, + truncated: false, + hostScope: { hostIds: ['ssh:box-1'], omittedHostIds: ['local'] } + }) + ) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['terminal', 'list', '--json'], '/tmp/repo') + + const printed = JSON.parse(String(logSpy.mock.calls[0]?.[0])) + expect(printed.result.terminals[0].executionHostId).toBe('ssh:box-1') + expect(printed.result.hostScope).toEqual({ + hostIds: ['ssh:box-1'], + omittedHostIds: ['local'] + }) + }) + + it('prints the execution host and scope in human output', async () => { + queueFixtures( + callMock, + okFixture('req_terminal_list', { + terminals: [REMOTE_ROW], + totalCount: 1, + truncated: false, + hostScope: { hostIds: ['ssh:box-1'], omittedHostIds: ['local'] } + }) + ) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['terminal', 'list'], '/tmp/repo') + + const printed = String(logSpy.mock.calls[0]?.[0]) + expect(printed).toContain('host=ssh:box-1') + expect(printed).toContain('scope: ssh:box-1') + expect(printed).toContain('not covered: local') + }) + + it('does not claim a local scope when the host never reported one', async () => { + queueFixtures( + callMock, + okFixture('req_terminal_list', { terminals: [], totalCount: 0, truncated: false }) + ) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['terminal', 'list'], '/tmp/repo') + + const printed = String(logSpy.mock.calls[0]?.[0]) + expect(printed).toContain('scope: unverifiable') + expect(printed).not.toContain('scope: local') + }) +}) diff --git a/src/cli/terminal-format.ts b/src/cli/terminal-format.ts index b2c74b8c685..717f1d33701 100644 --- a/src/cli/terminal-format.ts +++ b/src/cli/terminal-format.ts @@ -3,6 +3,7 @@ import type { RuntimeTerminalClose, RuntimeTerminalCreate, RuntimeTerminalFocus, + RuntimeTerminalListHostScope, RuntimeTerminalListResult, RuntimeTerminalVisualLayout, RuntimeTerminalVisualLayoutNode, @@ -17,20 +18,34 @@ import type { } from '../shared/runtime-types' export function formatTerminalList(result: RuntimeTerminalListResult): string { + const scope = formatTerminalListHostScope(result.hostScope) if (result.terminals.length === 0) { - return 'No live terminals.' + return `No terminals listed.\n${scope}` } const body = result.terminals .map( (terminal) => - `${terminal.handle} ${terminal.title ?? '(untitled)'} ${terminal.connected ? 'connected' : 'disconnected'} ${terminal.worktreePath}\n${terminal.preview ? `preview: ${terminal.preview}` : 'preview: '}` + `${terminal.handle} ${terminal.title ?? '(untitled)'} ${terminal.connected ? 'connected' : 'disconnected'} host=${terminal.executionHostId ?? 'unverifiable'} ${terminal.worktreePath}\n${terminal.preview ? `preview: ${terminal.preview}` : 'preview: '}` ) .join('\n\n') const visualLayout = formatTerminalVisualLayouts(result.visualLayouts) const bodyWithLayout = visualLayout ? `${body}\n\nvisual layout:\n${visualLayout}` : body + const bodyWithScope = `${bodyWithLayout}\n\n${scope}` return result.truncated - ? `${bodyWithLayout}\n\ntruncated: showing ${result.terminals.length} of ${result.totalCount}` - : bodyWithLayout + ? `${bodyWithScope}\ntruncated: showing ${result.terminals.length} of ${result.totalCount}` + : bodyWithScope +} + +// Why: a listing that does not say what it covers reads as absolute, and an +// absent scope means the host is too old to know — not that it covered everything. +function formatTerminalListHostScope(scope: RuntimeTerminalListHostScope | undefined): string { + if (!scope) { + return 'scope: unverifiable — this host does not report which hosts it lists' + } + const covered = scope.hostIds.length > 0 ? scope.hostIds.join(', ') : 'none' + const omitted = + scope.omittedHostIds.length > 0 ? ` — not covered: ${scope.omittedHostIds.join(', ')}` : '' + return `scope: ${covered}${omitted}` } function formatTerminalVisualLayouts( diff --git a/src/cli/terminal-list-host-scope-format.test.ts b/src/cli/terminal-list-host-scope-format.test.ts new file mode 100644 index 00000000000..45b3230d898 --- /dev/null +++ b/src/cli/terminal-list-host-scope-format.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' +import { formatTerminalList } from './format' +import type { RuntimeTerminalListResult, RuntimeTerminalSummary } from '../shared/runtime-types' + +function terminal(overrides: Partial = {}): RuntimeTerminalSummary { + return { + handle: 'term_1', + ptyId: 'pty-1', + worktreeId: 'repo::/repo', + worktreePath: '/repo', + branch: 'main', + tabId: 'tab-1', + leafId: 'leaf-1', + title: 'worker', + connected: true, + writable: true, + lastOutputAt: null, + preview: '', + ...overrides + } +} + +function listResult(overrides: Partial = {}): RuntimeTerminalListResult { + return { terminals: [terminal()], totalCount: 1, truncated: false, ...overrides } +} + +describe('formatTerminalList host identity', () => { + it('prints the execution host each terminal runs on', () => { + const output = formatTerminalList( + listResult({ terminals: [terminal({ executionHostId: 'ssh:box-1' })] }) + ) + + expect(output).toContain('host=ssh:box-1') + }) + + it('prints unverifiable, not local, for a row whose host the runtime could not name', () => { + const output = formatTerminalList(listResult({ terminals: [terminal()] })) + + expect(output).toContain('host=unverifiable') + expect(output).not.toContain('host=local') + }) +}) + +describe('formatTerminalList scope declaration', () => { + it('states the covered and omitted hosts', () => { + const output = formatTerminalList( + listResult({ + hostScope: { hostIds: ['local'], omittedHostIds: ['ssh:box-1'] } + }) + ) + + expect(output).toContain('scope: local') + expect(output).toContain('not covered: ssh:box-1') + }) + + it('keeps an empty listing self-describing instead of reading as absolute', () => { + const output = formatTerminalList( + listResult({ + terminals: [], + totalCount: 0, + hostScope: { hostIds: ['local'], omittedHostIds: ['ssh:box-1'] } + }) + ) + + expect(output).toContain('No terminals listed') + expect(output).toContain('scope: local') + expect(output).toContain('not covered: ssh:box-1') + }) + + it('says the scope is unverifiable when the host predates the field', () => { + const output = formatTerminalList(listResult()) + + expect(output).toContain('scope: unverifiable') + expect(output).not.toContain('scope: local') + }) +}) diff --git a/src/main/agent-hooks/hook-status-session-tabs-invalidation.test.ts b/src/main/agent-hooks/hook-status-session-tabs-invalidation.test.ts index 14235654d37..5204c6b0b0b 100644 --- a/src/main/agent-hooks/hook-status-session-tabs-invalidation.test.ts +++ b/src/main/agent-hooks/hook-status-session-tabs-invalidation.test.ts @@ -43,6 +43,22 @@ describe('createHookStatusSessionTabsInvalidator', () => { expect(changed(working({}, payload))).toBe(true) }) + it('invalidates when the completion stamp is added, changed, or removed', () => { + const changed = createHookStatusSessionTabsInvalidator() + changed(working()) + + expect(changed(working({}, { turnCompletedAt: 100 }))).toBe(true) + expect(changed(working({}, { turnCompletedAt: 200 }))).toBe(true) + expect(changed(working())).toBe(true) + }) + + it('invalidates when the assistant body changes', () => { + const changed = createHookStatusSessionTabsInvalidator() + changed(working({}, { lastAssistantMessage: 'First answer' })) + + expect(changed(working({}, { lastAssistantMessage: 'Corrected answer' }))).toBe(true) + }) + it('ignores resume-identity rows, which the provider-session path owns', () => { const changed = createHookStatusSessionTabsInvalidator() diff --git a/src/main/agent-hooks/hook-status-session-tabs-invalidation.ts b/src/main/agent-hooks/hook-status-session-tabs-invalidation.ts index 59b259fa849..3aa90b530f4 100644 --- a/src/main/agent-hooks/hook-status-session-tabs-invalidation.ts +++ b/src/main/agent-hooks/hook-status-session-tabs-invalidation.ts @@ -28,7 +28,9 @@ export function createHookStatusSessionTabsInvalidator(): { (previous.agentType ?? null) !== (next.agentType ?? null) || (previous.toolName ?? null) !== (next.toolName ?? null) || (previous.interactivePrompt ?? null) !== (next.interactivePrompt ?? null) || - (previous.interrupted ?? false) !== (next.interrupted ?? false) + (previous.interrupted ?? false) !== (next.interrupted ?? false) || + (previous.turnCompletedAt ?? null) !== (next.turnCompletedAt ?? null) || + (previous.lastAssistantMessage ?? null) !== (next.lastAssistantMessage ?? null) ) } // Why: a cleared pane must re-arm, else the memo swallows the first event of the diff --git a/src/main/agent-hooks/installer-utils.test.ts b/src/main/agent-hooks/installer-utils.test.ts index 2013865aa96..b7930e7a378 100644 --- a/src/main/agent-hooks/installer-utils.test.ts +++ b/src/main/agent-hooks/installer-utils.test.ts @@ -71,7 +71,10 @@ describe('readHooksJsonWithRaw', () => { for (const contents of [`\uFEFF\uFEFF${body}`, ` \uFEFF${body}`, `{\uFEFF"hooks": {}}`]) { writeFileSync(configPath, contents, 'utf-8') - expect(readHooksJsonWithRaw(configPath)).toEqual({ raw: contents, config: null }) + expect(readHooksJsonWithRaw(configPath)).toEqual({ + raw: contents, + config: null + }) } }) @@ -82,7 +85,10 @@ describe('readHooksJsonWithRaw', () => { it('keeps the raw bytes when the contents are not a JSON object', () => { writeFileSync(configPath, 'not json\n', 'utf-8') - expect(readHooksJsonWithRaw(configPath)).toEqual({ raw: 'not json\n', config: null }) + expect(readHooksJsonWithRaw(configPath)).toEqual({ + raw: 'not json\n', + config: null + }) }) }) @@ -95,7 +101,9 @@ describe('writeHooksJson', () => { writeHooksJson(configPath, { hooks: { Stop: [] } }) expect(lstatSync(configPath).isSymbolicLink()).toBe(true) - expect(JSON.parse(readFileSync(targetPath, 'utf-8'))).toEqual({ hooks: { Stop: [] } }) + expect(JSON.parse(readFileSync(targetPath, 'utf-8'))).toEqual({ + hooks: { Stop: [] } + }) }) it('does not replace a dangling hook config symlink', () => { @@ -182,9 +190,15 @@ describe('writeHooksJson', () => { }) it('updates the .bak file to the previous version on each write', () => { - const v1: HooksConfig = { hooks: { Stop: [{ hooks: [{ type: 'command', command: 'v1' }] }] } } - const v2: HooksConfig = { hooks: { Stop: [{ hooks: [{ type: 'command', command: 'v2' }] }] } } - const v3: HooksConfig = { hooks: { Stop: [{ hooks: [{ type: 'command', command: 'v3' }] }] } } + const v1: HooksConfig = { + hooks: { Stop: [{ hooks: [{ type: 'command', command: 'v1' }] }] } + } + const v2: HooksConfig = { + hooks: { Stop: [{ hooks: [{ type: 'command', command: 'v2' }] }] } + } + const v3: HooksConfig = { + hooks: { Stop: [{ hooks: [{ type: 'command', command: 'v3' }] }] } + } writeHooksJson(configPath, v1) writeHooksJson(configPath, v2) @@ -256,6 +270,16 @@ describe('createManagedCommandMatcher', () => { expect(match(command)).toBe(true) }) + it('matches the pre-default-form launcher so upgrades replace it instead of duplicating', () => { + // Why: installs before the ${VAR-} conversion emitted bare $HOME/$SYSTEMROOT. The sweep must + // still recognize them, or an upgrade would leave the stale entry beside the new one. + expect( + match( + 'if [ -z "$HOME" ]; then :; else if [ -f "$HOME/.orca/agent-hooks/claude-hook.sh" ]; then /bin/sh "$HOME/.orca/agent-hooks/claude-hook.sh"; fi; fi' + ) + ).toBe(true) + }) + it('matches PowerShell and POSIX variants across Copilot platform switches', () => { const matchPosix = createManagedCommandMatcher('copilot-hook.sh') const matchPowerShell = createManagedCommandMatcher('copilot-hook.ps1') @@ -310,7 +334,10 @@ describe('removeManagedCommands', () => { [ { hooks: [ - { type: 'command', command: '/bin/sh "/path/agent-hooks/copilot-hook.sh"' }, + { + type: 'command', + command: '/bin/sh "/path/agent-hooks/copilot-hook.sh"' + }, { type: 'command', command: 'echo keep me' } ] } @@ -381,7 +408,14 @@ describe('hookDefinitionHasManagedCommand', () => { ).toBe(true) expect( hookDefinitionHasManagedCommand( - { hooks: [{ type: 'command', command: '/bin/sh "/path/agent-hooks/copilot-hook.sh"' }] }, + { + hooks: [ + { + type: 'command', + command: '/bin/sh "/path/agent-hooks/copilot-hook.sh"' + } + ] + }, match ) ).toBe(true) @@ -539,7 +573,7 @@ describe('wrapPosixHookCommand', () => { }) const qualifiedWindowsPowerShellCommand = - /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/ + /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand \S+$/ function decodeWindowsHookCommand(command: string): string { const encodedCommand = command.match(/ -EncodedCommand (\S+)$/)?.[1] @@ -549,7 +583,8 @@ function decodeWindowsHookCommand(command: string): string { function expectedDecodedWindowsHookCommand(scriptPath: string): string { const quoted = `'${scriptPath.replaceAll("'", "''")}'` - return `if (Test-Path -LiteralPath ${quoted} -PathType Leaf) { & ${quoted}; exit $LASTEXITCODE }; [Console]::In.ReadToEnd() | Out-Null; exit 0` + // Why: PowerShell progress CLIXML corrupts consumers that merge stderr into JSON stdout. + return `$ProgressPreference='SilentlyContinue'; if (Test-Path -LiteralPath ${quoted} -PathType Leaf) { & ${quoted}; exit $LASTEXITCODE }; [Console]::In.ReadToEnd() | Out-Null; exit 0` } describe('wrapWindowsHookCommand', () => { @@ -653,13 +688,29 @@ describe('wrapRuntimeHomeHookCommand', () => { const command = wrapRuntimeHomeHookCommand('claude-hook') expect(command).toContain('case "${OSTYPE-}" in msys*|cygwin*|win32*)') - expect(command).toContain('case "$HOME" in *\\&*|*\\^*|*\\(*|*\\)*|*\\;*|*,*|*=*|*%*|*\\!*)') + expect(command).toContain('case "${HOME-}" in *\\&*|*\\^*|*\\(*|*\\)*|*\\;*|*,*|*=*|*%*|*\\!*)') expect(command).not.toContain('uname') - expect(command).toContain('"$HOME/.orca/agent-hooks/claude-hook.cmd"') - expect(command).toContain('/bin/sh "$HOME/.orca/agent-hooks/claude-hook.sh"') + expect(command).toContain('"${HOME-}/.orca/agent-hooks/claude-hook.cmd"') + expect(command).toContain('/bin/sh "${HOME-}/.orca/agent-hooks/claude-hook.sh"') expect(command).not.toMatch(/[A-Z]:[\\/]|\/Users\/|\/home\//) }) + // Why: a static hook precheck (Grok) rejects the whole command on any bare reference it cannot + // resolve, including one in a branch that platform never takes. + it.each([ + ['default', undefined], + ['neutral-json', { neutralJsonWhenMissing: true }] + ])( + 'references every variable in default form (%s) so a static precheck cannot reject it', + (_label, options) => { + const command = wrapRuntimeHomeHookCommand('claude-hook', options) + + expect(command).toContain('"${SYSTEMROOT-}/System32/WindowsPowerShell/v1.0/powershell.exe"') + expect(command).not.toMatch(/\$(?!\{)[A-Za-z_]/) + expect(command).not.toMatch(/\$\{[A-Za-z_][A-Za-z0-9_]*\}/) + } + ) + it('rejects a script base name that could inject shell syntax', () => { expect(() => wrapRuntimeHomeHookCommand('claude-hook; echo injected')).toThrow( 'Invalid managed script base name' @@ -725,11 +776,32 @@ describe('wrapRuntimeHomeHookCommand', () => { process.platform === 'win32' ? join(process.env.ProgramFiles ?? 'C:\\Program Files', 'Git', 'bin', 'bash.exe') : '/bin/sh' - const result = spawnSync(shell, ['-c', command], { input: Buffer.alloc(1_000_000, 'x') }) + const result = spawnSync(shell, ['-c', command], { + input: Buffer.alloc(1_000_000, 'x') + }) expect(result.error).toBeUndefined() expect(result.status).toBe(0) }) + + it('emits neutral JSON when a lifecycle script is missing', () => { + const shell = + process.platform === 'win32' + ? join(process.env.ProgramFiles ?? 'C:\\Program Files', 'Git', 'bin', 'bash.exe') + : '/bin/sh' + const result = spawnSync( + shell, + ['-c', wrapRuntimeHomeHookCommand('missing-orca-hook', { neutralJsonWhenMissing: true })], + { + env: { ...process.env, HOME: tmpDir.replaceAll('\\', '/') }, + input: Buffer.alloc(1_000_000, 'x') + } + ) + + expect(result.error).toBeUndefined() + expect(result.status, result.stderr.toString()).toBe(0) + expect(JSON.parse(result.stdout.toString().trim())).toEqual({}) + }) }) describe('buildWindowsAgentHookPostCommand', () => { diff --git a/src/main/agent-hooks/installer-utils.ts b/src/main/agent-hooks/installer-utils.ts index 8861d41f0cc..0bab6e22c2a 100644 --- a/src/main/agent-hooks/installer-utils.ts +++ b/src/main/agent-hooks/installer-utils.ts @@ -16,6 +16,7 @@ import { grantDirAcl, isPermissionError } from '../win32-utils' import { POSIX_HOOK_STDIN_DRAIN_COMMAND } from './hook-stdin-contract' import { resolveHooksJsonWritePath } from './hook-config-write-path' import { writeRollingFileBackup } from '../rolling-file-backup' +import { wrapWindowsPowerShellEncodedCommand } from './windows-powershell-hook-launcher' export type HookCommandConfig = { type: 'command' @@ -120,16 +121,14 @@ export function wrapPosixHookCommand(scriptPath: string, env: Record `$env:${key} = ${quotePowerShellString(value)}; `) .join('') const command = `${envPrefix}if (Test-Path -LiteralPath ${quoted} -PathType Leaf) { & ${quoted}; exit $LASTEXITCODE }; [Console]::In.ReadToEnd() | Out-Null; exit 0` - const encodedCommand = Buffer.from(command, 'utf16le').toString('base64') - return `${getWindowsPowerShellExecutablePath()} -NoProfile -ExecutionPolicy Bypass -EncodedCommand ${encodedCommand}` + return wrapWindowsPowerShellEncodedCommand(command) } export const WINDOWS_CMD_SAFE_PATH = /^[A-Za-z0-9_.:\\~-]+$/ diff --git a/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts b/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts index f805e6c25f1..3232c702dfd 100644 --- a/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts +++ b/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts @@ -69,6 +69,7 @@ vi.mock('os', async (importOriginal) => { import { AntigravityHookService } from '../antigravity/hook-service' import { ClaudeHookService } from '../claude/hook-service' +import { getRemoteManagedCommand } from '../claude/hook-settings' import { CodexHookService } from '../codex/hook-service' import { CommandCodeHookService } from '../command-code/hook-service' import { CopilotHookService } from '../copilot/hook-service' @@ -155,6 +156,7 @@ const LOCAL_INSTALLERS = [ type HookRun = { exitCode: number | null stdinErrors: NodeJS.ErrnoException[] + stderr: string stdout: string } @@ -164,8 +166,9 @@ function runHookProcess( env: NodeJS.ProcessEnv ): Promise { return new Promise((resolve, reject) => { - const child = spawn(executable, args, { env, stdio: ['pipe', 'pipe', 'ignore'] }) + const child = spawn(executable, args, { env, stdio: ['pipe', 'pipe', 'pipe'] }) const stdinErrors: NodeJS.ErrnoException[] = [] + let stderr = '' let stdout = '' const timeout = setTimeout(() => { child.kill('SIGKILL') @@ -178,10 +181,13 @@ function runHookProcess( child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString() }) + child.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString() + }) child.stdin.on('error', (error: NodeJS.ErrnoException) => stdinErrors.push(error)) child.on('close', (exitCode) => { clearTimeout(timeout) - resolve({ exitCode, stdinErrors, stdout }) + resolve({ exitCode, stdinErrors, stderr, stdout }) }) child.stdin.end(LARGE_PAYLOAD) }) @@ -401,9 +407,75 @@ describe('Windows managed hook stdin structure', () => { } } ) + + // Why: command-shape tests missed conhost discarding the JSON consumers observe (#14818). + it.skipIf(process.platform !== 'win32')( + 'emits parseable JSON on stdout from the registered Claude hook command, through cmd.exe and Git Bash', + async () => { + const home = mkdtempSync(join(tmpdir(), 'orca-hook-stdout-json-')) + homedirMock.mockReturnValue(home) + try { + expect(new ClaudeHookService().install().state).toBe('installed') + const settings = JSON.parse( + readFileSync(join(home, '.claude', 'settings.json'), 'utf8') + ) as { hooks: Record } + const entry = settings.hooks.PreToolUse[0].hooks[0] + expect(entry.args).toBeUndefined() + + // Why: MSYS rewrites switches and paths, so the command must survive both shells (#14815). + const gitBash = findGitBash() + const shells = [ + { name: 'cmd.exe', executable: 'cmd.exe', args: ['/d', '/c', entry.command] }, + { name: 'Git Bash', executable: gitBash, args: ['-c', entry.command] } + ] + // Why: cover guard exit, reached curl, and the launcher's missing-script fallback. + const environments = [ + { name: 'no Orca env', env: hookEnvironment({ USERPROFILE: home }) }, + { + name: 'Orca env with dead listener', + env: hookEnvironment({ + USERPROFILE: home, + ORCA_AGENT_HOOK_PORT: '59999', + ORCA_AGENT_HOOK_TOKEN: 'token', + ORCA_PANE_KEY: 'tab:leaf' + }) + }, + { + name: 'missing managed script', + env: hookEnvironment({ USERPROFILE: join(home, 'absent') }) + } + ] + for (const shell of shells) { + for (const environment of environments) { + const label = `${shell.name} / ${environment.name}` + const result = await runHookProcess(shell.executable, shell.args, environment.env) + expect(result.exitCode, `${label} exit code`).toBe(0) + expect(result.stderr, `${label} stderr`).toBe('') + expect(() => JSON.parse(result.stdout.trim()), `${label} stdout is JSON`).not.toThrow() + expect(JSON.parse(result.stdout.trim()), `${label} stdout`).toEqual({}) + } + } + } finally { + homedirMock.mockImplementation(() => process.env.HOME ?? tmpdir()) + rmSync(home, { recursive: true, force: true }) + } + }, + // Why: six shell launches can overrun the default while the suite competes for cores. + 60_000 + ) }) describe.skipIf(process.platform === 'win32')('managed hook stdin lifecycle', () => { + it('emits neutral JSON when the Claude lifecycle script is missing', async () => { + const command = getRemoteManagedCommand('/home/dev/.orca/agent-hooks/claude-hook.sh') + const result = await runPosixHook(command) + + expect(result.exitCode).toBe(0) + expect(result.stdinErrors).toHaveLength(0) + expect(result.stderr).toBe('') + expect(JSON.parse(result.stdout.trim())).toEqual({}) + }) + it('captures stdin before every possible whole-script success exit', async () => { const scripts = await generatePosixScripts() for (const [agent, script] of scripts) { diff --git a/src/main/agent-hooks/runtime-home-hook-command.ts b/src/main/agent-hooks/runtime-home-hook-command.ts index 3ae887fc054..321c8d4adef 100644 --- a/src/main/agent-hooks/runtime-home-hook-command.ts +++ b/src/main/agent-hooks/runtime-home-hook-command.ts @@ -1,22 +1,34 @@ import { POSIX_HOOK_STDIN_DRAIN_COMMAND } from './hook-stdin-contract' +import { + encodeWindowsPowerShellHookCommand, + WINDOWS_POWERSHELL_HOOK_SWITCHES +} from './windows-powershell-hook-launcher' const MANAGED_SCRIPT_BASE_NAME = /^[A-Za-z0-9_-]+$/ const WINDOWS_GIT_BASH_RUNTIME_HOME_UNSAFE = '*\\&*|*\\^*|*\\(*|*\\)*|*\\;*|*,*|*=*|*%*|*\\!*' -export function wrapRuntimeHomeHookCommand(scriptBaseName: string): string { +export function wrapRuntimeHomeHookCommand( + scriptBaseName: string, + options: { neutralJsonWhenMissing?: boolean } = {} +): string { if (!MANAGED_SCRIPT_BASE_NAME.test(scriptBaseName)) { throw new Error(`Invalid managed script base name: ${scriptBaseName}`) } - const windowsScript = `"$HOME/.orca/agent-hooks/${scriptBaseName}.cmd"` - const posixScript = `"$HOME/.orca/agent-hooks/${scriptBaseName}.sh"` + // Why: default-form every var — a static hook precheck (Grok) rejects the whole command on a bare + // reference it cannot resolve, even in a branch that platform never takes. + const windowsScript = `"\${HOME-}/.orca/agent-hooks/${scriptBaseName}.cmd"` + const posixScript = `"\${HOME-}/.orca/agent-hooks/${scriptBaseName}.sh"` const drain = POSIX_HOOK_STDIN_DRAIN_COMMAND - const powershell = '"$SYSTEMROOT/System32/WindowsPowerShell/v1.0/powershell.exe"' - const powershellCommand = `$homePath = $env:HOME -replace '^/([A-Za-z])/', '$1:/'; $scriptPath = Join-Path $homePath '.orca\\agent-hooks\\${scriptBaseName}.cmd'; if (Test-Path -LiteralPath $scriptPath -PathType Leaf) { & $scriptPath; exit $LASTEXITCODE }; [Console]::In.ReadToEnd() | Out-Null; exit 0` - const encodedCommand = Buffer.from(powershellCommand, 'utf16le').toString('base64') - const powershellInvocation = `${powershell} -NoProfile -ExecutionPolicy Bypass -EncodedCommand ${encodedCommand}` - const encodedWindowsBranch = `if [ -f ${powershell} ]; then ${powershellInvocation}; else ${drain}; fi` - const windowsBranch = `if [ -f ${windowsScript} ]; then case "$HOME" in ${WINDOWS_GIT_BASH_RUNTIME_HOME_UNSAFE}) ${encodedWindowsBranch} ;; *) ${windowsScript} ;; esac; else ${drain}; fi` - const posixBranch = `if [ -f ${posixScript} ] && [ -r ${posixScript} ] && [ -x ${posixScript} ]; then /bin/sh ${posixScript}; else ${drain}; fi` + const missingScriptFallback = options.neutralJsonWhenMissing ? `${drain}; printf '{}\\n'` : drain + const powershell = '"${SYSTEMROOT-}/System32/WindowsPowerShell/v1.0/powershell.exe"' + const powershellFallback = options.neutralJsonWhenMissing ? "; Write-Output '{}'" : '' + const powershellCommand = `$homePath = $env:HOME -replace '^/([A-Za-z])/', '$1:/'; $scriptPath = Join-Path $homePath '.orca\\agent-hooks\\${scriptBaseName}.cmd'; if (Test-Path -LiteralPath $scriptPath -PathType Leaf) { & $scriptPath; exit $LASTEXITCODE }; [Console]::In.ReadToEnd() | Out-Null${powershellFallback}; exit 0` + const encodedCommand = encodeWindowsPowerShellHookCommand(powershellCommand) + // Why: the Git Bash and native Windows launchers must suppress windows identically (#14815). + const powershellInvocation = `${powershell} ${WINDOWS_POWERSHELL_HOOK_SWITCHES} -EncodedCommand ${encodedCommand}` + const encodedWindowsBranch = `if [ -f ${powershell} ]; then ${powershellInvocation}; else ${missingScriptFallback}; fi` + const windowsBranch = `if [ -f ${windowsScript} ]; then case "\${HOME-}" in ${WINDOWS_GIT_BASH_RUNTIME_HOME_UNSAFE}) ${encodedWindowsBranch} ;; *) ${windowsScript} ;; esac; else ${missingScriptFallback}; fi` + const posixBranch = `if [ -f ${posixScript} ] && [ -r ${posixScript} ] && [ -x ${posixScript} ]; then /bin/sh ${posixScript}; else ${missingScriptFallback}; fi` // Why: OSTYPE is shell-owned, so platform selection adds no process to every hook invocation. - return `if [ -z "\${HOME-}" ]; then ${drain}; else case "\${OSTYPE-}" in msys*|cygwin*|win32*) ${windowsBranch} ;; *) ${posixBranch} ;; esac; fi` + return `if [ -z "\${HOME-}" ]; then ${missingScriptFallback}; else case "\${OSTYPE-}" in msys*|cygwin*|win32*) ${windowsBranch} ;; *) ${posixBranch} ;; esac; fi` } diff --git a/src/main/agent-hooks/server-ingest-terminal-status.test.ts b/src/main/agent-hooks/server-ingest-terminal-status.test.ts index b6182529628..8661701e1dc 100644 --- a/src/main/agent-hooks/server-ingest-terminal-status.test.ts +++ b/src/main/agent-hooks/server-ingest-terminal-status.test.ts @@ -27,6 +27,97 @@ afterEach(() => { }) describe('AgentHookServer ingestTerminalStatus', () => { + it('preserves a hook turn stamp when an OSC repaint omits hook-only completion text', () => { + const server = new AgentHookServer() + const listener = vi.fn() + server.setListener(listener) + + server.ingestRemote( + { + paneKey: PANE, + payload: { state: 'working', prompt: 'review the PR', agentType: 'claude' } + }, + 'conn-1' + ) + listener.mockClear() + server.ingestRemote( + { + paneKey: PANE, + payload: { + state: 'working', + prompt: 'review the PR', + agentType: 'claude', + lastAssistantMessage: 'Review complete.', + turnCompletedAt: 1_700_000_005_000 + } + }, + 'conn-1' + ) + server.ingestTerminalStatus({ + paneKey: PANE, + connectionId: 'conn-1', + payload: { state: 'working', prompt: 'review the PR', agentType: 'claude' } + }) + + expect(listener).toHaveBeenCalledOnce() + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'working', + lastAssistantMessage: 'Review complete.', + turnCompletedAt: 1_700_000_005_000 + }) + + server.ingestTerminalStatus({ + paneKey: PANE, + connectionId: 'conn-1', + payload: { state: 'done', prompt: 'review the PR', agentType: 'claude' } + }) + + expect(listener).toHaveBeenCalledTimes(2) + expect(listener).toHaveBeenLastCalledWith( + expect.objectContaining({ payload: expect.objectContaining({ state: 'done' }) }) + ) + }) + + it('accepts an identical-prompt hook boundary after a stamped turn', () => { + const server = new AgentHookServer() + const listener = vi.fn() + server.setListener(listener) + + server.ingestRemote( + { + paneKey: PANE, + hookEventName: 'Stop', + payload: { + state: 'working', + prompt: 'check status', + agentType: 'claude', + turnCompletedAt: 1_700_000_005_000 + } + }, + 'conn-1' + ) + server.ingestRemote( + { + paneKey: PANE, + hookEventName: 'UserPromptSubmit', + payload: { state: 'working', prompt: 'check status', agentType: 'claude' } + }, + 'conn-1' + ) + + expect(listener).toHaveBeenCalledTimes(2) + expect(listener).toHaveBeenLastCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + state: 'working', + prompt: 'check status', + turnCompletedAt: undefined + }) + }) + ) + expect(server.getStatusSnapshot()[0]?.turnCompletedAt).toBeUndefined() + }) + // Why: the OSC 9999 payload cannot carry a provider session, so letting it overwrite the row // erased the session id from persisted rows and from headless `orca serve` — which serves these // rows straight to mobile — leaving Chat UI with no transcript to subscribe to (#10630). diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index f819414ad4b..48ff387fad4 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -416,7 +416,8 @@ function toAgentStatusIpcPayload(entry: EnrichedAgentHookEventPayload): AgentSta // Why: OSC never carries model/children; omit both so an equivalent OSC ping preserves the hook-cached identity graph. function equivalentParsedAgentStatusPayload( a: ParsedAgentStatusPayload, - b: ParsedAgentStatusPayload + b: ParsedAgentStatusPayload, + preserveActiveTurnStamp = false ): boolean { return ( a.state === b.state && @@ -425,11 +426,14 @@ function equivalentParsedAgentStatusPayload( a.toolName === b.toolName && a.toolInput === b.toolInput && a.interactivePrompt === b.interactivePrompt && - a.lastAssistantMessage === b.lastAssistantMessage && + (a.lastAssistantMessage === b.lastAssistantMessage || + (preserveActiveTurnStamp && b.lastAssistantMessage === undefined)) && a.interrupted === b.interrupted && // Why: a session-boundary done must never be deduped against a cached real done — // the flag has to reach receivers deterministically (STA-3386). - a.sessionBoundary === b.sessionBoundary + a.sessionBoundary === b.sessionBoundary && + (a.turnCompletedAt === b.turnCompletedAt || + (preserveActiveTurnStamp && b.turnCompletedAt === undefined)) ) } @@ -630,6 +634,7 @@ export class AgentHookServer { private assistantMessageRetryTimers = new Map>() private codexSubagentPollTimers = new Map>() private promptSentDedupeByPaneKey = new Map() + private activeHookTurnCompletedAtByPaneKey = new Map() private promptSentHashSalt = randomBytes(16).toString('hex') private closedAgentStatusTabIds = new Set() private closedAgentStatusPaneKeys = new Set() @@ -917,6 +922,9 @@ export class AgentHookServer { prompt: payload.prompt, agentType: payload.agentType, ...(restored.state === 'done' && restored.interrupted ? { interrupted: true } : {}), + ...(restored.turnCompletedAt !== undefined + ? { turnCompletedAt: restored.turnCompletedAt } + : {}), ...(payload.subagents ? { subagents: payload.subagents } : {}) } }) @@ -1138,6 +1146,10 @@ export class AgentHookServer { payload: AgentHookEventPayload, onAccepted?: () => void ): EnrichedAgentHookEventPayload { + if (payload.hookEventName === 'UserPromptSubmit') { + // Why: the prompt boundary is authoritative even when text is unchanged; its next OSC working row must not inherit the prior cron/background turn stamp. + this.activeHookTurnCompletedAtByPaneKey.delete(payload.paneKey) + } const previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as | EnrichedAgentHookEventPayload | undefined @@ -1277,6 +1289,15 @@ export class AgentHookServer { } const cachedPayload = resolveCachedClaudeCompactOwnership(previous, effectivePayload) const enriched = this.attachStatusTiming(cachedPayload, now) + if ( + typeof enriched.payload.turnCompletedAt === 'number' && + Number.isFinite(enriched.payload.turnCompletedAt) + ) { + this.activeHookTurnCompletedAtByPaneKey.set( + enriched.paneKey, + enriched.payload.turnCompletedAt + ) + } // Why: an identity-matched event can still leave the aggregate backed only by another restored child; keep liveness reconciliation eligible. if (enriched.restoredUnconfirmed) { this.runtimeObservedStatusPaneKeys.delete(enriched.paneKey) @@ -1591,6 +1612,11 @@ export class AgentHookServer { if (this.runtimeObservedStatusPaneKeys.delete(previousOwnerPaneKey)) { this.runtimeObservedStatusPaneKeys.add(toPaneKey) } + const activeTurnCompletedAt = this.activeHookTurnCompletedAtByPaneKey.get(previousOwnerPaneKey) + if (activeTurnCompletedAt !== undefined) { + this.activeHookTurnCompletedAtByPaneKey.delete(previousOwnerPaneKey) + this.activeHookTurnCompletedAtByPaneKey.set(toPaneKey, activeTurnCompletedAt) + } const authorityObservation = this.currentAuthorityObservations.get(previousOwnerPaneKey) if (authorityObservation) { const owner = parsePaneKey(toPaneKey) @@ -1646,6 +1672,7 @@ export class AgentHookServer { this.clearAssistantMessageRetry(key) this.clearCodexSubagentPoll(key) clearPaneCacheState(this.state, key) + this.activeHookTurnCompletedAtByPaneKey.delete(key) this.runtimeObservedStatusPaneKeys.delete(key) this.currentAuthorityObservations.delete(key) this.promptSentDedupeByPaneKey.delete(key) @@ -1679,6 +1706,7 @@ export class AgentHookServer { } this.legacyPaneKeyAliases.delete(legacyPaneKey) clearPaneCacheState(this.state, legacyPaneKey) + this.activeHookTurnCompletedAtByPaneKey.delete(legacyPaneKey) this.currentAuthorityObservations.delete(legacyPaneKey) this.promptSentDedupeByPaneKey.delete(legacyPaneKey) if (shouldClearStablePaneKey && this.state.lastStatusByPaneKey.has(entry.stablePaneKey)) { @@ -1688,6 +1716,7 @@ export class AgentHookServer { if (shouldClearStablePaneKey) { // Why: hydrated rows live under the stable key; if this PTY dies before ptyPaneKey rebuilds, alias cleanup is the only evictor. clearPaneCacheState(this.state, entry.stablePaneKey) + this.activeHookTurnCompletedAtByPaneKey.delete(entry.stablePaneKey) this.runtimeObservedStatusPaneKeys.delete(entry.stablePaneKey) this.currentAuthorityObservations.delete(entry.stablePaneKey) this.promptSentDedupeByPaneKey.delete(entry.stablePaneKey) @@ -1829,12 +1858,15 @@ export class AgentHookServer { const previous = this.state.lastStatusByPaneKey.get(paneKey) as | EnrichedAgentHookEventPayload | undefined + const preserveActiveTurnStamp = + previous?.payload.turnCompletedAt !== undefined && + previous.payload.turnCompletedAt === this.activeHookTurnCompletedAtByPaneKey.get(paneKey) if ( !previous?.restoredUnconfirmed && previous?.connectionId === connectionId && previous.tabId === tabId && previous.worktreeId === worktreeId && - equivalentParsedAgentStatusPayload(previous.payload, event.payload) + equivalentParsedAgentStatusPayload(previous.payload, event.payload, preserveActiveTurnStamp) ) { return } @@ -2331,6 +2363,7 @@ export class AgentHookServer { return null } this.state.lastStatusByPaneKey.delete(resolvedPaneKey) + this.activeHookTurnCompletedAtByPaneKey.delete(resolvedPaneKey) if (!options?.preserveAuthority) { this.hydratedLaunchTokenHashByPaneKey.delete(resolvedPaneKey) this.persistedAuthorityCommitmentsByPaneKey.delete(resolvedPaneKey) @@ -2411,6 +2444,7 @@ export class AgentHookServer { this.clearAssistantMessageRetry(paneKey) this.clearCodexSubagentPoll(paneKey) clearPaneCacheState(this.state, paneKey) + this.activeHookTurnCompletedAtByPaneKey.delete(paneKey) this.runtimeObservedStatusPaneKeys.delete(paneKey) this.currentAuthorityObservations.delete(paneKey) this.promptSentDedupeByPaneKey.delete(paneKey) @@ -2432,6 +2466,7 @@ export class AgentHookServer { this.clearAssistantMessageRetry(resolvedPaneKey) this.clearCodexSubagentPoll(resolvedPaneKey) clearPaneCacheState(this.state, resolvedPaneKey) + this.activeHookTurnCompletedAtByPaneKey.delete(resolvedPaneKey) this.currentAuthorityObservations.delete(resolvedPaneKey) this.promptSentDedupeByPaneKey.delete(resolvedPaneKey) let clearedAlias = false @@ -2441,6 +2476,7 @@ export class AgentHookServer { paneKeys.add(legacyPaneKey) paneKeys.add(stablePaneKey.stablePaneKey) clearPaneCacheState(this.state, legacyPaneKey) + this.activeHookTurnCompletedAtByPaneKey.delete(legacyPaneKey) this.currentAuthorityObservations.delete(legacyPaneKey) this.promptSentDedupeByPaneKey.delete(legacyPaneKey) clearedAlias = true diff --git a/src/main/agent-hooks/windows-powershell-hook-launcher.ts b/src/main/agent-hooks/windows-powershell-hook-launcher.ts new file mode 100644 index 00000000000..e3974ef8ff1 --- /dev/null +++ b/src/main/agent-hooks/windows-powershell-hook-launcher.ts @@ -0,0 +1,27 @@ +// Why: centralizing the launcher keeps window suppression consistent across installers (#14815). + +// Why: an absolute forward-slash path avoids PATH hijacking and survives cmd.exe and Git Bash. +export function getWindowsSystem32Path(relativePath: string): string { + const systemRoot = process.env.SystemRoot || 'C:\\Windows' + return `${systemRoot.replaceAll('\\', '/')}/System32/${relativePath}` +} + +export function getWindowsPowerShellExecutablePath(): string { + return getWindowsSystem32Path('WindowsPowerShell/v1.0/powershell.exe') +} + +// Why: unlike conhost, hidden PowerShell relays hook output and exit status (#14818). +export const WINDOWS_POWERSHELL_HOOK_SWITCHES = + '-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden' + +// Why: redirected PowerShell progress becomes CLIXML that can corrupt merged JSON output. +const HOOK_PROGRESS_SILENCER = "$ProgressPreference='SilentlyContinue'; " + +// Why: encoding shields paths and switches from cmd.exe and MSYS rewriting (#6078, #14815). +export function encodeWindowsPowerShellHookCommand(command: string): string { + return Buffer.from(`${HOOK_PROGRESS_SILENCER}${command}`, 'utf16le').toString('base64') +} + +export function wrapWindowsPowerShellEncodedCommand(command: string): string { + return `${getWindowsPowerShellExecutablePath()} ${WINDOWS_POWERSHELL_HOOK_SWITCHES} -EncodedCommand ${encodeWindowsPowerShellHookCommand(command)}` +} diff --git a/src/main/browser/anti-detection-permission-status.test.ts b/src/main/browser/anti-detection-permission-status.test.ts new file mode 100644 index 00000000000..d686e32c975 --- /dev/null +++ b/src/main/browser/anti-detection-permission-status.test.ts @@ -0,0 +1,210 @@ +import { runInNewContext } from 'node:vm' +import { describe, expect, it } from 'vitest' + +import { ANTI_DETECTION_SCRIPT } from './anti-detection' + +type PermissionQueryResult = EventTarget & { + state: string + onchange: EventListener | null + marker: string +} + +type PermissionStatusConstructor = { + new (): PermissionQueryResult + prototype: PermissionQueryResult +} + +type AntiDetectionContext = { + Notification: { + permission: string + requestPermission: (callback?: (permission: string) => void) => Promise + } + PermissionStatus: PermissionStatusConstructor + dispatchPermissionChange: (name: string) => void + navigator: { + permissions: { + query: (descriptor: { name: string }) => Promise + } + } +} + +function createContext(args: { + nativeNotificationPermission: string + requestedNotificationPermission: string + rejectedPermissions?: string[] +}): AntiDetectionContext & Record { + class PermissionStatus extends EventTarget { + #state = 'denied' + #onchange: EventListener | null = null + marker = 'real-status' + + get state(): string { + return this.#state + } + + get onchange(): EventListener | null { + return this.#onchange + } + + set onchange(listener: EventListener | null) { + if (this.#onchange) { + super.removeEventListener('change', this.#onchange) + } + this.#onchange = typeof listener === 'function' ? listener : null + if (this.#onchange) { + super.addEventListener('change', this.#onchange) + } + } + } + + const statuses = new Map() + const rejectedPermissions = new Set(args.rejectedPermissions) + + class Permissions { + query(descriptor: { name: string }): Promise { + if (rejectedPermissions.has(descriptor.name)) { + return Promise.reject(new Error('Unsupported permission')) + } + const status = new PermissionStatus() + const permissionStatuses = statuses.get(descriptor.name) ?? [] + permissionStatuses.push(status) + statuses.set(descriptor.name, permissionStatuses) + return Promise.resolve(status) + } + } + + const Notification = { + permission: args.nativeNotificationPermission, + requestPermission(callback?: (permission: string) => void): Promise { + callback?.(args.requestedNotificationPermission) + return Promise.resolve(args.requestedNotificationPermission) + } + } + Object.defineProperty(Notification, 'permission', { + configurable: true, + get: () => args.nativeNotificationPermission + }) + + return { + Date, + Event, + EventTarget, + Object, + Promise, + Set, + performance: { now: () => 0 }, + // Why: the script's Firefox gate reads navigator.userAgent, so a non-Firefox UA keeps these + // tests on the ordinary-page path where the PermissionStatus override applies. + window: { chrome: {} }, + navigator: { + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36', + plugins: [], + languages: [], + permissions: new Permissions() + }, + Permissions, + PermissionStatus, + Notification, + dispatchPermissionChange(name: string): void { + for (const status of statuses.get(name) ?? []) { + status.dispatchEvent(new Event('change')) + } + } + } as AntiDetectionContext & Record +} + +describe('ANTI_DETECTION_SCRIPT — PermissionStatus', () => { + it('keeps an existing notification status current after permission changes', async () => { + const context = createContext({ + nativeNotificationPermission: 'denied', + requestedNotificationPermission: 'granted' + }) + + runInNewContext(ANTI_DETECTION_SCRIPT, context) + const status = await context.navigator.permissions.query({ name: 'notifications' }) + + expect(context.Notification.permission).toBe('default') + expect(status.state).toBe('prompt') + + await expect(context.Notification.requestPermission()).resolves.toBe('granted') + + expect(context.Notification.permission).toBe('granted') + expect(status.state).toBe('granted') + }) + + it('preserves native PermissionStatus identity and methods', async () => { + const context = createContext({ + nativeNotificationPermission: 'denied', + requestedNotificationPermission: 'granted' + }) + + runInNewContext(ANTI_DETECTION_SCRIPT, context) + const status = await context.navigator.permissions.query({ name: 'camera' }) + const expectedSource = Function.prototype.toString.call( + context.PermissionStatus.prototype.addEventListener + ) + + expect(status).toBeInstanceOf(context.PermissionStatus) + expect(status.state).toBe('prompt') + expect(status.constructor.name).toBe('PermissionStatus') + expect(status.marker).toBe('real-status') + expect(status.addEventListener.name).toBe('addEventListener') + expect(status.addEventListener).toBe(status.addEventListener) + expect(Function.prototype.toString.call(status.addEventListener)).toBe(expectedSource) + expect(expectedSource).toContain('addEventListener') + }) + + it('delivers change events through the returned status with the overridden state', async () => { + const context = createContext({ + nativeNotificationPermission: 'denied', + requestedNotificationPermission: 'granted' + }) + + runInNewContext(ANTI_DETECTION_SCRIPT, context) + const status = await context.navigator.permissions.query({ name: 'notifications' }) + const events: { receiver: EventTarget; target: EventTarget | null; state: string }[] = [] + const recordEvent = function (this: EventTarget, event: Event): void { + events.push({ + receiver: this, + target: event.target, + state: (event.target as PermissionQueryResult).state + }) + } + + status.addEventListener('change', recordEvent) + expect(() => { + status.onchange = function (this: EventTarget, event): void { + recordEvent.call(this, event) + } + }).not.toThrow() + + await context.Notification.requestPermission() + context.dispatchPermissionChange('notifications') + + expect(events).toHaveLength(2) + expect(events).toEqual([ + { receiver: status, target: status, state: 'granted' }, + { receiver: status, target: status, state: 'granted' } + ]) + }) + + // Why: 'camera' rather than 'storage-access' — #14685 narrowed the intercepted set to + // camera/microphone, so a name outside it falls through to the real query and never reaches + // the fallback at all. + it('uses a non-enumerable EventTarget fallback when the native query rejects', async () => { + const context = createContext({ + nativeNotificationPermission: 'denied', + requestedNotificationPermission: 'granted', + rejectedPermissions: ['camera'] + }) + + runInNewContext(ANTI_DETECTION_SCRIPT, context) + const status = await context.navigator.permissions.query({ name: 'camera' }) + + expect(status).toBeInstanceOf(EventTarget) + expect(status).not.toBeInstanceOf(context.PermissionStatus) + expect(status.state).toBe('prompt') + expect(Object.keys(status)).toEqual([]) + }) +}) diff --git a/src/main/browser/anti-detection.ts b/src/main/browser/anti-detection.ts index b955b200229..d725fbf965f 100644 --- a/src/main/browser/anti-detection.ts +++ b/src/main/browser/anti-detection.ts @@ -86,12 +86,43 @@ export const ANTI_DETECTION_SCRIPT = `(function() { 'camera', 'microphone' ]); const origQuery = Permissions.prototype.query; + // Why: sites must receive the genuine PermissionStatus so native events, brand checks and method + // identity survive. Shadow only state, and resolve it lazily so existing statuses stay current. + function withOverriddenState(realStatus, stateProvider) { + Object.defineProperty(realStatus, 'state', { + configurable: true, + get: stateProvider + }); + return realStatus; + } + // Why: some names the real implementation rejects outright; fall back to an EventTarget so + // listener registration still works instead of throwing. + function fallbackStatus(stateProvider) { + const status = new EventTarget(); + Object.defineProperties(status, { + state: { configurable: true, get: stateProvider }, + onchange: { configurable: true, value: null, writable: true } + }); + return status; + } + function queryWithState(permissions, desc, stateProvider) { + let real; + try { + real = origQuery.call(permissions, desc); + } catch { + return Promise.resolve(fallbackStatus(stateProvider)); + } + return Promise.resolve(real).then( + (status) => withOverriddenState(status, stateProvider), + () => fallbackStatus(stateProvider) + ); + } Permissions.prototype.query = function(desc) { if (desc.name === 'notifications') { - return Promise.resolve({ state: notificationPermissionState(), onchange: null }); + return queryWithState(this, desc, notificationPermissionState); } if (promptPerms.has(desc.name)) { - return Promise.resolve({ state: 'prompt', onchange: null }); + return queryWithState(this, desc, () => 'prompt'); } return origQuery.call(this, desc); }; diff --git a/src/main/browser/browser-cookie-import-app-bound-prefix.test.ts b/src/main/browser/browser-cookie-import-app-bound-prefix.test.ts new file mode 100644 index 00000000000..0109269248b --- /dev/null +++ b/src/main/browser/browser-cookie-import-app-bound-prefix.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { isAppBoundEncryptedCookie } from './browser-cookie-import' + +function encryptedCookie(versionPrefix: string): Buffer { + return Buffer.concat([Buffer.from(versionPrefix), Buffer.from([0xde, 0xad, 0xbe, 0xef])]) +} + +describe('isAppBoundEncryptedCookie', () => { + it('detects the v20 app-bound prefix', () => { + expect(isAppBoundEncryptedCookie(encryptedCookie('v20'))).toBe(true) + }) + + it.each(['v10', 'v11'])('leaves the decryptable %s prefix alone', (prefix) => { + expect(isAppBoundEncryptedCookie(encryptedCookie(prefix))).toBe(false) + }) + + it('does not treat a plaintext value as app-bound', () => { + expect(isAppBoundEncryptedCookie(Buffer.from('session=abc123'))).toBe(false) + }) + + it.each([ + ['empty', Buffer.alloc(0)], + ['shorter than the prefix', Buffer.from('v2')] + ])('handles a buffer %s without throwing', (_label, buffer) => { + expect(isAppBoundEncryptedCookie(buffer)).toBe(false) + }) +}) diff --git a/src/main/browser/browser-cookie-import-undecryptable.test.ts b/src/main/browser/browser-cookie-import-undecryptable.test.ts new file mode 100644 index 00000000000..e79485bef20 --- /dev/null +++ b/src/main/browser/browser-cookie-import-undecryptable.test.ts @@ -0,0 +1,259 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as NodeCrypto from 'node:crypto' +import type * as NodeFs from 'node:fs' + +const { + appGetPathMock, + createDecipherivMock, + execFileSyncMock, + sessionFromPartitionMock, + dialogShowOpenDialogMock, + setPendingCookieImportMock, + clearPendingCookieImportMock +} = vi.hoisted(() => ({ + appGetPathMock: vi.fn(), + createDecipherivMock: vi.fn(), + execFileSyncMock: vi.fn(), + sessionFromPartitionMock: vi.fn(), + dialogShowOpenDialogMock: vi.fn(), + setPendingCookieImportMock: vi.fn(), + clearPendingCookieImportMock: vi.fn() +})) + +vi.mock('node:crypto', async (importOriginal) => { + const original = await importOriginal() + createDecipherivMock.mockImplementation(original.createDecipheriv) + return { ...original, createDecipheriv: createDecipherivMock } +}) +vi.mock('./browser-session-registry', () => ({ + browserSessionRegistry: { + setPendingCookieImport: setPendingCookieImportMock, + clearPendingCookieImport: clearPendingCookieImportMock + } +})) +vi.mock('node:child_process', () => ({ execFileSync: execFileSyncMock })) +vi.mock('node:fs', async (importOriginal) => ({ ...(await importOriginal()) })) +vi.mock('electron', () => ({ + app: { getPath: appGetPathMock }, + BrowserWindow: { fromWebContents: vi.fn() }, + dialog: { showOpenDialog: dialogShowOpenDialogMock }, + session: { fromPartition: sessionFromPartitionMock } +})) +vi.mock('./browser-cookie-clear-store', () => ({ + openCookieClearStore: (targetSession: { + cookies: { + get: (filter: object) => Promise + remove: (url: string, name: string) => Promise + } + }) => ({ + get: (filter: object) => targetSession.cookies.get(filter), + remove: (url: string, name: string) => targetSession.cookies.remove(url, name), + snapshotClearIdentities: async (items: { cookie: Record; url: string }[]) => + items.map(({ cookie, url }) => ({ url, ...cookie })), + restoreClearIdentities: async () => undefined, + dispose: () => undefined + }) +})) + +import { importCookiesFromBrowser, type DetectedBrowser } from './browser-cookie-import' +import { createChromiumCookieTestDatabase } from './browser-cookie-import-test-database' +import { mkdtempSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { createCipheriv, pbkdf2Sync } from 'node:crypto' + +// Why: Linux derives its cookie key with a single PBKDF2 round, unlike the 1003 macOS uses, so +// the shared macOS helper cannot produce a row this code path will decrypt. +function encryptLinuxChromiumCookie(value: string, password: string, prefix: string): Buffer { + const key = pbkdf2Sync(password, 'saltysalt', 1, 16, 'sha1') + const cipher = createCipheriv('aes-128-cbc', key, Buffer.alloc(16, ' ')) + return Buffer.concat([ + Buffer.from(prefix), + cipher.update(Buffer.from(value, 'latin1')), + cipher.final() + ]) +} + +function chromeBrowser(cookiesPath: string): DetectedBrowser { + return { + family: 'chrome', + label: 'Google Chrome', + cookiesPath, + keychainService: 'Chrome Safe Storage', + keychainAccount: 'Chrome', + profiles: [{ name: 'Default', directory: 'Default' }], + selectedProfile: 'Default' + } +} + +describe('importCookiesFromBrowser — undecryptable cookies', () => { + let tmpDir: string + let cookiesSetMock: ReturnType + let platformSpy: ReturnType + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'orca-linux-keyring-test-')) + cookiesSetMock = vi.fn().mockResolvedValue(undefined) + appGetPathMock.mockReturnValue(join(tmpDir, 'userData')) + sessionFromPartitionMock.mockReturnValue({ + cookies: { + get: vi.fn().mockResolvedValue([]), + set: cookiesSetMock, + remove: vi.fn().mockResolvedValue(undefined), + flushStore: vi.fn().mockResolvedValue(undefined) + }, + clearData: vi.fn().mockResolvedValue(undefined), + setUserAgent: vi.fn() + }) + platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') + // Why: both secret-tool lookups failing is exactly the keyring-unavailable case. + execFileSyncMock.mockImplementation(() => { + throw new Error('secret-tool: no such service') + }) + }) + + afterEach(() => { + platformSpy.mockRestore() + vi.clearAllMocks() + rmSync(tmpDir, { recursive: true, force: true }) + }) + + function seedProfile(...encryptedValues: Buffer[]): string { + const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies') + createChromiumCookieTestDatabase( + sourceCookiesPath, + encryptedValues.map((encryptedValue, index) => ({ + name: `sid-${index}`, + value: '', + encryptedValue + })) + ).close() + createChromiumCookieTestDatabase( + join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies'), + [] + ).close() + return sourceCookiesPath + } + + it('does not attempt v11 CBC decryption without a keyring, even with valid wrong-key padding', async () => { + const sourceCookiesPath = seedProfile( + encryptLinuxChromiumCookie('wrong-key-garbage', 'peanuts', 'v11') + ) + + const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test') + + expect(createDecipherivMock).not.toHaveBeenCalled() + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + expect(result.summary.importedCookies).toBe(0) + expect(result.summary.warning).toEqual({ + code: 'cookies-undecryptable', + failedCookies: 1, + reason: 'linux-keyring-unavailable' + }) + expect(cookiesSetMock).not.toHaveBeenCalled() + }) + + it.each(['v10', 'v99'])( + 'rejects a prefix-only %s CBC row before importing it', + async (prefix) => { + const sourceCookiesPath = seedProfile(Buffer.from(prefix)) + + const result = await importCookiesFromBrowser( + chromeBrowser(sourceCookiesPath), + 'persist:test' + ) + + expect(createDecipherivMock).not.toHaveBeenCalled() + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + expect(result.summary.importedCookies).toBe(0) + expect(result.summary.warning).toEqual({ + code: 'cookies-undecryptable', + failedCookies: 1, + reason: 'unknown' + }) + expect(cookiesSetMock).not.toHaveBeenCalled() + } + ) + + it('reports Windows app-bound (v20) cookies rather than an empty success', async () => { + // Why: Chrome/Edge 140+ v20 rows can only be unwrapped by the writing browser. + const sourceCookiesPath = seedProfile( + Buffer.concat([Buffer.from('v20'), Buffer.from([1, 2, 3, 4, 5, 6, 7, 8])]) + ) + + const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test') + + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + expect(result.summary.importedCookies).toBe(0) + expect(result.summary.warning).toEqual({ + code: 'cookies-undecryptable', + failedCookies: 1, + reason: 'app-bound-encryption' + }) + }) + + it('still imports v10 cookies, which is what a keyring-less profile actually holds', async () => { + const sourceCookiesPath = seedProfile(encryptLinuxChromiumCookie('v10-value', 'peanuts', 'v10')) + + const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test') + + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + // Mutation guard: replacing the keyring-unavailable branch with `return null` must break v10. + expect(result.summary.importedCookies).toBe(1) + expect(result.summary.warning).toBeUndefined() + expect(cookiesSetMock).toHaveBeenCalledWith( + expect.objectContaining({ name: 'sid-0', value: 'v10-value' }) + ) + }) + + it('uses an unknown warning when app-bound and corrupt failures are tied', async () => { + const sourceCookiesPath = seedProfile( + Buffer.concat([Buffer.from('v20'), Buffer.from([1, 2, 3, 4])]), + Buffer.from('v99-corrupt') + ) + + const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test') + + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + expect(result.summary.warning).toEqual({ + code: 'cookies-undecryptable', + failedCookies: 2, + reason: 'unknown' + }) + }) + + it('reports a dominant app-bound cause with its exact count and the remainder', async () => { + const appBoundValues = Array.from({ length: 200 }, () => + Buffer.concat([Buffer.from('v20'), Buffer.from([1, 2, 3, 4])]) + ) + const sourceCookiesPath = seedProfile(...appBoundValues, Buffer.from('v99-corrupt')) + + const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test') + + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + expect(result.summary.warning).toEqual({ + code: 'cookies-undecryptable', + failedCookies: 200, + otherFailedCookies: 1, + reason: 'app-bound-encryption' + }) + }) +}) diff --git a/src/main/browser/browser-cookie-import.ts b/src/main/browser/browser-cookie-import.ts index 2e2f50dbdd1..581b2b2222b 100644 --- a/src/main/browser/browser-cookie-import.ts +++ b/src/main/browser/browser-cookie-import.ts @@ -823,12 +823,13 @@ function chromiumTimestampToUnix(chromiumTs: bigint | number | string): number { // Why: each platform protects the Chromium key differently: macOS/Linux PBKDF2→AES-128-CBC, Windows DPAPI→AES-256-GCM. -type EncryptionKeyResult = { - key: Buffer - mode: 'aes-128-cbc' | 'aes-256-gcm' - // Why: Linux v10 cookies use "peanuts" and v11 the keyring password; both keys are needed to decrypt the full set. - fallbackKey?: Buffer -} +type EncryptionKeyResult = + | { + mode: 'aes-128-cbc' + keysByVersion: Partial> + keyringUnavailable?: boolean + } + | { mode: 'aes-256-gcm'; key: Buffer } export type ChromiumCookieColumnInfo = { name: string @@ -971,8 +972,10 @@ function getMacEncryptionKey( { encoding: 'utf-8', timeout: 30_000 } ).trim() return { - key: pbkdf2Sync(raw, PBKDF2_SALT, PBKDF2_ITERATIONS, PBKDF2_KEY_LENGTH, 'sha1'), - mode: 'aes-128-cbc' + mode: 'aes-128-cbc', + keysByVersion: { + v10: pbkdf2Sync(raw, PBKDF2_SALT, PBKDF2_ITERATIONS, PBKDF2_KEY_LENGTH, 'sha1') + } } } catch { return null @@ -983,7 +986,8 @@ function getLinuxEncryptionKey( keychainService: string, keychainAccount: string ): EncryptionKeyResult | null { - // Why: v10 cookies use hardcoded "peanuts", v11 the keyring password; derive both so decrypt can pick by version prefix. + // Chromium uses v11 only with OS key storage; without it, Linux writes v10 with hardcoded + // "peanuts". Keep eligibility explicit because CBC cannot authenticate a wrong-key result. const v10Key = pbkdf2Sync('peanuts', PBKDF2_SALT, 1, PBKDF2_KEY_LENGTH, 'sha1') let keyringPassword = '' @@ -1003,12 +1007,20 @@ function getLinuxEncryptionKey( timeout: 5_000 }).trim() } catch { - diag(' Linux keyring unavailable — v11 cookies may fail to decrypt') + diag(' Linux keyring unavailable — v11 cookies cannot be decrypted') + } + } + + if (!keyringPassword) { + return { + mode: 'aes-128-cbc', + keysByVersion: { v10: v10Key }, + keyringUnavailable: true } } const v11Key = pbkdf2Sync(keyringPassword, PBKDF2_SALT, 1, PBKDF2_KEY_LENGTH, 'sha1') - return { key: v11Key, mode: 'aes-128-cbc', fallbackKey: v10Key } + return { mode: 'aes-128-cbc', keysByVersion: { v10: v10Key, v11: v11Key } } } function getWindowsEncryptionKey(browser: DetectedBrowser): EncryptionKeyResult | null { @@ -1084,6 +1096,53 @@ function stripHmac(buf: Buffer): Buffer { return hasHmacPrefix(buf) ? buf.subarray(CHROMIUM_COOKIE_HMAC_LEN) : buf } +// Why: the version prefix is the only thing that survives a failed decrypt, so read it once and +// share it between the decrypt path and the failure attribution. +function cookieEncryptionVersion(encryptedBuffer: Buffer): string | null { + if (encryptedBuffer.length < 3) { + return null + } + const version = encryptedBuffer.subarray(0, 3).toString('utf-8') + return /^v\d\d$/.test(version) ? version : null +} + +// Why: Chrome/Edge 140+ on Windows prefix every cookie with `v20` (app-bound encryption), which +// only the writing browser can unwrap. Classify it before decrypt so it is not folded into corruption. +export function isAppBoundEncryptedCookie(encryptedBuffer: Buffer): boolean { + return cookieEncryptionVersion(encryptedBuffer) === 'v20' +} + +// Why: a named cause must carry only its exact count; tied causes fall back to unknown. +function buildUndecryptableWarning(counts: { + decryptFailed: number + appBoundFailed: number + keyringUnavailableFailed: number +}): BrowserCookieImportSummary['warning'] { + if (counts.decryptFailed === 0) { + return undefined + } + const unknownFailed = + counts.decryptFailed - counts.appBoundFailed - counts.keyringUnavailableFailed + const rankedCauses = [ + { reason: 'app-bound-encryption' as const, count: counts.appBoundFailed }, + { reason: 'linux-keyring-unavailable' as const, count: counts.keyringUnavailableFailed }, + { reason: 'unknown' as const, count: unknownFailed } + ].sort((left, right) => right.count - left.count) + const [dominant, runnerUp] = rankedCauses + + if (dominant.reason === 'unknown' || dominant.count === runnerUp.count) { + return { code: 'cookies-undecryptable', failedCookies: counts.decryptFailed, reason: 'unknown' } + } + + const otherFailedCookies = counts.decryptFailed - dominant.count + return { + code: 'cookies-undecryptable', + failedCookies: dominant.count, + reason: dominant.reason, + ...(otherFailedCookies > 0 ? { otherFailedCookies } : {}) + } +} + function decryptCookieValueRaw( encryptedBuffer: Buffer, keyResult: EncryptionKeyResult @@ -1101,29 +1160,25 @@ function decryptCookieValueRaw( } // AES-128-CBC (macOS and Linux) + const key = version === 'v10' || version === 'v11' ? keyResult.keysByVersion[version] : undefined + if (!key) { + return null + } + const ciphertext = encryptedBuffer.subarray(3) if (!ciphertext.length) { - return Buffer.alloc(0) + return null } - // Why: Linux v10 uses the "peanuts" key, v11 the keyring key; try primary then fallback (macOS uses one key). - const keysToTry = - version === 'v10' && keyResult.fallbackKey - ? [keyResult.fallbackKey, keyResult.key] - : [keyResult.key, ...(keyResult.fallbackKey ? [keyResult.fallbackKey] : [])] - - for (const key of keysToTry) { - try { - const iv = Buffer.alloc(16, ' ') - const decipher = createDecipheriv('aes-128-cbc', key, iv) - decipher.setAutoPadding(true) - const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]) - return stripHmac(decrypted) - } catch { - continue - } + try { + const iv = Buffer.alloc(16, ' ') + const decipher = createDecipheriv('aes-128-cbc', key, iv) + decipher.setAutoPadding(true) + const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]) + return stripHmac(decrypted) + } catch { + return null } - return null } function decryptAes256Gcm(payload: Buffer, key: Buffer): Buffer | null { @@ -1630,6 +1685,9 @@ export async function importCookiesFromBrowser( let imported = 0 let skipped = 0 + let decryptFailed = 0 + let appBoundFailed = 0 + let keyringUnavailableFailed = 0 let integritySkipped = 0 let nonTransplantableSkipped = 0 let memoryLoaded = 0 @@ -1697,8 +1755,26 @@ export async function importCookiesFromBrowser( let decryptedValue: Buffer if (encBuf && encBuf.length > 0) { - const raw = sourceKey ? decryptCookieValueRaw(encBuf, sourceKey) : null + const version = cookieEncryptionVersion(encBuf) + const appBoundIneligible = version === 'v20' + const keyringIneligible = + version === 'v11' && + sourceKey?.mode === 'aes-128-cbc' && + sourceKey.keyringUnavailable === true + const raw = + sourceKey && !appBoundIneligible && !keyringIneligible + ? decryptCookieValueRaw(encBuf, sourceKey) + : null if (!raw) { + // Why: once decrypt returns null every failure looks identical, so attribute the cause + // here while the version prefix is still in hand. Without this an undecryptable profile + // is indistinguishable from an empty one and reports success. + decryptFailed++ + if (appBoundIneligible) { + appBoundFailed++ + } else if (keyringIneligible) { + keyringUnavailableFailed++ + } skipped++ continue } @@ -1765,7 +1841,14 @@ export async function importCookiesFromBrowser( ) const googleCookiesSkipped = integritySkipped + nonTransplantableSkipped + const undecryptableWarning = buildUndecryptableWarning({ + decryptFailed, + appBoundFailed, + keyringUnavailableFailed + }) + if (decryptedCookies.length === 0) { + const zeroPathWarning = undecryptableWarning closeStagingDb() discardStagingFile() return { @@ -1776,7 +1859,10 @@ export async function importCookiesFromBrowser( importedCookies: 0, skippedCookies: skipped + integritySkipped + nonTransplantableSkipped, ...(googleCookiesSkipped > 0 ? { googleCookiesSkipped } : {}), - domains: [] + domains: [], + // Why: a profile whose rows cannot be decrypted returns here, and without this it is + // reported as a successful empty import. + ...(zeroPathWarning ? { warning: zeroPathWarning } : {}) } } } @@ -1874,6 +1960,13 @@ export async function importCookiesFromBrowser( // Google-bound integrity cookies are already excluded by // isGoogleSourceBoundCookie, which is what actually prevents CookieMismatch. + // Why: a partial import still drops every undecryptable row, so silence here would report it + // as an unqualified success. The restart-fallback warning describes a lossier outcome and + // keeps precedence. + if (!warning && undecryptableWarning) { + warning = undecryptableWarning + } + const summary: BrowserCookieImportSummary = { totalCookies: sourceRows.length, importedCookies: imported, diff --git a/src/main/browser/browser-manager.ts b/src/main/browser/browser-manager.ts index 3af2e46be1c..abbd79bcf4f 100644 --- a/src/main/browser/browser-manager.ts +++ b/src/main/browser/browser-manager.ts @@ -1334,6 +1334,17 @@ export class BrowserManager { return this.worktreeIdByTabId.get(browserTabId) } + getRendererContextForGuest( + guestWebContentsId: number + ): { browserPageId: string; renderer: Electron.WebContents } | null { + const browserPageId = this.resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId) + if (!browserPageId) { + return null + } + const renderer = this.resolveRendererForBrowserTab(browserPageId) + return renderer ? { browserPageId, renderer } : null + } + getSessionProfileIdForTab(browserTabId: string): string | null { return this.sessionProfileIdByPageId.get(browserTabId) ?? null } diff --git a/src/main/browser/browser-webauthn-access.test.ts b/src/main/browser/browser-webauthn-access.test.ts new file mode 100644 index 00000000000..dfcd3ac61ca --- /dev/null +++ b/src/main/browser/browser-webauthn-access.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { requestAccountMock, cancelSessionAccountRequestsMock } = vi.hoisted(() => ({ + requestAccountMock: vi.fn(), + cancelSessionAccountRequestsMock: vi.fn() +})) + +vi.mock('./browser-webauthn-account-picker', () => ({ + requestBrowserWebAuthnAccount: requestAccountMock, + cancelBrowserWebAuthnAccountRequestsForSession: cancelSessionAccountRequestsMock +})) + +import { installBrowserWebAuthnAccessHandlers } from './browser-webauthn-access' + +describe('browser WebAuthn access', () => { + beforeEach(() => { + requestAccountMock.mockReset() + cancelSessionAccountRequestsMock.mockReset() + }) + + it('dispatches multi-account assertions to the account picker', async () => { + let selectHandler: + | (( + event: Electron.Event, + details: Electron.SelectWebauthnAccountDetails, + callback: (credentialId?: string | null) => void + ) => Promise) + | undefined + const browserSession = { + setDevicePermissionHandler: vi.fn(), + removeListener: vi.fn(), + on: vi.fn((eventName: string, listener: unknown) => { + if (eventName === 'select-webauthn-account') { + selectHandler = listener as typeof selectHandler + } + }) + } as unknown as Electron.Session + const details = { + relyingPartyId: 'example.com', + frame: {} as Electron.WebFrameMain, + accounts: [{ credentialId: 'first' }, { credentialId: 'second' }] + } + const callback = vi.fn() + requestAccountMock.mockResolvedValue('second') + + installBrowserWebAuthnAccessHandlers(browserSession) + await selectHandler?.( + { preventDefault: vi.fn() } as unknown as Electron.Event, + details, + callback + ) + + expect(requestAccountMock).toHaveBeenCalledWith(details, browserSession) + expect(callback).toHaveBeenCalledOnce() + expect(callback).toHaveBeenCalledWith('second') + }) +}) diff --git a/src/main/browser/browser-webauthn-access.ts b/src/main/browser/browser-webauthn-access.ts index 60da5b1e297..521c0f15886 100644 --- a/src/main/browser/browser-webauthn-access.ts +++ b/src/main/browser/browser-webauthn-access.ts @@ -1,7 +1,17 @@ import type { Session } from 'electron' +import { + cancelBrowserWebAuthnAccountRequestsForSession, + requestBrowserWebAuthnAccount +} from './browser-webauthn-account-picker' const FIDO_HID_USAGE_PAGE = 0xf1d0 const LOCALHOST_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', '[::1]']) +type SelectWebAuthnAccountHandler = ( + event: Electron.Event, + details: Electron.SelectWebauthnAccountDetails, + callback: (credentialId?: string | null) => void +) => Promise +const selectWebAuthnAccountHandlers = new WeakMap() function isSecureBrowserOrigin(rawOrigin: string | undefined): boolean { if (!rawOrigin) { @@ -53,15 +63,23 @@ function handleBrowserSelectHidDevice( callback(selectedDevice?.deviceId) } -function handleBrowserSelectWebAuthnAccount( +async function handleBrowserSelectWebAuthnAccount( + browserSession: Session, event: Electron.Event, details: Electron.SelectWebauthnAccountDetails, callback: (credentialId?: string | null) => void -): void { +): Promise { event.preventDefault() - // Why: Electron cancels discoverable WebAuthn when no listener exists. Pick - // only the unambiguous single-account case until Orca has account-picker UI. - callback(details.accounts.length === 1 ? details.accounts[0].credentialId : null) + if (details.accounts.length <= 1) { + callback(details.accounts[0]?.credentialId ?? null) + return + } + let credentialId: string | null = null + try { + credentialId = await requestBrowserWebAuthnAccount(details, browserSession) + } finally { + callback(credentialId) + } } export function installBrowserWebAuthnAccessHandlers(browserSession: Session): void { @@ -74,12 +92,23 @@ export function installBrowserWebAuthnAccessHandlers(browserSession: Session): v }) browserSession.removeListener('select-hid-device', handleBrowserSelectHidDevice) browserSession.on('select-hid-device', handleBrowserSelectHidDevice) - browserSession.removeListener('select-webauthn-account', handleBrowserSelectWebAuthnAccount) - browserSession.on('select-webauthn-account', handleBrowserSelectWebAuthnAccount) + const previousHandler = selectWebAuthnAccountHandlers.get(browserSession) + if (previousHandler) { + browserSession.removeListener('select-webauthn-account', previousHandler) + } + const selectWebAuthnAccountHandler: SelectWebAuthnAccountHandler = (event, details, callback) => + handleBrowserSelectWebAuthnAccount(browserSession, event, details, callback) + selectWebAuthnAccountHandlers.set(browserSession, selectWebAuthnAccountHandler) + browserSession.on('select-webauthn-account', selectWebAuthnAccountHandler) } export function clearBrowserWebAuthnAccessHandlers(browserSession: Session): void { browserSession.removeListener('select-hid-device', handleBrowserSelectHidDevice) - browserSession.removeListener('select-webauthn-account', handleBrowserSelectWebAuthnAccount) + const selectWebAuthnAccountHandler = selectWebAuthnAccountHandlers.get(browserSession) + if (selectWebAuthnAccountHandler) { + browserSession.removeListener('select-webauthn-account', selectWebAuthnAccountHandler) + selectWebAuthnAccountHandlers.delete(browserSession) + } browserSession.setDevicePermissionHandler(null) + cancelBrowserWebAuthnAccountRequestsForSession(browserSession) } diff --git a/src/main/browser/browser-webauthn-account-picker.test.ts b/src/main/browser/browser-webauthn-account-picker.test.ts new file mode 100644 index 00000000000..3e9e370b807 --- /dev/null +++ b/src/main/browser/browser-webauthn-account-picker.test.ts @@ -0,0 +1,166 @@ +import { EventEmitter } from 'node:events' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { fromFrameMock, getRendererContextForGuestMock } = vi.hoisted(() => ({ + fromFrameMock: vi.fn(), + getRendererContextForGuestMock: vi.fn() +})) + +vi.mock('electron', () => ({ + webContents: { fromFrame: fromFrameMock } +})) + +vi.mock('./browser-manager', () => ({ + browserManager: { getRendererContextForGuest: getRendererContextForGuestMock } +})) + +import { + BROWSER_WEBAUTHN_ACCOUNT_PICKER_TIMEOUT_MS, + cancelAllBrowserWebAuthnAccountRequests, + cancelBrowserWebAuthnAccountRequests, + requestBrowserWebAuthnAccount, + respondToBrowserWebAuthnAccountRequest +} from './browser-webauthn-account-picker' + +function mockWebContents(id: number): Electron.WebContents & EventEmitter { + const contents = new EventEmitter() as Electron.WebContents & EventEmitter + Object.assign(contents, { + id, + isDestroyed: vi.fn(() => false), + send: vi.fn() + }) + return contents +} + +function accountDetails(): Electron.SelectWebauthnAccountDetails { + return { + relyingPartyId: 'accounts.example.com', + frame: {} as Electron.WebFrameMain, + accounts: [ + { credentialId: 'credential-1', displayName: 'Personal', name: 'me@example.com' }, + { credentialId: 'credential-2', displayName: 'Work', name: 'me@work.example' } + ] + } +} + +function mockSession(): Electron.Session { + return {} as Electron.Session +} + +describe('browser WebAuthn account picker', () => { + beforeEach(() => { + vi.useFakeTimers() + fromFrameMock.mockReset() + getRendererContextForGuestMock.mockReset() + }) + + afterEach(() => { + cancelAllBrowserWebAuthnAccountRequests() + vi.useRealTimers() + }) + + it('returns the credential selected by the owning renderer', async () => { + const guest = mockWebContents(41) + const renderer = mockWebContents(42) + fromFrameMock.mockReturnValue(guest) + getRendererContextForGuestMock.mockReturnValue({ browserPageId: 'page-1', renderer }) + + const selection = requestBrowserWebAuthnAccount(accountDetails(), mockSession()) + const request = vi.mocked(renderer.send).mock.calls[0][1] + + expect(request).toMatchObject({ + browserPageId: 'page-1', + relyingPartyId: 'accounts.example.com', + accounts: [ + { credentialId: 'credential-1', displayName: 'Personal', name: 'me@example.com' }, + { credentialId: 'credential-2', displayName: 'Work', name: 'me@work.example' } + ] + }) + expect( + respondToBrowserWebAuthnAccountRequest(renderer, { + requestId: request.requestId, + credentialId: 'credential-2' + }) + ).toBe(true) + await expect(selection).resolves.toBe('credential-2') + }) + + it('rejects responses from another renderer or with an unoffered credential', async () => { + const guest = mockWebContents(51) + const renderer = mockWebContents(52) + fromFrameMock.mockReturnValue(guest) + getRendererContextForGuestMock.mockReturnValue({ browserPageId: 'page-2', renderer }) + + const selection = requestBrowserWebAuthnAccount(accountDetails(), mockSession()) + const requestId = vi.mocked(renderer.send).mock.calls[0][1].requestId + + expect( + respondToBrowserWebAuthnAccountRequest(mockWebContents(53), { + requestId, + credentialId: 'credential-1' + }) + ).toBe(false) + expect( + respondToBrowserWebAuthnAccountRequest(renderer, { + requestId, + credentialId: 'credential-not-offered' + }) + ).toBe(false) + expect( + respondToBrowserWebAuthnAccountRequest(renderer, { requestId, credentialId: null }) + ).toBe(true) + await expect(selection).resolves.toBeNull() + }) + + it('cancels when the tab closes or the prompt times out', async () => { + const guest = mockWebContents(61) + const renderer = mockWebContents(62) + fromFrameMock.mockReturnValue(guest) + getRendererContextForGuestMock.mockReturnValue({ browserPageId: 'page-3', renderer }) + const browserSession = mockSession() + const closedSelection = requestBrowserWebAuthnAccount(accountDetails(), browserSession) + vi.mocked(renderer.send).mockImplementation((channel) => { + if (channel === 'browser:webauthn-account-request-closed') { + throw new Error('renderer destroyed during send') + } + }) + + cancelBrowserWebAuthnAccountRequests('page-3') + await expect(closedSelection).resolves.toBeNull() + + const timedOutSelection = requestBrowserWebAuthnAccount(accountDetails(), browserSession) + await vi.advanceTimersByTimeAsync(BROWSER_WEBAUTHN_ACCOUNT_PICKER_TIMEOUT_MS) + await expect(timedOutSelection).resolves.toBeNull() + }) + + it('cancels when the guest or window is destroyed', async () => { + const guest = mockWebContents(71) + const renderer = mockWebContents(72) + fromFrameMock.mockReturnValue(guest) + getRendererContextForGuestMock.mockReturnValue({ browserPageId: 'page-4', renderer }) + const browserSession = mockSession() + const guestDestroyed = requestBrowserWebAuthnAccount(accountDetails(), browserSession) + guest.emit('destroyed') + await expect(guestDestroyed).resolves.toBeNull() + + const windowDestroyed = requestBrowserWebAuthnAccount(accountDetails(), browserSession) + renderer.emit('destroyed') + await expect(windowDestroyed).resolves.toBeNull() + }) + + it('cancels immediately when the guest or window renderer process exits', async () => { + const guest = mockWebContents(81) + const renderer = mockWebContents(82) + const browserSession = mockSession() + fromFrameMock.mockReturnValue(guest) + getRendererContextForGuestMock.mockReturnValue({ browserPageId: 'page-5', renderer }) + + const guestCrashed = requestBrowserWebAuthnAccount(accountDetails(), browserSession) + guest.emit('render-process-gone') + await expect(guestCrashed).resolves.toBeNull() + + const windowCrashed = requestBrowserWebAuthnAccount(accountDetails(), browserSession) + renderer.emit('render-process-gone') + await expect(windowCrashed).resolves.toBeNull() + }) +}) diff --git a/src/main/browser/browser-webauthn-account-picker.ts b/src/main/browser/browser-webauthn-account-picker.ts new file mode 100644 index 00000000000..522630e6851 --- /dev/null +++ b/src/main/browser/browser-webauthn-account-picker.ts @@ -0,0 +1,150 @@ +import { randomUUID } from 'node:crypto' +import { webContents } from 'electron' + +import type { + BrowserWebAuthnAccountRequest, + BrowserWebAuthnAccountResponse +} from '../../shared/browser-webauthn-account' +import { browserManager } from './browser-manager' + +export const BROWSER_WEBAUTHN_ACCOUNT_PICKER_TIMEOUT_MS = 60_000 + +type PendingAccountRequest = { + browserPageId: string + browserSession: Electron.Session + guest: Electron.WebContents + renderer: Electron.WebContents + credentialIds: Set + timer: ReturnType + onUnavailable: () => void + settle: (credentialId: string | null) => void +} + +const pendingAccountRequests = new Map() + +function closeRendererPrompt(requestId: string, request: PendingAccountRequest): void { + try { + if (!request.renderer.isDestroyed()) { + request.renderer.send('browser:webauthn-account-request-closed', { requestId }) + } + } catch { + // Renderer may be destroyed between the check and send. + } +} + +function cleanUpRequest(requestId: string, request: PendingAccountRequest): void { + clearTimeout(request.timer) + request.guest.removeListener('destroyed', request.onUnavailable) + request.guest.removeListener('render-process-gone', request.onUnavailable) + request.renderer.removeListener('destroyed', request.onUnavailable) + request.renderer.removeListener('render-process-gone', request.onUnavailable) + pendingAccountRequests.delete(requestId) +} + +export function requestBrowserWebAuthnAccount( + details: Electron.SelectWebauthnAccountDetails, + browserSession: Electron.Session +): Promise { + const guest = details.frame ? webContents.fromFrame(details.frame) : undefined + if (!guest || guest.isDestroyed()) { + return Promise.resolve(null) + } + const context = browserManager.getRendererContextForGuest(guest.id) + if (!context || context.renderer.isDestroyed()) { + return Promise.resolve(null) + } + + const requestId = randomUUID() + const requestPayload: BrowserWebAuthnAccountRequest = { + requestId, + browserPageId: context.browserPageId, + relyingPartyId: details.relyingPartyId, + accounts: details.accounts.map(({ credentialId, displayName, name }) => ({ + credentialId, + ...(displayName ? { displayName } : {}), + ...(name ? { name } : {}) + })) + } + + return new Promise((resolve) => { + let settled = false + const settle = (credentialId: string | null = null): void => { + if (settled) { + return + } + settled = true + const request = pendingAccountRequests.get(requestId) + if (request) { + cleanUpRequest(requestId, request) + closeRendererPrompt(requestId, request) + } + resolve(credentialId) + } + const request: PendingAccountRequest = { + browserPageId: context.browserPageId, + browserSession, + guest, + renderer: context.renderer, + credentialIds: new Set(details.accounts.map((account) => account.credentialId)), + timer: setTimeout(settle, BROWSER_WEBAUTHN_ACCOUNT_PICKER_TIMEOUT_MS), + onUnavailable: () => settle(null), + settle + } + pendingAccountRequests.set(requestId, request) + guest.once('destroyed', request.onUnavailable) + guest.once('render-process-gone', request.onUnavailable) + context.renderer.once('destroyed', request.onUnavailable) + context.renderer.once('render-process-gone', request.onUnavailable) + try { + context.renderer.send('browser:webauthn-account-requested', requestPayload) + } catch { + settle(null) + } + }) +} + +export function respondToBrowserWebAuthnAccountRequest( + sender: Electron.WebContents, + response: BrowserWebAuthnAccountResponse +): boolean { + if ( + !response || + typeof response.requestId !== 'string' || + (response.credentialId !== null && typeof response.credentialId !== 'string') + ) { + return false + } + const request = pendingAccountRequests.get(response.requestId) + if (!request || request.renderer.id !== sender.id) { + return false + } + if (response.credentialId !== null && !request.credentialIds.has(response.credentialId)) { + return false + } + request.settle(response.credentialId) + return true +} + +export function cancelBrowserWebAuthnAccountRequests(browserPageId: string): void { + for (const request of pendingAccountRequests.values()) { + if (request.browserPageId === browserPageId) { + request.settle(null) + } + } +} + +export function cancelBrowserWebAuthnAccountRequestsForSession( + browserSession: Electron.Session +): void { + for (const request of pendingAccountRequests.values()) { + if (request.browserSession === browserSession) { + request.settle(null) + } + } +} + +export function cancelAllBrowserWebAuthnAccountRequests(): void { + for (const request of pendingAccountRequests.values()) { + request.settle(null) + } +} diff --git a/src/main/browser/browser-webauthn-profile-delete.test.ts b/src/main/browser/browser-webauthn-profile-delete.test.ts new file mode 100644 index 00000000000..9b063b6bbdd --- /dev/null +++ b/src/main/browser/browser-webauthn-profile-delete.test.ts @@ -0,0 +1,144 @@ +import { EventEmitter } from 'node:events' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { fromFrameMock, getRendererContextForGuestMock, sessionFromPartitionMock } = vi.hoisted( + () => ({ + fromFrameMock: vi.fn(), + getRendererContextForGuestMock: vi.fn(), + sessionFromPartitionMock: vi.fn() + }) +) + +vi.mock('electron', () => ({ + app: { + getPath: vi.fn(() => { + throw new Error('userData unavailable in test') + }) + }, + session: { fromPartition: sessionFromPartitionMock }, + systemPreferences: { + askForMediaAccess: vi.fn(), + getMediaAccessStatus: vi.fn(() => 'granted') + }, + webContents: { fromFrame: fromFrameMock } +})) + +vi.mock('./browser-manager', () => ({ + browserManager: { + getRendererContextForGuest: getRendererContextForGuestMock, + handleGuestWillDownload: vi.fn(), + installCertificateRequestGuard: vi.fn(), + notifyPermissionDenied: vi.fn(), + removeCertificateRequestGuard: vi.fn() + } +})) + +import { browserSessionRegistry } from './browser-session-registry' +import { + cancelAllBrowserWebAuthnAccountRequests, + respondToBrowserWebAuthnAccountRequest +} from './browser-webauthn-account-picker' + +type MockSession = Electron.Session & EventEmitter + +function mockSession(): MockSession { + return Object.assign(new EventEmitter(), { + clearCache: vi.fn().mockResolvedValue(undefined), + clearStorageData: vi.fn().mockResolvedValue(undefined), + setDevicePermissionHandler: vi.fn(), + setDisplayMediaRequestHandler: vi.fn(), + setPermissionCheckHandler: vi.fn(), + setPermissionRequestHandler: vi.fn() + }) as unknown as MockSession +} + +function mockWebContents(id: number): Electron.WebContents & EventEmitter { + return Object.assign(new EventEmitter(), { + id, + isDestroyed: vi.fn(() => false), + send: vi.fn() + }) as unknown as Electron.WebContents & EventEmitter +} + +function accountDetails(frame: Electron.WebFrameMain): Electron.SelectWebauthnAccountDetails { + return { + relyingPartyId: 'accounts.example.com', + frame, + accounts: [{ credentialId: 'personal' }, { credentialId: 'work' }] + } +} + +describe('browser WebAuthn profile deletion', () => { + const sessionsByPartition = new Map() + + beforeEach(() => { + sessionsByPartition.clear() + sessionFromPartitionMock.mockReset() + sessionFromPartitionMock.mockImplementation((partition: string) => { + const existing = sessionsByPartition.get(partition) + if (existing) { + return existing + } + const created = mockSession() + sessionsByPartition.set(partition, created) + return created + }) + fromFrameMock.mockReset() + getRendererContextForGuestMock.mockReset() + }) + + afterEach(async () => { + cancelAllBrowserWebAuthnAccountRequests() + for (const profile of browserSessionRegistry.listProfiles()) { + if (profile.scope !== 'default') { + await browserSessionRegistry.deleteProfile(profile.id) + } + } + }) + + it('leaves another session pending when a profile is deleted', async () => { + const firstProfile = browserSessionRegistry.createProfile('isolated', 'First') + const secondProfile = browserSessionRegistry.createProfile('isolated', 'Second') + expect(firstProfile).not.toBeNull() + expect(secondProfile).not.toBeNull() + + const firstSession = sessionsByPartition.get(firstProfile!.partition)! + const secondSession = sessionsByPartition.get(secondProfile!.partition)! + const firstFrame = {} as Electron.WebFrameMain + const secondFrame = {} as Electron.WebFrameMain + const firstGuest = mockWebContents(101) + const secondGuest = mockWebContents(102) + const firstRenderer = mockWebContents(201) + const secondRenderer = mockWebContents(202) + fromFrameMock.mockImplementation((frame) => (frame === firstFrame ? firstGuest : secondGuest)) + getRendererContextForGuestMock.mockImplementation((guestId: number) => + guestId === firstGuest.id + ? { browserPageId: 'first-page', renderer: firstRenderer } + : { browserPageId: 'second-page', renderer: secondRenderer } + ) + const firstCallback = vi.fn() + const secondCallback = vi.fn() + const firstHandler = firstSession.listeners('select-webauthn-account')[0] + const secondHandler = secondSession.listeners('select-webauthn-account')[0] + + void firstHandler({ preventDefault: vi.fn() }, accountDetails(firstFrame), firstCallback) + void secondHandler({ preventDefault: vi.fn() }, accountDetails(secondFrame), secondCallback) + + await expect(browserSessionRegistry.deleteProfile(firstProfile!.id)).resolves.toBe(true) + await vi.waitFor(() => expect(firstCallback).toHaveBeenCalledWith(null)) + firstGuest.emit('destroyed') + firstRenderer.emit('render-process-gone') + expect(firstCallback).toHaveBeenCalledOnce() + expect(secondCallback).not.toHaveBeenCalled() + + const secondRequest = vi.mocked(secondRenderer.send).mock.calls[0][1] + expect( + respondToBrowserWebAuthnAccountRequest(secondRenderer, { + requestId: secondRequest.requestId, + credentialId: 'work' + }) + ).toBe(true) + await vi.waitFor(() => expect(secondCallback).toHaveBeenCalledWith('work')) + expect(secondCallback).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/claude/hook-service.test.ts b/src/main/claude/hook-service.test.ts index b3fa02debc5..b93acc1213e 100644 --- a/src/main/claude/hook-service.test.ts +++ b/src/main/claude/hook-service.test.ts @@ -34,14 +34,28 @@ function hasManagedCommand(hook: TestHook, matcher: (command: string | undefined } describe('getWindowsManagedLifecycleHook', () => { - it('resolves the managed script from the runtime Windows profile', () => { + it('resolves the managed script from the runtime Windows profile, as a single command string', () => { const scriptPath = 'C:\\Users\\%name%\\a^b&c\\.orca\\agent-hooks\\claude-hook.cmd' const hook = getWindowsManagedLifecycleHook(scriptPath) - expect(hook.args?.[0]).toBe('--headless') - expect(hook.args?.[1]).toMatch(/\\System32\\cmd\.exe$/i) - expect(hook.args?.at(-1)).toBe('%USERPROFILE%\\.orca\\agent-hooks\\claude-hook.cmd') - expect(hook.args).not.toContain(scriptPath) + expect(hook.args).toBeUndefined() + expect(hook.command).toMatch( + /\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden / + ) + expect(hook.command).not.toContain(scriptPath) + // Why: Git Bash/MSYS mangles backslash paths and slash-prefixed switches. + expect(hook.command.replace(/-EncodedCommand \S+$/, '')).not.toMatch(/\\| \/[a-zA-Z]+( |$)/) + + const encoded = hook.command.match(/-EncodedCommand (\S+)$/)?.[1] + const decoded = Buffer.from(encoded ?? '', 'base64').toString('utf16le') + expect(decoded).toContain('$env:USERPROFILE') + expect(decoded).toContain('.orca\\agent-hooks\\claude-hook.cmd') + }) + + it('is still recognized as managed by createManagedCommandMatcher (#14825)', () => { + const scriptPath = 'C:\\Users\\alice\\.orca\\agent-hooks\\claude-hook.cmd' + const hook = getWindowsManagedLifecycleHook(scriptPath) + expect(isClaudeManagedCommand(hook.command)).toBe(true) }) }) @@ -199,9 +213,17 @@ describe('ClaudeHookService.install', () => { expect(hasManagedCommand(legacy.hooks.StopFailure[0].hooks[0], isClaudeManagedCommand)).toBe( true ) - expect( - readFileSync(join(tmpHome, '.orca', 'agent-hooks', CLAUDE_SCRIPT_FILE_NAME), 'utf-8') - ).toContain('DEVIN_PROJECT_DIR') + const managedScript = readFileSync( + join(tmpHome, '.orca', 'agent-hooks', CLAUDE_SCRIPT_FILE_NAME), + 'utf-8' + ) + expect(managedScript).toContain('DEVIN_PROJECT_DIR') + // Why: guard and Devin-skip paths must still return neutral JSON (#14818). + expect(managedScript).toMatch( + process.platform === 'win32' + ? /^@echo off\r\nsetlocal\r\necho \{\}\r\n/ + : /^#!\/bin\/sh\nprintf "\{\}\\n"\n/ + ) } finally { vi.unstubAllEnvs() rmSync(tmpHome, { recursive: true, force: true }) @@ -220,10 +242,10 @@ describe('ClaudeHookService.install', () => { ) as { statusLine?: { type: string; command: string } } expect(settings.statusLine?.type).toBe('command') expect(settings.statusLine?.command).toContain( - '"$HOME/.orca/agent-hooks/claude-statusline.cmd"' + '"${HOME-}/.orca/agent-hooks/claude-statusline.cmd"' ) expect(settings.statusLine?.command).toContain( - '"$HOME/.orca/agent-hooks/claude-statusline.sh"' + '"${HOME-}/.orca/agent-hooks/claude-statusline.sh"' ) expect(settings.statusLine?.command).not.toContain(tmpHome.replaceAll('\\', '/')) @@ -255,7 +277,12 @@ describe('ClaudeHookService.install', () => { mkdirSync(join(tmpHome, '.claude'), { recursive: true }) writeFileSync( settingsPath, - JSON.stringify({ statusLine: { type: 'command', command: '/usr/local/bin/my-statusline' } }) + JSON.stringify({ + statusLine: { + type: 'command', + command: '/usr/local/bin/my-statusline' + } + }) ) expect(new ClaudeHookService().install().state).toBe('installed') @@ -339,7 +366,7 @@ describe('ClaudeHookService.install', () => { }) it.skipIf(process.platform !== 'win32')( - 'runs portable managed hooks through headless exec form', + 'runs portable managed hooks through a single headless command string', () => { const tmpHome = mkdtempSync(join(tmpdir(), 'orca claude home with spaces ')) vi.stubEnv('HOME', tmpHome) @@ -351,24 +378,20 @@ describe('ClaudeHookService.install', () => { readFileSync(join(tmpHome, '.claude', 'settings.json'), 'utf-8') ) as { hooks: Record } - const system32 = join(process.env.SystemRoot ?? 'C:\\Windows', 'System32') const scriptPath = join(tmpHome, '.orca', 'agent-hooks', CLAUDE_SCRIPT_FILE_NAME) - const runtimeScriptPath = join( - '%USERPROFILE%', - '.orca', - 'agent-hooks', - CLAUDE_SCRIPT_FILE_NAME - ) for (const eventName of ['UserPromptSubmit', 'Stop', 'StopFailure']) { const hook = settings.hooks[eventName]?.[0]?.hooks?.[0] - expect(hook).toEqual({ - type: 'command', - command: join(system32, 'conhost.exe'), - args: ['--headless', join(system32, 'cmd.exe'), '/d', '/c', runtimeScriptPath], - timeout: 10 - }) - expect(hook.args).not.toContain(scriptPath) + expect(hook?.args).toBeUndefined() + expect(hook?.command).toMatch( + /\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden / + ) + expect(hook?.command).not.toContain(scriptPath) + + const encoded = hook?.command.match(/-EncodedCommand (\S+)$/)?.[1] + const decoded = Buffer.from(encoded ?? '', 'base64').toString('utf16le') + expect(decoded).toContain('$env:USERPROFILE') + expect(decoded).toContain(`.orca\\agent-hooks\\${CLAUDE_SCRIPT_FILE_NAME}`) } } finally { vi.unstubAllEnvs() @@ -393,6 +416,8 @@ describe('ClaudeHookService.install', () => { expect(script).toContain('--data-urlencode "payload@-"') expect(script).toContain('/hook/claude') expect(script).not.toMatch(/Invoke-WebRequest/i) + // Why: guard and Devin-skip paths must still return neutral JSON (#14818). + expect(script.split('\r\n')[2]).toBe('echo {}') } finally { vi.unstubAllEnvs() rmSync(tmpHome, { recursive: true, force: true }) @@ -429,13 +454,17 @@ describe('ClaudeHookService.installRemote', () => { ]) { expect(parsed.hooks[event]).toBeTruthy() const cmd = parsed.hooks[event][0].hooks[0].command as string - expect(cmd).toContain('"$HOME/.orca/agent-hooks/claude-hook.sh"') + expect(cmd).toContain('"${HOME-}/.orca/agent-hooks/claude-hook.sh"') expect(cmd).not.toContain('/home/dev/.orca/agent-hooks/claude-hook.sh') } // Managed script body const script = fs.files.get('/home/dev/.orca/agent-hooks/claude-hook.sh') expect(script).toContain('#!/bin/sh') expect(script).toContain('DEVIN_PROJECT_DIR') + // Why: remote guard paths must still return neutral JSON (#14818). + expect(script!.indexOf('printf "{}\\n"')).toBe( + script!.indexOf('#!/bin/sh') + '#!/bin/sh\n'.length + ) // Why: payload is piped to curl via stdin (`payload@-`) so it never lands // on the curl command line (EDR oversized-command-line false positive), // matching the Windows curl.exe hook post. @@ -522,8 +551,8 @@ describe('OpenClaudeHookService-compatible install', () => { for (const event of ['UserPromptSubmit', 'Stop', 'StopFailure']) { const command = parsed.hooks[event][0].hooks[0].command as string expect(isOpenClaudeManagedCommand(command)).toBe(true) - expect(command).toContain('"$HOME/.orca/agent-hooks/openclaude-hook.cmd"') - expect(command).toContain('"$HOME/.orca/agent-hooks/openclaude-hook.sh"') + expect(command).toContain('"${HOME-}/.orca/agent-hooks/openclaude-hook.cmd"') + expect(command).toContain('"${HOME-}/.orca/agent-hooks/openclaude-hook.sh"') expect(command).not.toContain(tmpHome.replaceAll('\\', '/')) } expect( @@ -553,7 +582,7 @@ describe('OpenClaudeHookService-compatible install', () => { }) const parsed = JSON.parse(fs.files.get('/home/dev/.openclaude/settings.json')!) const command = parsed.hooks.StopFailure[0].hooks[0].command as string - expect(command).toContain('"$HOME/.orca/agent-hooks/openclaude-hook.sh"') + expect(command).toContain('"${HOME-}/.orca/agent-hooks/openclaude-hook.sh"') expect(command).not.toContain('/home/dev/.orca/agent-hooks/openclaude-hook.sh') expect(fs.files.get('/home/dev/.orca/agent-hooks/openclaude-hook.sh')).toContain('/hook/claude') }) diff --git a/src/main/claude/hook-service.ts b/src/main/claude/hook-service.ts index 47269b1e17d..57270575c5c 100644 --- a/src/main/claude/hook-service.ts +++ b/src/main/claude/hook-service.ts @@ -65,6 +65,8 @@ function getManagedScript( return [ '@echo off', 'setlocal', + // Why: Claude-compatible permission hooks fail closed on empty stdout (#14818). + 'echo {}', // Why: refresh endpoint coordinates for PTYs surviving an Orca restart. 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', // Why (#11549): the env guards must outrank the Devin skip — the Devin skip parks in more.com, @@ -86,6 +88,8 @@ function getManagedScript( return [ '#!/bin/sh', + // Why: Claude-compatible permission hooks fail closed on empty stdout (#14818). + 'printf "{}\\n"', ...buildPosixHookPayloadCapture(), ...(options.skipWhenDevinImportsClaude ? [ diff --git a/src/main/claude/hook-settings.ts b/src/main/claude/hook-settings.ts index d39b8e27bcb..3447c62ffa5 100644 --- a/src/main/claude/hook-settings.ts +++ b/src/main/claude/hook-settings.ts @@ -6,7 +6,9 @@ import { getSharedManagedScriptPath, isPlainObject, MANAGED_HOOK_TIMEOUT_SECONDS, + quotePowerShellString, removeManagedCommands, + wrapWindowsPowerShellEncodedCommand, type HookCommandConfig, type HookDefinition, type HooksConfig @@ -16,38 +18,59 @@ import { wrapRuntimeHomeHookCommand } from '../agent-hooks/runtime-home-hook-com export type ClaudeCompatibleHookSettings = { configDirName: '.claude' | '.openclaude' scriptBaseName: 'claude-hook' | 'openclaude-hook' - supportsExecHookArgs: boolean + usesWindowsPowerShellLauncher: boolean } export const CLAUDE_HOOK_SETTINGS: ClaudeCompatibleHookSettings = { configDirName: '.claude', scriptBaseName: 'claude-hook', - supportsExecHookArgs: true + usesWindowsPowerShellLauncher: true } export const OPENCLAUDE_HOOK_SETTINGS: ClaudeCompatibleHookSettings = { configDirName: '.openclaude', scriptBaseName: 'openclaude-hook', - supportsExecHookArgs: false + usesWindowsPowerShellLauncher: false } export const CLAUDE_EVENTS = [ // Why: SessionStart is the only event a resumed/idle session emits before the // first prompt; without it the sidebar row can't exist until the user types (STA-3386). - { eventName: 'SessionStart', definition: { hooks: [{ type: 'command', command: '' }] } }, - { eventName: 'UserPromptSubmit', definition: { hooks: [{ type: 'command', command: '' }] } }, - { eventName: 'Stop', definition: { hooks: [{ type: 'command', command: '' }] } }, + { + eventName: 'SessionStart', + definition: { hooks: [{ type: 'command', command: '' }] } + }, + { + eventName: 'UserPromptSubmit', + definition: { hooks: [{ type: 'command', command: '' }] } + }, + { + eventName: 'Stop', + definition: { hooks: [{ type: 'command', command: '' }] } + }, // Why: OpenClaude skips normal Stop hooks after API/model errors and emits // StopFailure instead; without this hook Orca leaves the turn spinning. - { eventName: 'StopFailure', definition: { hooks: [{ type: 'command', command: '' }] } }, + { + eventName: 'StopFailure', + definition: { hooks: [{ type: 'command', command: '' }] } + }, // Why: subagent/teammate lifecycle feeds the sidebar's child rows and keeps // a pane 'working' while background children outlive the lead's turn. // TeammateIdle parks turn-based teammates without trusting their permanently // "running" background_tasks entry to gate the pane. // Older Claude builds ignore unregistered event names (StopFailure precedent). - { eventName: 'SubagentStart', definition: { hooks: [{ type: 'command', command: '' }] } }, - { eventName: 'SubagentStop', definition: { hooks: [{ type: 'command', command: '' }] } }, - { eventName: 'TeammateIdle', definition: { hooks: [{ type: 'command', command: '' }] } }, + { + eventName: 'SubagentStart', + definition: { hooks: [{ type: 'command', command: '' }] } + }, + { + eventName: 'SubagentStop', + definition: { hooks: [{ type: 'command', command: '' }] } + }, + { + eventName: 'TeammateIdle', + definition: { hooks: [{ type: 'command', command: '' }] } + }, // Why: PreToolUse gives the dashboard a live readout of the in-flight tool // (name + input preview) before it completes. { @@ -108,11 +131,15 @@ export function getRemoteConfigPath(remoteHome: string, settings = CLAUDE_HOOK_S return `${remoteHome.replace(/\/$/, '')}/${settings.configDirName}/settings.json` } -export function getManagedCommand(scriptPath: string): string { +export function getManagedCommand( + scriptPath: string, + options: { neutralJsonWhenMissing?: boolean } = {} +): string { const scriptFileName = basename(scriptPath) const extension = extname(scriptFileName) return wrapRuntimeHomeHookCommand( - extension ? scriptFileName.slice(0, -extension.length) : scriptFileName + extension ? scriptFileName.slice(0, -extension.length) : scriptFileName, + options ) } @@ -120,25 +147,25 @@ export function getManagedLifecycleHook( scriptPath: string, settings = CLAUDE_HOOK_SETTINGS ): HookCommandConfig { - if (process.platform !== 'win32' || !settings.supportsExecHookArgs) { - return buildManagedCommandHook(getManagedCommand(scriptPath)) + if (process.platform !== 'win32' || !settings.usesWindowsPowerShellLauncher) { + return buildManagedCommandHook(getManagedCommand(scriptPath, { neutralJsonWhenMissing: true })) } return getWindowsManagedLifecycleHook(scriptPath) } +// Why: some Claude-compatible consumers ignore `args`, so the invocation must be self-contained. export function getWindowsManagedLifecycleHook(scriptPath: string): HookCommandConfig { - const system32 = win32.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32') - const runtimeScriptPath = win32.join( - '%USERPROFILE%', - '.orca', - 'agent-hooks', - win32.basename(scriptPath) - ) - // Why: Claude's Windows shell form opens Git Bash consoles; exec form hosts the client in a windowless console. + const scriptFileName = win32.basename(scriptPath) + // Why: runtime profile resolution keeps the managed entry portable across users (STA-3348). + const quotedRelativePath = quotePowerShellString(`.orca\\agent-hooks\\${scriptFileName}`) + // Why: compat consumers require neutral JSON even when the managed script is missing (#14818). + const innerCommand = + `$scriptPath = Join-Path $env:USERPROFILE ${quotedRelativePath}; ` + + 'if (Test-Path -LiteralPath $scriptPath -PathType Leaf) { & $scriptPath; exit $LASTEXITCODE }; ' + + "[Console]::In.ReadToEnd() | Out-Null; Write-Output '{}'; exit 0" return { type: 'command', - command: win32.join(system32, 'conhost.exe'), - args: ['--headless', win32.join(system32, 'cmd.exe'), '/d', '/c', runtimeScriptPath], + command: wrapWindowsPowerShellEncodedCommand(innerCommand), timeout: MANAGED_HOOK_TIMEOUT_SECONDS } } @@ -154,7 +181,7 @@ export function hasSameManagedHookInvocation( } export function getRemoteManagedCommand(scriptPath: string): string { - return getManagedCommand(scriptPath) + return getManagedCommand(scriptPath, { neutralJsonWhenMissing: true }) } export function applyManagedHooks( diff --git a/src/main/codex/hook-service-managed-install.test.ts b/src/main/codex/hook-service-managed-install.test.ts index 4f53c51513f..22f6bf698c5 100644 --- a/src/main/codex/hook-service-managed-install.test.ts +++ b/src/main/codex/hook-service-managed-install.test.ts @@ -30,7 +30,7 @@ vi.mock('os', async (importOriginal) => { import { CodexHookService } from './hook-service' const WINDOWS_POWERSHELL_LAUNCHER = - /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/ + /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand \S+$/ const homes = setupCodexHookHomes(homedirMock, getPathMock) @@ -235,24 +235,31 @@ describe('CodexHookService', () => { // would block the event loop and starve this handler, so the child is // spawned asynchronously while the server drains the request concurrently. let resolveReceived: (value: { headers: Record; body: string }) => void - const receivedPromise = new Promise<{ headers: Record; body: string }>( - (resolve) => { - resolveReceived = resolve - } - ) + const receivedPromise = new Promise<{ + headers: Record + body: string + }>((resolve) => { + resolveReceived = resolve + }) const server = createServer((req, res) => { const chunks: Buffer[] = [] req.on('data', (c: Buffer) => chunks.push(c)) req.on('end', () => { res.end('ok') - resolveReceived({ headers: req.headers, body: Buffer.concat(chunks).toString('utf-8') }) + resolveReceived({ + headers: req.headers, + body: Buffer.concat(chunks).toString('utf-8') + }) }) }) await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) const port = (server.address() as AddressInfo).port try { - const payload = JSON.stringify({ prompt: '你好世界', hook_event_name: 'UserPromptSubmit' }) + const payload = JSON.stringify({ + prompt: '你好世界', + hook_event_name: 'UserPromptSubmit' + }) // Why: this suite may run inside an Orca-launched terminal whose env // already carries ORCA_AGENT_HOOK_ENDPOINT/PORT/TOKEN. The managed // script sources that endpoint file, so leave it out or the hook posts diff --git a/src/main/command-code/hook-service.test.ts b/src/main/command-code/hook-service.test.ts index 6e294516324..ce59e20b056 100644 --- a/src/main/command-code/hook-service.test.ts +++ b/src/main/command-code/hook-service.test.ts @@ -21,7 +21,7 @@ vi.mock('os', async () => { import { CommandCodeHookService } from './hook-service' const WINDOWS_POWERSHELL_LAUNCHER = - /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/ + /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand \S+$/ describe('CommandCodeHookService', () => { let homeDir: string @@ -136,7 +136,10 @@ describe('CommandCodeHookService', () => { body += chunk }) req.on('end', () => { - requests.push({ body, token: req.headers['x-orca-agent-hook-token'] }) + requests.push({ + body, + token: req.headers['x-orca-agent-hook-token'] + }) res.statusCode = 204 res.end() }) diff --git a/src/main/crash-reporting/crash-breadcrumb-store.test.ts b/src/main/crash-reporting/crash-breadcrumb-store.test.ts index fd7b1f074da..7690a01ff27 100644 --- a/src/main/crash-reporting/crash-breadcrumb-store.test.ts +++ b/src/main/crash-reporting/crash-breadcrumb-store.test.ts @@ -49,48 +49,6 @@ describe('crash breadcrumb store', () => { expect(snapshot.at(-1)?.data).toEqual({ index: 31 }) }) - it('retains the newest coalesced parking census across later activity', () => { - vi.useFakeTimers() - vi.setSystemTime(new Date('2026-08-15T12:00:00.000Z')) - recordCrashBreadcrumb('renderer_memory_highwater', { - rendererSurface: 'main', - thresholdPct: 80 - }) - recordCoalescedCrashBreadcrumb({ - name: 'terminal_parking_pass', - data: { managers: 49, panes: 60, sampledAtMs: Date.now() }, - coalesceKey: 'terminal_parking_pass', - minIntervalMs: 30_000 - }) - vi.advanceTimersByTime(1_000) - recordCoalescedCrashBreadcrumb({ - name: 'terminal_parking_pass', - data: { managers: 28, panes: 34, sampledAtMs: Date.now() }, - coalesceKey: 'terminal_parking_pass', - minIntervalMs: 30_000 - }) - for (let index = 0; index < 32; index += 1) { - recordCrashBreadcrumb(`later_event_${index}`, { index }) - } - - const snapshot = getCrashBreadcrumbSnapshot() - const parkingPasses = snapshot.filter( - (breadcrumb) => breadcrumb.name === 'terminal_parking_pass' - ) - - expect(snapshot).toHaveLength(30) - expect(parkingPasses).toHaveLength(1) - expect(snapshot.some((breadcrumb) => breadcrumb.name === 'renderer_memory_highwater')).toBe( - true - ) - expect(parkingPasses[0]?.data).toEqual({ - managers: 28, - panes: 34, - sampledAtMs: Date.now(), - suppressedSinceLast: 1 - }) - }) - it('caps retained high-water profiles', () => { for (let index = 0; index < 5; index += 1) { recordCrashBreadcrumb('renderer_memory_highwater', { @@ -104,41 +62,6 @@ describe('crash breadcrumb store', () => { ).toEqual(['surface-1', 'surface-2', 'surface-3', 'surface-4']) }) - it('keeps memory profiles and parking census from starving each other', () => { - recordCrashBreadcrumb('terminal_parking_pass', { managers: 1, panes: 1 }) - for (let index = 0; index < 12; index += 1) { - recordCrashBreadcrumb('renderer_memory_highwater', { - rendererSurface: `surface-${index}`, - thresholdPct: 80 - }) - } - - expect( - getCrashBreadcrumbSnapshot().filter( - (breadcrumb) => breadcrumb.name === 'terminal_parking_pass' - ) - ).toHaveLength(1) - - for (let index = 0; index < 12; index += 1) { - recordCrashBreadcrumb('terminal_parking_pass', { managers: index, panes: index + 1 }) - } - - const snapshot = getCrashBreadcrumbSnapshot() - const memoryProfiles = snapshot.filter( - (breadcrumb) => breadcrumb.name === 'renderer_memory_highwater' - ) - - expect(memoryProfiles.map((breadcrumb) => breadcrumb.data?.rendererSurface)).toEqual([ - 'surface-8', - 'surface-9', - 'surface-10', - 'surface-11' - ]) - expect(snapshot.filter((breadcrumb) => breadcrumb.name === 'terminal_parking_pass')).toEqual([ - expect.objectContaining({ data: { managers: 11, panes: 12 } }) - ]) - }) - it('redacts sensitive breadcrumb fields before they can be snapshotted', () => { recordCrashBreadcrumb('workspace_opened', { path: '/Users/alice/project', diff --git a/src/main/crash-reporting/crash-breadcrumb-store.ts b/src/main/crash-reporting/crash-breadcrumb-store.ts index ed66864718d..e5f96e8f05e 100644 --- a/src/main/crash-reporting/crash-breadcrumb-store.ts +++ b/src/main/crash-reporting/crash-breadcrumb-store.ts @@ -6,10 +6,8 @@ import { } from '../../shared/crash-reporting' const MAX_BREADCRUMBS = 30 -// Why: preserve four memory highwaters and one named singleton within the 30-entry snapshot. -const MAX_RETAINED_BREADCRUMBS = 5 -const MAX_RETAINED_MEMORY_BREADCRUMBS = 4 -const RETAINED_SINGLETON_BREADCRUMB_NAMES = new Set(['terminal_parking_pass']) +// Why: retain two thresholds for each renderer surface without growing the ring. +const MAX_RETAINED_BREADCRUMBS = 4 // Why: coalesceKey embeds an open-string agentType (length-trimmed only, never // enum-checked), so the key space is unbounded over a long multi-agent/SSH session. // Bound the coalesce map the same way ProcessGoneDedupe bounds its key map. @@ -39,9 +37,6 @@ let retainedBreadcrumbs = new Map() let coalescedBreadcrumbs = new Map() function retainedBreadcrumbKey(breadcrumb: CrashReportBreadcrumb): string | null { - if (RETAINED_SINGLETON_BREADCRUMB_NAMES.has(breadcrumb.name)) { - return breadcrumb.name - } if (breadcrumb.name !== 'renderer_memory_highwater') { return null } @@ -70,15 +65,6 @@ export function recordCrashBreadcrumb( if (retainedKey) { retainedBreadcrumbs.delete(retainedKey) retainedBreadcrumbs.set(retainedKey, breadcrumb) - const memoryKeys = [...retainedBreadcrumbs.entries()] - .filter(([, retained]) => retained.name === 'renderer_memory_highwater') - .map(([key]) => key) - while (memoryKeys.length > MAX_RETAINED_MEMORY_BREADCRUMBS) { - const oldestMemoryKey = memoryKeys.shift() - if (oldestMemoryKey) { - retainedBreadcrumbs.delete(oldestMemoryKey) - } - } while (retainedBreadcrumbs.size > MAX_RETAINED_BREADCRUMBS) { const oldestKey = retainedBreadcrumbs.keys().next() if (oldestKey.done) { diff --git a/src/main/crash-reporting/process-gone-killed-one-ordering.test.ts b/src/main/crash-reporting/process-gone-killed-one-ordering.test.ts index 7f47e794252..2a4ded3c2a5 100644 --- a/src/main/crash-reporting/process-gone-killed-one-ordering.test.ts +++ b/src/main/crash-reporting/process-gone-killed-one-ordering.test.ts @@ -17,6 +17,9 @@ import { import { ProcessGoneDedupe } from './process-gone-dedupe' import { recordProcessGoneCrash, type ProcessGoneCrashEvent } from './process-gone-recorder' +const noMinidump = async () => null +const attachDetails = async () => null + function event(overrides: Partial = {}): ProcessGoneCrashEvent { return { source: 'renderer', @@ -82,9 +85,27 @@ describe('recordProcessGoneCrash killed/1 ordering', () => { recordProcessGoneCrash({ record } as never, gpuKill, dedupe) recordProcessGoneCrash({ record } as never, networkServiceKill, dedupe) - recordProcessGoneCrash({ record } as never, rendererKill, dedupe) + recordProcessGoneCrash({ record, attachDetails } as never, rendererKill, dedupe, noMinidump) expect(record).toHaveBeenCalledOnce() + // Timing proximity is evidence, not authority to discard an ambiguous report. + expect(record).toHaveBeenCalledWith( + expect.objectContaining({ + breadcrumbs: expect.arrayContaining([ + expect.objectContaining({ + name: 'process_gone_suppressed', + data: expect.objectContaining({ source: 'child', processType: 'GPU' }) + }), + expect.objectContaining({ + name: 'process_gone_suppressed', + data: expect.objectContaining({ + source: 'child', + serviceName: 'network.mojom.NetworkService' + }) + }) + ]) + }) + ) }) it('suppresses the fleet sequence only after independent session-end intent', () => { diff --git a/src/main/crash-reporting/process-gone-recorder.test.ts b/src/main/crash-reporting/process-gone-recorder.test.ts index 8a0f77dd1e2..24749b1bacf 100644 --- a/src/main/crash-reporting/process-gone-recorder.test.ts +++ b/src/main/crash-reporting/process-gone-recorder.test.ts @@ -436,11 +436,12 @@ describe('recordProcessGoneCrash', () => { } } - // Why child kills: the decode gate is source-agnostic, and a non-recoverable - // child persists synchronously on every branch of the crash-reporting stack - // (the renderer killed path defers behind a sibling-kill settle), so the - // platform stub is still in force when the gate reads process.platform. - const nonRecoverableChildKill = (overrides: Partial): ProcessGoneCrashEvent => + // Why child kills: the decode gate is source-agnostic and reads + // process.platform synchronously at record time, so the platform stub is + // still in force when it runs. + const nonRecoverableChildKill = ( + overrides: Partial + ): ProcessGoneCrashEvent => event({ source: 'child', processType: 'Utility', diff --git a/src/main/cursor/hook-service.test.ts b/src/main/cursor/hook-service.test.ts index f861d4f2a66..b723d947db3 100644 --- a/src/main/cursor/hook-service.test.ts +++ b/src/main/cursor/hook-service.test.ts @@ -31,7 +31,7 @@ const CURSOR_EVENTS = [ const CURSOR_SCRIPT_FILE_NAME = process.platform === 'win32' ? 'cursor-hook.cmd' : 'cursor-hook.sh' const WINDOWS_POWERSHELL_LAUNCHER = - /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/ + /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand \S+$/ describe('CursorHookService', () => { let homeDir: string diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index 7d2063e2a7d..aad29987053 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -66,9 +66,6 @@ import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery' import type { PtyIncarnationId } from '../../shared/pty-incarnation' import { resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd' -import { resolveUnixShellPath } from '../providers/local-pty-utils' -import { injectHistoryEnv, injectWslFishHistoryEnv, logHistoryInjection } from '../terminal-history' -import { addWslEnvKeys } from '../wsl-env' import { PtyWriteUnavailableError } from '../providers/pty-write-unavailable-error' import { ColdRestorePayloadCache, type ColdRestorePayload } from './cold-restore-payload-cache' import { CheckpointSessionQueue } from './daemon-checkpoint-session-queue' @@ -428,8 +425,7 @@ export class DaemonPtyAdapter implements IPtyProvider { } async spawn(opts: PtySpawnOptions): Promise { - const spawnOpts = this.withHistoryIsolation(opts) - const sessionId = spawnOpts.sessionId ?? mintPtySessionId(spawnOpts.worktreeId) + const sessionId = opts.sessionId ?? mintPtySessionId(opts.worktreeId) const operation = { exitsBySessionId: new Map(), ignoredExitIncarnationIds: new Set(), @@ -448,9 +444,7 @@ export class DaemonPtyAdapter implements IPtyProvider { } try { return await this.withHistorySpawnLock(sessionId, () => - this.withDaemonRetry(() => - this.doSpawn({ ...spawnOpts, sessionId }, operation, historyRecovery) - ) + this.withDaemonRetry(() => this.doSpawn({ ...opts, sessionId }, operation, historyRecovery)) ) } finally { if (historyRecovery.freeze) { @@ -464,44 +458,6 @@ export class DaemonPtyAdapter implements IPtyProvider { } } - private withHistoryIsolation(opts: PtySpawnOptions): PtySpawnOptions { - const wslContext = resolveWslSessionContext({ - cwd: opts.cwd, - sessionId: opts.sessionId, - shellOverride: opts.shellOverride, - terminalWindowsWslDistro: opts.terminalWindowsWslDistro - }) - if ( - opts.attachOnly === true || - (opts.sessionId !== undefined && opts.isNewSession !== true) || - !opts.worktreeId || - opts.historyIsolationEnabled !== true || - (process.platform === 'win32' && !wslContext) - ) { - return opts - } - const env = { ...opts.env } - const preferredShell = wslContext - ? 'bash' - : opts.shellOverride || env.SHELL || process.env.SHELL || '/bin/zsh' - const shellPath = resolveUnixShellPath(preferredShell) - const historyArgs = [ - env, - opts.worktreeId, - shellPath, - opts.cwd ?? resolveSafePtyDefaultCwd() - ] as const - const result = wslContext - ? injectHistoryEnv(...historyArgs, { wslDistro: wslContext.distro }) - : injectHistoryEnv(...historyArgs) - if (wslContext) { - injectWslFishHistoryEnv(env, opts.worktreeId, wslContext.distro) - addWslEnvKeys(env, ['HISTFILE', 'fish_history']) - } - logHistoryInjection(opts.worktreeId, result) - return { ...opts, env } - } - private async doSpawn( opts: PtySpawnOptions, operation: PendingDaemonSpawnOperation, diff --git a/src/main/daemon/daemon-zsh-shell-ready-rcfile.ts b/src/main/daemon/daemon-zsh-shell-ready-rcfile.ts index f9517b67d80..4e5d4242d81 100644 --- a/src/main/daemon/daemon-zsh-shell-ready-rcfile.ts +++ b/src/main/daemon/daemon-zsh-shell-ready-rcfile.ts @@ -1,8 +1,6 @@ import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper' import { getPosixCodexShellLaunchPreflight } from '../pty/codex-shell-launch-preflight' -import { getZshFinalZdotdirRestoreBlock, getZshStartupFileSourceBlock, - ZSH_HISTFILE_RESTORE_BLOCK -} from '../shell-templates' +import { getZshFinalZdotdirRestoreBlock, getZshStartupFileSourceBlock } from '../shell-templates' export function getDaemonZshShellReadyRcfileContent(): string { return `# Orca daemon zsh shell-ready wrapper @@ -25,7 +23,6 @@ if [[ ! -o login ]]; then [[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" ${getPosixOmpShellWrapper()} [[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}" -${ZSH_HISTFILE_RESTORE_BLOCK} ${getPosixCodexShellLaunchPreflight()} fi __orca_osc133_precmd() { diff --git a/src/main/daemon/history-reader.ts b/src/main/daemon/history-reader.ts index de0a98e3071..bcd19fbca78 100644 --- a/src/main/daemon/history-reader.ts +++ b/src/main/daemon/history-reader.ts @@ -6,7 +6,7 @@ import { getHistorySessionDirName } from './history-paths' import { decodeTerminalHistoryLog, LOG_HEADER_BYTES } from './terminal-history-log' import { DAEMON_RESTORE_SCROLLBACK_ROWS } from './daemon-restore-scrollback-depth' import { HeadlessEmulator } from './headless-emulator' -import { PrioritySemaphore } from './priority-semaphore' +import { PrioritySemaphore } from '../../shared/priority-semaphore' import { ColdRestoreReplayWriter } from './cold-restore-replay-writer' import { readTerminalHistoryBufferAsync } from './terminal-history-file-reader' import { detectColdRestoreFromLegacyScrollback } from './terminal-history-legacy-scrollback-restore' diff --git a/src/main/daemon/shell-ready.ts b/src/main/daemon/shell-ready.ts index 0f9b2d65885..7989401e4d0 100644 --- a/src/main/daemon/shell-ready.ts +++ b/src/main/daemon/shell-ready.ts @@ -16,8 +16,7 @@ import { getZshEnvTemplate, getZshFinalZdotdirRestoreBlock, getZshShellReadyMarkerRegistrationBlock, - getZshStartupFileSourceBlock, - ZSH_HISTFILE_RESTORE_BLOCK + getZshStartupFileSourceBlock } from '../shell-templates' import { SHELL_READY_MARKER } from './daemon-shell-ready-marker' import { getDaemonBashShellReadyRcfileContent } from './daemon-bash-shell-ready-rcfile' @@ -122,7 +121,6 @@ __orca_restore_agent_teams_path [[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" ${getPosixOmpShellWrapper()} [[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}" -${ZSH_HISTFILE_RESTORE_BLOCK} ${getPosixCodexShellLaunchPreflight()} ${getZshShellReadyMarkerRegistrationBlock(SHELL_READY_MARKER)} ${getZshFinalZdotdirRestoreBlock()} diff --git a/src/main/daemon/terminal-history-seed-segments.test.ts b/src/main/daemon/terminal-history-seed-segments.test.ts index 7852a7eac89..ec022a94b8b 100644 --- a/src/main/daemon/terminal-history-seed-segments.test.ts +++ b/src/main/daemon/terminal-history-seed-segments.test.ts @@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest' import { HeadlessEmulator } from './headless-emulator' import { buildRehydrateSequences } from './terminal-mode-rehydrate-sequences' import { getRecoveredHistorySeedSegments } from './terminal-history-seed-segments' -import { COLD_RESTORE_SEED_MODE_RESET } from '../../shared/terminal-mode-reset-profiles' +import { + COLD_RESTORE_SEED_MODE_RESET, + RESET_GRAPHIC_RENDITION +} from '../../shared/terminal-mode-reset-profiles' import type { ColdRestoreInfo } from './terminal-history-cold-restore-info' import type { TerminalModes } from './types' @@ -38,7 +41,7 @@ describe('getRecoveredHistorySeedSegments', () => { restoreInfo({ pendingEscapeTailAnsi: '\x1b[3' }) ) expect(segments).toEqual([ - '\x1b[?1003h\x1b[?1006h', + `${RESET_GRAPHIC_RENDITION}\x1b[?1003h\x1b[?1006h`, 'user@host ~ $ \x1b[?1003h', MOUSE_OFF, '\x1b[3' @@ -50,7 +53,7 @@ describe('getRecoveredHistorySeedSegments', () => { getRecoveredHistorySeedSegments( restoreInfo({ modes: { ...ARMED_MODES, alternateScreen: true } }) ) - ).toEqual(['user@host ~ $ ', MOUSE_OFF]) + ).toEqual([`${RESET_GRAPHIC_RENDITION}user@host ~ $ `, MOUSE_OFF]) }) it('stays empty when there is no recovered normal buffer', () => { @@ -116,4 +119,10 @@ describe('getRecoveredHistorySeedSegments', () => { // snapshots must keep re-arming or an alt-screen TUI loses scroll forever. expect(buildRehydrateSequences(ARMED_MODES)).toBe('\x1b[?1003h\x1b[?1006h') }) + + it('grounds the pen before re-entering the alternate screen', () => { + expect(buildRehydrateSequences({ ...ARMED_MODES, alternateScreen: true })).toBe( + `${RESET_GRAPHIC_RENDITION}\x1b[?1049h\x1b[?1003h\x1b[?1006h` + ) + }) }) diff --git a/src/main/daemon/terminal-history-seed-segments.ts b/src/main/daemon/terminal-history-seed-segments.ts index 670cef152b8..09cd94e8348 100644 --- a/src/main/daemon/terminal-history-seed-segments.ts +++ b/src/main/daemon/terminal-history-seed-segments.ts @@ -1,5 +1,8 @@ import type { ColdRestoreInfo } from './terminal-history-cold-restore-info' -import { COLD_RESTORE_SEED_MODE_RESET } from '../../shared/terminal-mode-reset-profiles' +import { + COLD_RESTORE_SEED_MODE_RESET, + RESET_GRAPHIC_RENDITION +} from '../../shared/terminal-mode-reset-profiles' // Why the reset belongs in the seed and not only at replay: the recovered stream // re-arms mouse reporting from two independent sources (rehydrateSequences AND @@ -12,7 +15,9 @@ import { COLD_RESTORE_SEED_MODE_RESET } from '../../shared/terminal-mode-reset-p export function getRecoveredHistorySeedSegments(restoreInfo: ColdRestoreInfo): readonly string[] { if (restoreInfo.modes.alternateScreen) { const normalBuffer = restoreInfo.scrollbackAnsi || restoreInfo.snapshotAnsi - return normalBuffer ? [normalBuffer, COLD_RESTORE_SEED_MODE_RESET] : [] + return normalBuffer + ? [`${RESET_GRAPHIC_RENDITION}${normalBuffer}`, COLD_RESTORE_SEED_MODE_RESET] + : [] } const recovered = [restoreInfo.rehydrateSequences, restoreInfo.snapshotAnsi].filter( (segment) => segment.length > 0 @@ -25,5 +30,9 @@ export function getRecoveredHistorySeedSegments(restoreInfo: ColdRestoreInfo): r } // Why after the snapshot: it must undo the snapshot's own mode trailer, and // pendingEscapeTailAnsi is a torn escape that has to stay at the very end. - return [...recovered, COLD_RESTORE_SEED_MODE_RESET, ...(escapeTail ? [escapeTail] : [])] + const [firstRecovered, ...remainingRecovered] = recovered + const groundedRecovered = firstRecovered + ? [`${RESET_GRAPHIC_RENDITION}${firstRecovered}`, ...remainingRecovered] + : [] + return [...groundedRecovered, COLD_RESTORE_SEED_MODE_RESET, ...(escapeTail ? [escapeTail] : [])] } diff --git a/src/main/daemon/terminal-mode-rehydrate-sequences.ts b/src/main/daemon/terminal-mode-rehydrate-sequences.ts index 77e3603d4f5..ac4e34897b6 100644 --- a/src/main/daemon/terminal-mode-rehydrate-sequences.ts +++ b/src/main/daemon/terminal-mode-rehydrate-sequences.ts @@ -1,4 +1,5 @@ import type { TerminalModes } from './types' +import { RESET_GRAPHIC_RENDITION } from '../../shared/terminal-mode-reset-profiles' // Why no kitty flags here: rehydrateSequences feeds renderer xterms, and // POST_REPLAY_REATTACH_RESET's deliberate kitty reset (stale CSI-u Ctrl+C @@ -10,7 +11,7 @@ export function buildRehydrateSequences(modes: TerminalModes): string { if (modes.alternateScreen) { // Why: normal-buffer serialization can leave its pen active, while the // separately serialized alt body assumes it starts from default SGR. - seqs.push('\x1b[0m\x1b[?1049h') + seqs.push(`${RESET_GRAPHIC_RENDITION}\x1b[?1049h`) } if (modes.bracketedPaste) { seqs.push('\x1b[?2004h') diff --git a/src/main/droid/hook-service.test.ts b/src/main/droid/hook-service.test.ts index ef31754a7e7..0a65facab21 100644 --- a/src/main/droid/hook-service.test.ts +++ b/src/main/droid/hook-service.test.ts @@ -25,7 +25,7 @@ vi.mock('os', async () => { import { DroidHookService } from './hook-service' const WINDOWS_POWERSHELL_LAUNCHER = - /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/ + /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand \S+$/ describe('DroidHookService', () => { let homeDir: string diff --git a/src/main/fish-history-session.test.ts b/src/main/fish-history-session.test.ts deleted file mode 100644 index d1a8a7a632c..00000000000 --- a/src/main/fish-history-session.test.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { - deleteFishHistoryFile, - fishHistorySessionName, - isSafeFishHistorySession, - resolveFishHistoryDir, - sweepOrphanedFishHistoryFiles -} from './fish-history-session' - -const HASH_A = 'a1b2c3d4e5f60718' -const HASH_B = '00112233445566ff' -const SESSION_A = fishHistorySessionName(HASH_A) - -describe('fish history session naming', () => { - it('mints a session name that is a valid fish variable value', () => { - // fish falls back to the shared default for anything that is not a valid - // variable name, which would silently un-isolate the worktree. - expect(SESSION_A).toBe('orca_a1b2c3d4e5f60718') - expect(SESSION_A).toMatch(/^[A-Za-z_][A-Za-z0-9_]*$/) - }) - - it.each([ - ['orca_deadbeef', true], - ['orca_', false], - ['fish_history', false], - ['orca_../../etc/passwd', false], - ['orca_DEADBEEF', false], - ['', false], - [undefined, false], - [42, false] - ])('accepts %s as a safe session name: %s', (value, expected) => { - expect(isSafeFishHistorySession(value)).toBe(expected) - }) -}) - -describe('fish history directory resolution', () => { - // Why join rather than literals: the separator is platform-specific, and on - // Windows `isAbsolute('/data')` is also true, so a hardcoded '/data/fish' - // fails there for a reason that has nothing to do with the behavior tested. - it('follows XDG_DATA_HOME when it is absolute', () => { - expect(resolveFishHistoryDir({ XDG_DATA_HOME: '/data', HOME: '/home/me' })).toBe( - join('/data', 'fish') - ) - }) - - it('ignores a relative XDG_DATA_HOME the way fish does', () => { - expect(resolveFishHistoryDir({ XDG_DATA_HOME: 'relative', HOME: '/home/me' })).toBe( - join('/home/me', '.local', 'share', 'fish') - ) - }) - - it('falls back to HOME when XDG_DATA_HOME is unset', () => { - expect(resolveFishHistoryDir({ HOME: '/home/me' })).toBe( - join('/home/me', '.local', 'share', 'fish') - ) - }) -}) - -describe('fish history deletion', () => { - let root: string - let fishDir: string - - beforeEach(() => { - root = mkdtempSync(join(tmpdir(), 'orca-fish-history-')) - fishDir = join(root, 'fish') - mkdirSync(fishDir, { recursive: true }) - }) - - afterEach(() => { - rmSync(root, { recursive: true, force: true }) - }) - - const historyFile = (session: string): string => join(fishDir, `${session}_history`) - - it('deletes the session file rather than truncating it', () => { - writeFileSync(historyFile(SESSION_A), '- cmd: ls\n') - - expect(deleteFishHistoryFile(SESSION_A, [fishDir])).toBe(true) - expect(existsSync(historyFile(SESSION_A))).toBe(false) - }) - - it('tries every candidate directory, since the PTY may not share this env', () => { - const other = join(root, 'other-xdg', 'fish') - mkdirSync(other, { recursive: true }) - writeFileSync(join(other, `${SESSION_A}_history`), '- cmd: ls\n') - - expect(deleteFishHistoryFile(SESSION_A, [fishDir, other])).toBe(true) - expect(existsSync(join(other, `${SESSION_A}_history`))).toBe(false) - }) - - it('refuses a session name it did not mint', () => { - const foreign = join(fishDir, 'fish_history') - writeFileSync(foreign, 'the user’s real history\n') - - expect(deleteFishHistoryFile('fish_history', [fishDir])).toBe(false) - expect(existsSync(foreign)).toBe(true) - }) - - // Why skipped on Windows: symlinkSync needs Developer Mode or elevation there. - it.skipIf(process.platform === 'win32')( - 'refuses to follow a symlink out of the fish data dir', - () => { - const outside = join(root, 'precious.txt') - writeFileSync(outside, 'keep me\n') - symlinkSync(outside, historyFile(SESSION_A)) - - expect(deleteFishHistoryFile(SESSION_A, [fishDir])).toBe(false) - expect(existsSync(outside)).toBe(true) - } - ) - - it('reports false when there is nothing to delete', () => { - expect(deleteFishHistoryFile(SESSION_A, [fishDir])).toBe(false) - }) -}) - -describe('orphaned fish history sweep', () => { - let root: string - let fishDir: string - - beforeEach(() => { - root = mkdtempSync(join(tmpdir(), 'orca-fish-sweep-')) - fishDir = join(root, 'fish') - mkdirSync(fishDir, { recursive: true }) - }) - - afterEach(() => { - rmSync(root, { recursive: true, force: true }) - }) - - it('removes files for dead worktrees and keeps live ones', () => { - writeFileSync(join(fishDir, `${fishHistorySessionName(HASH_A)}_history`), 'live\n') - writeFileSync(join(fishDir, `${fishHistorySessionName(HASH_B)}_history`), 'dead\n') - - expect(sweepOrphanedFishHistoryFiles(new Set([HASH_A]), [fishDir])).toBe(1) - expect(existsSync(join(fishDir, `${fishHistorySessionName(HASH_A)}_history`))).toBe(true) - expect(existsSync(join(fishDir, `${fishHistorySessionName(HASH_B)}_history`))).toBe(false) - }) - - it("never touches the user's own history files", () => { - // The whole safety of the sweep rests on the orca__ prefix. - const untouched = ['fish_history', 'work_history', 'orca_history', 'orca_nothex_history'] - for (const name of untouched) { - writeFileSync(join(fishDir, name), 'mine\n') - } - - expect(sweepOrphanedFishHistoryFiles(new Set([HASH_A]), [fishDir])).toBe(0) - for (const name of untouched) { - expect(existsSync(join(fishDir, name))).toBe(true) - } - }) - - it('refuses to sweep on an empty live set, which cannot be told from a cold store', () => { - writeFileSync(join(fishDir, `${fishHistorySessionName(HASH_B)}_history`), 'dead\n') - - expect(sweepOrphanedFishHistoryFiles(new Set(), [fishDir])).toBe(0) - expect(existsSync(join(fishDir, `${fishHistorySessionName(HASH_B)}_history`))).toBe(true) - }) - - it('spares a file younger than the age guard, since the live set is a snapshot', () => { - // The reachable race: a worktree created after the snapshot but before the - // sweep looks dead while it is actively writing the history it owns. - const fresh = join(fishDir, `${fishHistorySessionName(HASH_B)}_history`) - writeFileSync(fresh, 'just created\n') - - expect(sweepOrphanedFishHistoryFiles(new Set([HASH_A]), [fishDir], 5 * 60 * 1000)).toBe(0) - expect(existsSync(fresh)).toBe(true) - }) - - it('sweeps a file older than the age guard', () => { - const stale = join(fishDir, `${fishHistorySessionName(HASH_B)}_history`) - writeFileSync(stale, 'long dead\n') - const wellPast = Date.now() + 60 * 60 * 1000 - - expect( - sweepOrphanedFishHistoryFiles(new Set([HASH_A]), [fishDir], 5 * 60 * 1000, wellPast) - ).toBe(1) - expect(existsSync(stale)).toBe(false) - }) - - it('tolerates a directory that does not exist', () => { - expect(sweepOrphanedFishHistoryFiles(new Set([HASH_A]), [join(root, 'absent')])).toBe(0) - }) - - it('sweeps every candidate directory once', () => { - const other = join(root, 'other', 'fish') - mkdirSync(other, { recursive: true }) - for (const dir of [fishDir, other]) { - writeFileSync(join(dir, `${fishHistorySessionName(HASH_B)}_history`), 'dead\n') - } - - expect(sweepOrphanedFishHistoryFiles(new Set([HASH_A]), [fishDir, other, fishDir])).toBe(2) - }) -}) diff --git a/src/main/fish-history-session.ts b/src/main/fish-history-session.ts deleted file mode 100644 index 7ab56a59490..00000000000 --- a/src/main/fish-history-session.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { lstatSync, readdirSync, rmSync } from 'node:fs' -import { homedir } from 'node:os' -import { isAbsolute, join } from 'node:path' - -/** - * Per-worktree fish history. - * - * fish ignores HISTFILE entirely — history lives at `/_history` - * where `` comes only from the `fish_history` variable, and there is no - * custom-directory knob. So the session NAME is the only isolation lever, and - * the file necessarily lands in the user's fish data dir rather than inside - * Orca's history tree. That is why deletion needs its own path here instead of - * riding the tree tombstone the way bash/zsh history does. - * - * `XDG_DATA_HOME` is deliberately not redirected: that would move every other - * tool's data for the session, not just fish's history. - */ -const SESSION_PREFIX = 'orca_' -const SAFE_SESSION_NAME = /^orca_[0-9a-f]{1,64}$/ -const ORCA_HISTORY_FILE = /^orca_([0-9a-f]{1,64})_history$/ - -export function isSafeFishHistorySession(session: unknown): session is string { - return typeof session === 'string' && SAFE_SESSION_NAME.test(session) -} - -export function fishHistorySessionName(worktreeHash: string): string { - return `${SESSION_PREFIX}${worktreeHash}` -} - -/** The directory fish will write history into, for a given environment. - * Resolve this from the PTY's SPAWN env: Orca can be launched with a different - * `XDG_DATA_HOME`/`HOME` than the shells it spawns, and fish follows its own. */ -export function resolveFishHistoryDir(env: NodeJS.ProcessEnv = process.env): string { - const xdg = env.XDG_DATA_HOME?.trim() - const dataHome = - xdg && isAbsolute(xdg) ? xdg : join(env.HOME?.trim() || homedir(), '.local', 'share') - return join(dataHome, 'fish') -} - -/** Removes one history file, refusing anything that is not a regular file. - * Why lstat: a symlink here would take `rmSync` out of the fish data dir. */ -function removeHistoryFile(path: string): boolean { - try { - if (!lstatSync(path).isFile()) { - return false - } - rmSync(path) - return true - } catch { - return false - } -} - -/** Delete one worktree's fish history file, trying every directory it could be in. - * Callers pass the directory recorded at spawn time plus this process's own, since - * the two disagree whenever Orca's environment differs from the PTY's. */ -export function deleteFishHistoryFile(session: string, dirs: Iterable): boolean { - if (!isSafeFishHistorySession(session)) { - return false - } - let removed = false - for (const dir of new Set(dirs)) { - removed = removeHistoryFile(join(dir, `${session}_history`)) || removed - } - return removed -} - -/** - * Delete every Orca-minted fish history file whose worktree is gone. - * - * Why a sweep and not just per-worktree deletion: the history file outlives the - * directory that names it. A crash between tombstone and removal, a hand-deleted - * history dir, or a file written by an older build all orphan one, and only a - * scan of the data dir can find those. Matching on Orca's own `orca__` - * prefix means the user's real `fish_history` is never a candidate. - * - * `minAgeMs` mirrors the age guard the history-tree GC already applies: the - * live-worktree set is a snapshot, so a worktree created between the snapshot - * and this sweep would otherwise look dead and lose the history it is actively - * writing. A live session's file keeps a recent mtime, so this doubles as a - * second check on the set itself. - * - * Refuses to run at all on an empty live set: that cannot be told apart from a - * store that failed to hydrate, and unlike the tree GC this deletes files - * outside Orca's own directory. - */ -export function sweepOrphanedFishHistoryFiles( - liveWorktreeHashes: ReadonlySet, - dirs: Iterable, - minAgeMs = 0, - now = Date.now() -): number { - if (liveWorktreeHashes.size === 0) { - return 0 - } - let removed = 0 - for (const dir of new Set(dirs)) { - let entries: string[] - try { - entries = readdirSync(dir) - } catch { - continue - } - for (const entry of entries) { - const hash = ORCA_HISTORY_FILE.exec(entry)?.[1] - if (!hash || liveWorktreeHashes.has(hash)) { - continue - } - const path = join(dir, entry) - if (minAgeMs > 0 && !isOlderThan(path, minAgeMs, now)) { - continue - } - if (removeHistoryFile(path)) { - removed += 1 - } - } - } - return removed -} - -/** Treats an unreadable file as too young: never delete on a failed stat. */ -function isOlderThan(path: string, minAgeMs: number, now: number): boolean { - try { - return now - lstatSync(path).mtimeMs >= minAgeMs - } catch { - return false - } -} diff --git a/src/main/gemini/hook-service.test.ts b/src/main/gemini/hook-service.test.ts index 1a80c1568bf..fc5a302a7d1 100644 --- a/src/main/gemini/hook-service.test.ts +++ b/src/main/gemini/hook-service.test.ts @@ -26,7 +26,7 @@ vi.mock('os', async (importOriginal) => { import { GeminiHookService } from './hook-service' const WINDOWS_POWERSHELL_LAUNCHER = - /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/ + /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand \S+$/ describe('GeminiHookService', () => { let homeDir: string diff --git a/src/main/ghostty/mapper-extended.test.ts b/src/main/ghostty/mapper-extended.test.ts index 402e9253f21..189e5bf4217 100644 --- a/src/main/ghostty/mapper-extended.test.ts +++ b/src/main/ghostty/mapper-extended.test.ts @@ -90,9 +90,9 @@ describe('mapGhosttyToOrca — window-padding', () => { expect(result.unsupportedKeys).toEqual([]) }) - it('preserves odd-sum dual-value padding as a fractional average', () => { + it('rounds odd-sum dual-value padding to the nearest fitted pixel', () => { const result = mapGhosttyToOrca({ 'window-padding-x': '1,2' }) - expect(result.diff).toEqual({ terminalPaddingX: 1.5 }) + expect(result.diff).toEqual({ terminalPaddingX: 2 }) expect(result.unsupportedKeys).toEqual([]) }) diff --git a/src/main/ghostty/numeric-config-values.ts b/src/main/ghostty/numeric-config-values.ts index f8a7ce7b468..5ced69afa48 100644 --- a/src/main/ghostty/numeric-config-values.ts +++ b/src/main/ghostty/numeric-config-values.ts @@ -1,3 +1,5 @@ +import { normalizeTerminalPadding } from '../../shared/terminal-padding-settings' + // Why: `Number("1e10")` succeeds and passes `Number.isInteger`, so a Ghostty // config with `window-padding-x = 1e9` would sail through the mapper and land // an absurd value in the store. Restrict to plain decimal integers. @@ -11,8 +13,7 @@ export const parseStrictInt = (v: string): number | null => { } // Why: Ghostty accepts "top,bottom" / "left,right" pairs for window paddings, -// but Orca stores a single value per axis — average the pair so the total -// padding along the axis stays the same. +// but Orca stores an integer per axis — preserve the nearest total inset. export const parsePaddingValue = (v: string): number | null => { const parts = v.split(',') if (parts.length > 2) { @@ -26,5 +27,5 @@ export const parsePaddingValue = (v: string): number | null => { } nums.push(num) } - return nums.reduce((sum, num) => sum + num, 0) / nums.length + return normalizeTerminalPadding(nums.reduce((sum, num) => sum + num, 0) / nums.length) } diff --git a/src/main/grok/hook-service.test.ts b/src/main/grok/hook-service.test.ts index ba469506316..f08779aed02 100644 --- a/src/main/grok/hook-service.test.ts +++ b/src/main/grok/hook-service.test.ts @@ -23,7 +23,7 @@ import { POSIX_HOOK_STDIN_READER } from '../agent-hooks/hook-stdin-contract' const GROK_SCRIPT_FILE_NAME = process.platform === 'win32' ? 'grok-hook.cmd' : 'grok-hook.sh' const WINDOWS_POWERSHELL_LAUNCHER = - /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/ + /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand \S+$/ type WindowsGrokHookRun = { status: number | null @@ -118,7 +118,7 @@ describe('GrokHookService', () => { expect(script).toContain('set "ORCA_GROK_HOME="') expect(script).toContain('if not defined GROK_HOME goto :orca_grok_home_ready') expect(script).toContain('%GROK_HOME:~4096,1%') - expect(script).toContain('set "ORCA_GROK_HOME=%GROK_HOME%"') + expect(script).toContain('set "ORCA_GROK_HOME=%GROK_HOME:"=%"') expect(script).toContain('%ORCA_GROK_HOME:~4096,1%') expect(script).toContain(':orca_grok_home_ready') expect(script).toContain('if not defined ORCA_GROK_HOME goto :orca_grok_home_ready') @@ -269,7 +269,7 @@ describe('GrokHookService', () => { expect(script).toContain('%SystemRoot%\\System32\\curl.exe') // Why: windows-grok-hook-script.test.ts pins the GROK_HOME guard shape itself, // and does so on every platform rather than only on Windows runners. - expect(script).toContain('set "ORCA_GROK_HOME=%GROK_HOME%"') + expect(script).toContain('set "ORCA_GROK_HOME=%GROK_HOME:"=%"') expect(script).toContain('--data-urlencode "grokHome=%ORCA_GROK_HOME%"') } else { // Why: payload is piped to curl via stdin (`payload@-`) so it never lands @@ -343,7 +343,11 @@ describe('GrokHookService', () => { `${JSON.stringify( { hooks: { - Notification: [{ hooks: [{ type: 'command', command: '/usr/local/bin/user-hook' }] }] + Notification: [ + { + hooks: [{ type: 'command', command: '/usr/local/bin/user-hook' }] + } + ] } }, null, diff --git a/src/main/grok/windows-grok-hook-script.test.ts b/src/main/grok/windows-grok-hook-script.test.ts index a8aea9065f0..98da524e7c7 100644 --- a/src/main/grok/windows-grok-hook-script.test.ts +++ b/src/main/grok/windows-grok-hook-script.test.ts @@ -26,8 +26,9 @@ function findUnguardedSubstringReads(lines: readonly string[]): string[] { defined.add(guarded[1]) } // A value is provably non-empty only if something outside an expansion survives: - // `set "V=%OTHER%"` may undefine V, `set "V=%V%."` cannot. - for (const [, name, value] of line.matchAll(/set "(\w+)=([^"]*)"/g)) { + // `set "V=%OTHER%"` may undefine V, `set "V=%V%."` cannot. The lazy value stops at + // the quote that closes the `set`, so a substitution like `%V:"=%` stays one token. + for (const [, name, value] of line.matchAll(/set "(\w+)=(.*?)"(?=\s|$)/g)) { if (value.replaceAll(/%[^%]*%/g, '') === '') { defined.delete(name) } else { @@ -93,6 +94,7 @@ describe.skipIf(process.platform !== 'win32')('buildWindowsGrokHookScript (win32 grokHome: string | null paneKey: string | null worktreeId: string | null + payload: string | null } const PANE_KEY = 'tab!1:pane!2' @@ -149,7 +151,8 @@ describe.skipIf(process.platform !== 'win32')('buildWindowsGrokHookScript (win32 stderr, grokHome: posted?.get('grokHome') ?? null, paneKey: posted?.get('paneKey') ?? null, - worktreeId: posted?.get('worktreeId') ?? null + worktreeId: posted?.get('worktreeId') ?? null, + payload: posted?.get('payload') ?? null }) ) }) @@ -188,6 +191,36 @@ describe.skipIf(process.platform !== 'win32')('buildWindowsGrokHookScript (win32 expect(result.grokHome).toBe('C:\\Users\\test\\.grok\\.') }) + // Why (#14221): `setx GROK_HOME "C:\path\"` stores `C:\path"` — the CRT turns the + // `\"` into a literal quote. That quote unbalanced the trailing-backslash `if`, so + // cmd aborted the whole script before curl and every Grok hook event failed. + it('exits 0 and strips a trailing quote from GROK_HOME', async () => { + const result = await runHook('C:\\Users\\test\\.grok"') + + expect(result.stderr).not.toContain('unexpected at this time') + expect(result.status).toBe(0) + expect(result.grokHome).toBe('C:\\Users\\test\\.grok') + expect(result.payload).toBe('{"session_id":"s"}') + }) + + // Why: an embedded quote closed curl's `grokHome=` argument early, which swallowed + // the `^` continuation and dropped the `payload@-` line — a silent, exit-0 failure. + it('strips an embedded quote without truncating the curl arguments', async () => { + const result = await runHook('C:\\Users\\a"b\\.grok') + + expect(result.status).toBe(0) + expect(result.grokHome).toBe('C:\\Users\\ab\\.grok') + expect(result.payload).toBe('{"session_id":"s"}') + }) + + it('posts an empty grokHome when GROK_HOME is only a quote', async () => { + const result = await runHook('"') + + expect(result.status).toBe(0) + expect(result.grokHome).toBe('') + expect(result.payload).toBe('{"session_id":"s"}') + }) + it('drops a GROK_HOME past the envelope limit', async () => { const result = await runHook(`C:\\${'a'.repeat(4094)}`) diff --git a/src/main/grok/windows-grok-hook-script.ts b/src/main/grok/windows-grok-hook-script.ts index 5a6e81ee633..e68c8914eeb 100644 --- a/src/main/grok/windows-grok-hook-script.ts +++ b/src/main/grok/windows-grok-hook-script.ts @@ -21,6 +21,7 @@ const WINDOWS_GROK_HOOK_POST_COMMAND = buildWindowsAgentHookPostCommand('grok', * * - Guard substring ops behind `if defined` + goto (not a parenthesized block). * - Reject oversized values before copying them onto a cmd input line. + * - Strip `"` before the value reaches an `if` operand or the curl argument. */ export function buildWindowsGrokHookScript(): string { return [ @@ -35,7 +36,10 @@ export function buildWindowsGrokHookScript(): string { 'set "ORCA_GROK_HOME="', 'if not defined GROK_HOME goto :orca_grok_home_ready', `if not "%GROK_HOME:~${GROK_HOME_ENVELOPE_MAX_LENGTH},1%"=="" goto :orca_grok_home_ready`, - 'set "ORCA_GROK_HOME=%GROK_HOME%"', + // Why (#14221): `setx GROK_HOME "C:\path\"` stores a literal trailing quote, which + // unbalances the trailing-backslash `if` below (exit 255) and truncates the curl + // argument. `"` is illegal in a Windows path, so strip it rather than preserve it. + 'set "ORCA_GROK_HOME=%GROK_HOME:"=%"', 'if not defined ORCA_GROK_HOME goto :orca_grok_home_ready', 'if "%ORCA_GROK_HOME:~-1%"=="\\" set "ORCA_GROK_HOME=%ORCA_GROK_HOME%."', // Why: the trailing-backslash safety sentinel counts toward the relay envelope. diff --git a/src/main/ipc/browser.ts b/src/main/ipc/browser.ts index 4a8e0fad03a..2f374e186b2 100644 --- a/src/main/ipc/browser.ts +++ b/src/main/ipc/browser.ts @@ -15,6 +15,11 @@ import { } from './browser-grab-ipc' import { registerBrowserSessionProfileHandlers } from './browser-session-profile-ipc' import type { BrowserCertificateProceedResult } from '../../shared/browser-workspace-types' +import { + cancelBrowserWebAuthnAccountRequests, + respondToBrowserWebAuthnAccountRequest +} from '../browser/browser-webauthn-account-picker' +import type { BrowserWebAuthnAccountResponse } from '../../shared/browser-webauthn-account' let agentBrowserBridgeRef: AgentBrowserBridge | null = null @@ -38,6 +43,7 @@ export function registerBrowserHandlers(): void { ipcMain.removeHandler('browser:unregisterGuest') ipcMain.removeHandler('browser:activeTabChanged') ipcMain.removeHandler('browser:proceedCertificate') + ipcMain.removeHandler('browser:respondWebAuthnAccount') const registerGuest = ( event: Electron.IpcMainInvokeEvent, @@ -124,11 +130,22 @@ export function registerBrowserHandlers(): void { if (wcId !== null && agentBrowserBridgeRef) { agentBrowserBridgeRef.onTabClosed(wcId) } + cancelBrowserWebAuthnAccountRequests(args.browserPageId) browserManager.unregisterGuest(args.browserPageId) disposeGrabModeStateForPage(args.browserPageId) return true }) + ipcMain.handle( + 'browser:respondWebAuthnAccount', + (event, response: BrowserWebAuthnAccountResponse): boolean => { + if (!isTrustedBrowserRenderer(event.sender)) { + return false + } + return respondToBrowserWebAuthnAccountRequest(event.sender, response) + } + ) + ipcMain.handle( 'browser:proceedCertificate', ( diff --git a/src/main/ipc/pty-runtime-kill-and-exit.test.ts b/src/main/ipc/pty-runtime-kill-and-exit.test.ts index e8045be70ab..2da8b80d4b8 100644 --- a/src/main/ipc/pty-runtime-kill-and-exit.test.ts +++ b/src/main/ipc/pty-runtime-kill-and-exit.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { spawnMock } from './pty-ipc-mock-registry' import { makeDeferred } from './pty-ipc-test-constants' import { setupPtyIpcSuite } from './pty-ipc-test-harness' +import { LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId } from '../../shared/execution-host' import { registerPtyHandlers, registerSshPtyProvider, @@ -102,11 +103,16 @@ describe('registerPtyHandlers', () => { }) registerSshPtyProvider('ssh-a', { listProcesses: sshAList } as never) registerSshPtyProvider('ssh-b', { listProcesses: sshBList } as never) - const runtime = { setPtyController: vi.fn() } + setPtyOwnership('ssh-b-pty', 'ssh-b') + const runtime = { + setPtyController: vi.fn(), + markPtyLivenessUnverifiable: vi.fn() + } handlers.clear() registerPtyHandlers(mainWindow as never, runtime as never) const controller = runtime.setPtyController.mock.calls[0]?.[0] as { listProcesses(connectionId?: string | null): Promise<{ id: string }[]> + listProcessesWithHostScope(): Promise<{ processes: { id: string }[]; hostIds: string[] }> } await expect(controller.listProcesses(null)).resolves.toEqual([ @@ -125,6 +131,17 @@ describe('registerPtyHandlers', () => { { id: 'ssh-a-pty' } ]) expect(sshBList).toHaveBeenCalledOnce() + expect(runtime.markPtyLivenessUnverifiable).toHaveBeenCalledWith( + 'ssh-b-pty', + 'ssh-b unavailable' + ) + + await expect(controller.listProcessesWithHostScope()).resolves.toEqual({ + processes: [{ id: 'local-pty', title: 'Local', cwd: '/local' }, { id: 'ssh-a-pty' }], + hostIds: [LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId('ssh-a')] + }) + expect(sshBList).toHaveBeenCalledTimes(2) + deletePtyOwnership('ssh-b-pty') }) it('returns unavailable runtime confirmation for unsupported or missing providers', async () => { registerSshPtyProvider('ssh-1', {} as never) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 2962c39cef0..6fbbd4cbe86 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -18,7 +18,11 @@ import { retireTerminalSurfaceFromPersistence } from '../runtime/mobile-session- import { SSH_PROVIDER_UNREGISTERED_REASON } from '../../shared/pty-liveness-verdict' import type { GlobalSettings } from '../../shared/global-settings-types' import type { TuiAgent } from '../../shared/tui-agent' -import { toSshExecutionHostId } from '../../shared/execution-host' +import { + LOCAL_EXECUTION_HOST_ID, + toSshExecutionHostId, + type ExecutionHostId +} from '../../shared/execution-host' import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import { terminalOutputBacklogCapChars } from '../../shared/terminal-scrollback-policy' import type { @@ -73,6 +77,7 @@ import { } from '../../shared/pi-agent-kind' import { isPwshAvailableAsync } from '../pwsh' import { LocalPtyProvider } from '../providers/local-pty-provider' +import type { PtyProcessInfo } from '../providers/pty-process-info' import { normalizeWindowsTerminalCwd } from '../providers/windows-shell-args' import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types' import { isPtyWriteUnavailableError } from '../providers/pty-write-unavailable-error' @@ -260,6 +265,36 @@ function registeredPtyProviders(): RegisteredPtyProvider[] { ] } +async function listRegisteredPtyProcessesWithHostScope( + onSshInventoryUnavailable?: (connectionId: string, error: unknown) => void +): Promise<{ + processes: PtyProcessInfo[] + hostIds: ExecutionHostId[] +}> { + const providers = registeredPtyProviders() + const providerSessions = await Promise.all( + providers.map(async ({ provider, connectionId }) => { + try { + return { + processes: await provider.listProcesses(), + hostId: connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID + } + } catch (error) { + if (!connectionId) { + throw error + } + onSshInventoryUnavailable?.(connectionId, error) + return null + } + }) + ) + const respondingSessions = providerSessions.filter((session) => session !== null) + return { + processes: respondingSessions.flatMap((session) => session.processes), + hostIds: respondingSessions.map((session) => session.hostId) + } +} + const SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS = 30_000 // Why: kill switch — flip to disable producer flow control (pause/resume) without untangling the wiring. const PRODUCER_FLOW_CONTROL_ENABLED = true @@ -4753,8 +4788,7 @@ export function registerPtyHandlers( rows: args.rows, cwd, env, - ...(isNewDaemonSession ? { isNewSession: true } : {}), - historyIsolationEnabled: getSettings?.()?.terminalScopeHistoryByWorktree ?? true + ...(isNewDaemonSession ? { isNewSession: true } : {}) } if (!args.connectionId && !isDaemonHostSpawn) { spawnOptions.codexHomePathOverride = { value: selectedCodexHomePath } @@ -5808,19 +5842,10 @@ export function registerPtyHandlers( throw error } } - const providerSessions = await Promise.all([ - localProvider.listProcesses(), - ...Array.from(sshProviders, async ([sshConnectionId, provider]) => { - try { - return await provider.listProcesses() - } catch (error) { - markSshInventoryUnverifiable(sshConnectionId, error) - return [] - } - }) - ]) - return providerSessions.flat() + return (await listRegisteredPtyProcessesWithHostScope(markSshInventoryUnverifiable)).processes }, + listProcessesWithHostScope: () => + listRegisteredPtyProcessesWithHostScope(markSshInventoryUnverifiable), serializeBuffer: (ptyId, opts) => { // Why: mobile xterm must start from the desktop's exact screen state/dimensions before live TUI chunks render correctly. return requestSerializedBuffer(ptyId, opts) @@ -6529,8 +6554,7 @@ export function registerPtyHandlers( cwd, ...(prevalidatedCwd && !isDaemonHostSpawn ? { prevalidatedCwd } : {}), env: spawnEnv, - ...(isMintedSessionId ? { isNewSession: true } : {}), - historyIsolationEnabled: getSettings?.()?.terminalScopeHistoryByWorktree ?? true + ...(isMintedSessionId ? { isNewSession: true } : {}) } if (!args.connectionId && !isDaemonHostSpawn) { spawnOptions.codexHomePathOverride = { value: selectedCodexHomePath } diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index c7ec109e2e6..beebaf70058 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -265,7 +265,6 @@ import { removeStaleLocalWorktreeRegistrationAfterFilesystemRemoval, recoverLocalWindowsWorktreeRemoval } from '../local-worktree-removal-recovery' -import { deleteRemoteWorktreeHistory } from '../remote-worktree-history-cleanup' const NullableWorkspaceLinkedItemSchema = WorkspaceLinkedItemSchema.nullable() const NullableTaskSourceContextSchema = TaskSourceContextSchema.nullable() @@ -2494,15 +2493,15 @@ export function registerWorktreeHandlers( 'Cannot delete the project root workspace. Remove the folder project instead.' ) } - const ownerHost = parseExecutionHostId(removalHostId) - const sshPtyProvider = - ownerHost?.kind === 'ssh' ? getSshPtyProvider(ownerHost.targetId) : undefined // Why: folder workspaces share one root, so there's no Git remove step to close shells; sweep PTYs before dropping metadata. await withWorktreeRemoveStageSpan('pty_sweep', 'folder', async () => { // Folder projects can be SSH-backed, so fence the sweep to the owning host exactly // like the git paths — the local inventory must never reach a remote workspace's id. // The resolved repo is authoritative here: path-derived metadata is shared by // same-id host copies and can describe a different owner's workspace. + const ownerHost = parseExecutionHostId(removalHostId) + const sshPtyProvider = + ownerHost?.kind === 'ssh' ? getSshPtyProvider(ownerHost.targetId) : undefined const externalHost = ownerHost?.kind === 'ssh' || ownerHost?.kind === 'runtime' await killAllProcessesForWorktree(args.worktreeId, { runtime, @@ -2525,7 +2524,6 @@ export function registerWorktreeHandlers( }) }) await withWorktreeRemoveStageSpan('metadata_purge', 'folder', async () => { - await deleteRemoteWorktreeHistory(sshPtyProvider, args.worktreeId) removeWorktreeMetadataAndTransientState( store, args.worktreeId, @@ -2614,14 +2612,6 @@ export function registerWorktreeHandlers( } finally { await removalGate.finish(removalCompleted) } - // Why history first: the worktree is already gone from git and - // disk by here, so a rejecting push-target cleanup must not be - // able to skip history removal and leave the user's commands on - // the remote host. - await deleteRemoteWorktreeHistory( - getSshPtyProvider(repo.connectionId), - args.worktreeId - ) await cleanupUnusedWorktreePushTargetRemoteSsh( provider!, repo.path, @@ -2730,14 +2720,6 @@ export function registerWorktreeHandlers( } // Why: a manually deleted worktree is already gone; persisted metadata proves it was an Orca-known row, so no force is needed. if (repo.connectionId) { - // Why history first: the worktree is already gone from git and - // disk by here, so a rejecting push-target cleanup must not be - // able to skip history removal and leave the user's commands on - // the remote host. - await deleteRemoteWorktreeHistory( - getSshPtyProvider(repo.connectionId), - args.worktreeId - ) await cleanupUnusedWorktreePushTargetRemoteSsh( provider!, repo.path, @@ -2916,10 +2898,6 @@ export function registerWorktreeHandlers( removedPushTarget, store ) - await deleteRemoteWorktreeHistory( - getSshPtyProvider(remoteConnectionId), - args.worktreeId - ) rememberPreservedBranchCleanupTarget( args.worktreeId, removalHostId, diff --git a/src/main/persistence/tracking-repos/project-host-compatibility.test.ts b/src/main/persistence/tracking-repos/project-host-compatibility.test.ts new file mode 100644 index 00000000000..540dc6955f7 --- /dev/null +++ b/src/main/persistence/tracking-repos/project-host-compatibility.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' +import type { ProjectHostSetup } from '../../../shared/project-types' +import type { Repo } from '../../../shared/repo-types' +import { mergeProjectHostSetupCompatibilityState } from './project-host-compatibility' + +const PROJECT_ID = 'github:acme/orca' + +function repo(overrides: Partial & Pick): Repo { + return { + path: `/src/${overrides.id}`, + displayName: 'orca', + addedAt: 1, + upstream: { owner: 'acme', repo: 'orca' }, + ...overrides + } as Repo +} + +function pendingSetup(overrides: Partial = {}): ProjectHostSetup { + return { + id: `${PROJECT_ID}::ssh:devbox`, + projectId: PROJECT_ID, + hostId: 'ssh:devbox', + repoId: '', + path: '', + displayName: 'orca', + setupState: 'not-set-up', + setupMethod: 'pending', + createdAt: 1, + updatedAt: 1, + ...overrides + } as ProjectHostSetup +} + +describe('mergeProjectHostSetupCompatibilityState', () => { + it('keeps a placeholder for a host with no repo yet', () => { + const merged = mergeProjectHostSetupCompatibilityState( + { projects: [], projectHostSetups: [pendingSetup()] }, + [repo({ id: 'local-repo' })] + ) + + expect(merged.projectHostSetups.map((setup) => setup.hostId)).toEqual(['local', 'ssh:devbox']) + }) + + // Setting a location on a host that already had a placeholder projects a ready setup for + // the same project+host; the placeholder must not survive to shadow it. + it('drops a placeholder once a repo covers the same project and host', () => { + const merged = mergeProjectHostSetupCompatibilityState( + { projects: [], projectHostSetups: [pendingSetup()] }, + [repo({ id: 'local-repo' }), repo({ id: 'devbox-repo', executionHostId: 'ssh:devbox' })] + ) + + expect(merged.projectHostSetups).toHaveLength(2) + expect(merged.projectHostSetups.every((setup) => setup.setupState === 'ready')).toBe(true) + expect(merged.projectHostSetups.map((setup) => setup.id)).toEqual(['local-repo', 'devbox-repo']) + }) + + it('keeps the placeholder when the repo covers a different host', () => { + const merged = mergeProjectHostSetupCompatibilityState( + { projects: [], projectHostSetups: [pendingSetup({ hostId: 'ssh:other' })] }, + [repo({ id: 'devbox-repo', executionHostId: 'ssh:devbox' })] + ) + + expect(merged.projectHostSetups.map((setup) => setup.hostId)).toEqual([ + 'ssh:devbox', + 'ssh:other' + ]) + }) +}) diff --git a/src/main/persistence/tracking-repos/project-host-compatibility.ts b/src/main/persistence/tracking-repos/project-host-compatibility.ts index 88e80ecdb93..0b51c758652 100644 --- a/src/main/persistence/tracking-repos/project-host-compatibility.ts +++ b/src/main/persistence/tracking-repos/project-host-compatibility.ts @@ -23,6 +23,10 @@ export function isRepoBackedProjectHostSetup( return repoId.length > 0 && (currentRepoIds.has(repoId) || setup.id === repoId) } +function projectHostKey(setup: Pick): string { + return `${setup.projectId}\u0000${setup.hostId}` +} + export function mergeProjectHostSetupCompatibilityState( state: Pick, repos: readonly Repo[] @@ -35,6 +39,7 @@ export function mergeProjectHostSetupCompatibilityState( const currentRepoIds = new Set(repos.map((repo) => repo.id)) const projectedProjectIds = new Set(projection.projects.map((project) => project.id)) const projectedSetupIds = new Set(projection.setups.map((setup) => setup.id)) + const projectedHosts = new Set(projection.setups.map(projectHostKey)) // Why: legacy/repo-backed setup rows reuse the repo id; keep only independent rows so repo deletion leaves no ghosts. const independentSetups = (state.projectHostSetups ?? []) .filter((setup) => { @@ -48,6 +53,10 @@ export function mergeProjectHostSetupCompatibilityState( const remappedProjectId = succession.remappedProjectIds.get(setup.projectId) return remappedProjectId ? { ...setup, projectId: remappedProjectId } : setup }) + // Why: a project resolves to one setup per host. Once a repo projection covers that + // pair, a leftover placeholder is a ghost that shadows the ready row — it sorts first + // and reads back as "not set up". Runs after the remap so renamed rows are caught too. + .filter((setup) => !projectedHosts.has(projectHostKey(setup))) const independentProjectIds = new Set(independentSetups.map((setup) => setup.projectId)) const independentProjects = (state.projects ?? []) .filter( diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index cf98ca7cf0b..af0e17656bd 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -19,10 +19,8 @@ import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id' import { isBracketedPasteSafeShell } from '../../shared/startup-command-submission' import { injectHistoryEnv, - injectWslFishHistoryEnv, - updateHistoryEnvForFallback, - logHistoryInjection, - type HistoryInjectionResult + updateHistFileForFallback, + logHistoryInjection } from '../terminal-history' import type { IPtyProvider, PtyProcessInfo, PtySpawnOptions, PtySpawnResult } from './types' import { @@ -874,15 +872,7 @@ export class LocalPtyProvider implements IPtyProvider { historyResult = injectHistoryEnv(finalEnv, worktreeId, effectiveShellPath, cwd, { wslDistro: launchWslDistro }) - if (isWslTerminal && launchWslDistro) { - injectWslFishHistoryEnv(finalEnv, worktreeId, launchWslDistro) - addWslEnvKeys(finalEnv, ['HISTFILE', 'fish_history']) - } logHistoryInjection(worktreeId, historyResult) - } else { - // Why: injectHistoryEnv is what normally clears it, so when history is off - // an inherited ORCA_HISTFILE would still reach the wrapper. Credit: #11146. - delete finalEnv.ORCA_HISTFILE } await prepareLocalPtySpawn(id) @@ -905,9 +895,8 @@ export class LocalPtyProvider implements IPtyProvider { ptySpawn: pty.spawn, getShellReadyConfig: getFallbackShellReadyConfig, // Why: on zsh→bash fallback HISTFILE still points to zsh_history; update before spawn so the child inherits it (design doc §8). - onBeforeFallbackSpawn: historyResult?.historyDir - ? (env, fallbackShell) => - updateHistoryEnvForFallback(env, fallbackShell, historyResult as HistoryInjectionResult) + onBeforeFallbackSpawn: historyResult?.histFile + ? (env, fallbackShell) => updateHistFileForFallback(env, fallbackShell) : undefined, windowsFallbackAttempts }) diff --git a/src/main/providers/local-pty-shell-ready-wrapper-generation.ts b/src/main/providers/local-pty-shell-ready-wrapper-generation.ts index 7c32a283aa2..29863d0a169 100644 --- a/src/main/providers/local-pty-shell-ready-wrapper-generation.ts +++ b/src/main/providers/local-pty-shell-ready-wrapper-generation.ts @@ -11,8 +11,7 @@ import { getZshEnvTemplate, getZshFinalZdotdirRestoreBlock, getZshShellReadyMarkerRegistrationBlock, - getZshStartupFileSourceBlock, - ZSH_HISTFILE_RESTORE_BLOCK + getZshStartupFileSourceBlock } from '../shell-templates' import { getBashShellReadyRcfileContent } from './local-pty-shell-ready-bash-rcfile' import { @@ -45,7 +44,6 @@ if [[ ! -o login ]]; then ${getPosixOmpShellWrapper()} # Why: Codex must keep using Orca's runtime CODEX_HOME after rc files. [[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}" -${ZSH_HISTFILE_RESTORE_BLOCK} ${getPosixCodexShellLaunchPreflight()} fi __orca_osc133_precmd() { @@ -98,7 +96,6 @@ __orca_restore_agent_teams_path [[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" ${getPosixOmpShellWrapper()} [[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}" -${ZSH_HISTFILE_RESTORE_BLOCK} ${getPosixCodexShellLaunchPreflight()} ${getZshShellReadyMarkerRegistrationBlock(SHELL_READY_MARKER_ESCAPED)} ${getZshFinalZdotdirRestoreBlock()} diff --git a/src/main/providers/pty-provider-contract.ts b/src/main/providers/pty-provider-contract.ts index 3ebdb6827fb..43bbb15a125 100644 --- a/src/main/providers/pty-provider-contract.ts +++ b/src/main/providers/pty-provider-contract.ts @@ -70,8 +70,6 @@ export type PtySpawnOptions = { * Existing-session attach paths must stay false so recovery checks do not * replace the daemon out from under a still-live PTY. */ isNewSession?: boolean - /** Host setting forwarded additively to the process owner; old owners ignore it. */ - historyIsolationEnabled?: boolean /** Attach the named session atomically or fail without creating a process. */ attachOnly?: boolean /** Exact persisted owner expected by an attach-only routing decision. */ @@ -121,8 +119,6 @@ export type IPtyProvider = { /** Re-probes a degraded durable host before main commits to fallback spawn semantics. */ recoverFreshSpawnRouting?: () => Promise spawn(opts: PtySpawnOptions): Promise - /** Process-owner cleanup for history stored outside the workspace tree. */ - deleteWorktreeHistory?: (worktreeId: string) => Promise /** Whether this spawn target can append the Git guard after its final env merge. */ supportsGitCredentialGuardHost?: (sessionId?: string) => boolean /** Explicit false selects pre-claim legacy spawn for a preserved old daemon. */ diff --git a/src/main/providers/ssh-pty-provider.test.ts b/src/main/providers/ssh-pty-provider.test.ts index b3b64be7a71..40301906f52 100644 --- a/src/main/providers/ssh-pty-provider.test.ts +++ b/src/main/providers/ssh-pty-provider.test.ts @@ -369,16 +369,14 @@ describe('SshPtyProvider', () => { }) }) - it('forwards shell, WSL, and scoped-history options to the relay mux', async () => { + it('forwards explicit shellOverride and terminalWindowsWslDistro to the relay mux', async () => { mux.request.mockResolvedValue({ id: 'pty-2' }) await provider.spawn({ cols: 120, rows: 40, shellOverride: 'powershell.exe', - terminalWindowsWslDistro: 'Ubuntu', - worktreeId: 'repo-1::/remote/wt', - historyIsolationEnabled: true + terminalWindowsWslDistro: 'Ubuntu' }) expectRequest(mux.request, 'pty.spawn', { @@ -387,9 +385,7 @@ describe('SshPtyProvider', () => { cwd: undefined, env: { [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true' }, shellOverride: 'powershell.exe', - terminalWindowsWslDistro: 'Ubuntu', - worktreeId: 'repo-1::/remote/wt', - historyIsolationEnabled: true + terminalWindowsWslDistro: 'Ubuntu' }) }) diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index 99df854f4d0..eb24ffdbb8b 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -14,7 +14,7 @@ import { spawnFreshSshPty } from './ssh-agent-session-create-operation' import { mapSshPtyProcessList } from './ssh-agent-session-process-list' import { requestSshPtyAttach, - reattachSshPtySessionForSpawn, + reattachSshPtySessionWithExitFence, type PtySourceRecoveryRequest, type SshPtyAttachResult } from './ssh-pty-session-reattach' @@ -22,6 +22,7 @@ import { buildSshPtySpawnRequest } from './ssh-pty-spawn-request' import { SshPtySpawnExitRaceTracker } from './ssh-pty-spawn-exit-race' import { SshAgentSessionCapabilities } from './ssh-agent-session-capabilities' import type { PtyProcessInspection } from './pty-process-inspection' +import { SSH_SESSION_EXPIRED_ERROR } from './ssh-pty-errors' import { writeToSshPty, writeToSshPtyWithSettlement } from './ssh-pty-write' // Why: sequential relay teardown calls share one absolute budget; convert to the mux-relative timeout only at dispatch. @@ -90,18 +91,36 @@ export class SshPtyProvider implements IPtyProvider { } } if (opts.sessionId) { - return await reattachSshPtySessionForSpawn({ - mux: this.mux, - connectionId: this.connectionId, - sessionId: opts.sessionId, - options: opts, - exitRaceTracker: this.spawnExitRaces, - installSourceActivation: (relayPtyId, activation) => - this.outputState.installReceivingActivation(relayPtyId, activation), - rememberPtyIncarnation: (relayPtyId, incarnationId) => - this.outputState.rememberPtyIncarnation(relayPtyId, incarnationId), - acceptLivePty: (relayPtyId) => this.livePtyIds.add(relayPtyId) - }) + let result: Awaited> | undefined + try { + result = await reattachSshPtySessionWithExitFence({ + mux: this.mux, + connectionId: this.connectionId, + sessionId: opts.sessionId, + options: opts, + exitRaceTracker: this.spawnExitRaces, + installSourceActivation: (relayPtyId, activation) => + this.outputState.installReceivingActivation(relayPtyId, activation), + rememberPtyIncarnation: (relayPtyId, incarnationId) => + this.outputState.rememberPtyIncarnation(relayPtyId, incarnationId) + }) + if (result.sourceRecovery?.status === 'restoreRequired') { + throw new Error( + `${SSH_SESSION_EXPIRED_ERROR}: ${toRelaySshPtyId(this.connectionId, result.id)}` + ) + } + this.livePtyIds.add(result.id) + result.sourceActivationLease?.commit() + const { + sourceActivationLease: _lease, + sourceRecovery: _sourceRecovery, + ...spawnResult + } = result + return spawnResult + } catch (error) { + result?.sourceActivationLease?.rollback() + throw error + } } const supportsCreateOperation = opts.agentSessionCreateOperationId @@ -132,10 +151,6 @@ export class SshPtyProvider implements IPtyProvider { }) } - async deleteWorktreeHistory(worktreeId: string): Promise { - await this.mux.request('pty.deleteWorktreeHistory', { worktreeId }) - } - async supportsAgentSessionClaims(options: { signal?: AbortSignal } = {}): Promise { return await this.agentSessionCapabilities.supportsClaims(options) } diff --git a/src/main/providers/ssh-pty-session-reattach.ts b/src/main/providers/ssh-pty-session-reattach.ts index 06e4c9ae7c5..34974940caf 100644 --- a/src/main/providers/ssh-pty-session-reattach.ts +++ b/src/main/providers/ssh-pty-session-reattach.ts @@ -255,39 +255,3 @@ export async function reattachSshPtySessionWithExitFence( args.exitRaceTracker.finish(operation) } } - -/** - * The full reattach path a spawn takes when it carries a sessionId: fence the - * exit race, reject a session the relay can no longer restore, and commit or - * roll back the source-activation lease. - * - * Lives here rather than in SshPtyProvider.spawn so the lease's commit and - * rollback stay in one place — a caller that only wrapped the fence could - * return without committing and silently leak the activation. - */ -export async function reattachSshPtySessionForSpawn( - args: Parameters[0] & { - acceptLivePty: (relayPtyId: string) => void - } -): Promise { - let result: SshPtyReattachResult | undefined - try { - result = await reattachSshPtySessionWithExitFence(args) - if (result.sourceRecovery?.status === 'restoreRequired') { - throw new Error( - `${SSH_SESSION_EXPIRED_ERROR}: ${toRelaySshPtyId(args.connectionId, result.id)}` - ) - } - args.acceptLivePty(result.id) - result.sourceActivationLease?.commit() - const { - sourceActivationLease: _lease, - sourceRecovery: _sourceRecovery, - ...spawnResult - } = result - return spawnResult - } catch (error) { - result?.sourceActivationLease?.rollback() - throw error - } -} diff --git a/src/main/providers/ssh-pty-spawn-request.ts b/src/main/providers/ssh-pty-spawn-request.ts index 9f493d9e778..889c0813ee9 100644 --- a/src/main/providers/ssh-pty-spawn-request.ts +++ b/src/main/providers/ssh-pty-spawn-request.ts @@ -22,10 +22,6 @@ export function buildSshPtySpawnRequest(args: { // Why: the relay needs launch identity for plugin env overlays and provider-side delivery. ...(options.command ? { command: options.command } : {}), ...(options.launchAgent ? { launchAgent: options.launchAgent } : {}), - ...(options.worktreeId ? { worktreeId: options.worktreeId } : {}), - ...(options.historyIsolationEnabled !== undefined - ? { historyIsolationEnabled: options.historyIsolationEnabled } - : {}), ...(options.shellOverride !== undefined ? { shellOverride: options.shellOverride } : {}), ...(options.terminalWindowsWslDistro !== undefined ? { terminalWindowsWslDistro: options.terminalWindowsWslDistro } diff --git a/src/main/providers/working-directory-validation.test.ts b/src/main/providers/working-directory-validation.test.ts index 6531176a3de..8c7d3867b11 100644 --- a/src/main/providers/working-directory-validation.test.ts +++ b/src/main/providers/working-directory-validation.test.ts @@ -11,12 +11,20 @@ vi.mock('../wsl', async (importOriginal) => { }) import { validateWorkingDirectoryAsync } from './local-pty-utils' +import { _resetWorkingDirectoryValidationStateForTest } from './working-directory-validation' let tempDir: string +/** UNC probes now start after a lane acquire, so let that microtask land. */ +const flushLaneAcquire = async (): Promise => { + await Promise.resolve() + await Promise.resolve() +} + beforeEach(async () => { wslUncDirectoryExistsAsyncMock.mockReset() wslUncDirectoryExistsAsyncMock.mockResolvedValue(null) + _resetWorkingDirectoryValidationStateForTest() tempDir = await mkdtemp(path.join(os.tmpdir(), 'orca-cwd-validate-')) }) @@ -137,6 +145,71 @@ describe('validateWorkingDirectoryAsync', () => { }) }) + describe('UNC route concurrency', () => { + beforeEach(() => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('caps concurrent probes against one unreachable distro', async () => { + // Four dead paths would otherwise hold all four libuv fs threads. + let inFlight = 0 + let peak = 0 + wslUncDirectoryExistsAsyncMock.mockImplementation( + () => + new Promise(() => { + inFlight += 1 + peak = Math.max(peak, inFlight) + }) + ) + + for (let index = 0; index < 4; index += 1) { + void validateWorkingDirectoryAsync(`\\\\wsl.localhost\\Ubuntu\\capped-${index}`).catch( + () => {} + ) + } + await flushLaneAcquire() + + expect(peak).toBe(2) + }) + + it('keeps a healthy local path out of a stalled share queue', async () => { + wslUncDirectoryExistsAsyncMock.mockReturnValue(new Promise(() => {})) + for (let index = 0; index < 4; index += 1) { + void validateWorkingDirectoryAsync(`\\\\wsl.localhost\\Ubuntu\\blocking-${index}`).catch( + () => {} + ) + } + + // A local-disk path must not queue behind a dead server. + await expect(validateWorkingDirectoryAsync(tempDir)).resolves.toBeUndefined() + }) + + it('gives separate servers separate lanes', async () => { + let inFlight = 0 + wslUncDirectoryExistsAsyncMock.mockImplementation( + () => + new Promise(() => { + inFlight += 1 + }) + ) + + for (const distro of ['AlphaDistro', 'BetaDistro']) { + for (let index = 0; index < 2; index += 1) { + void validateWorkingDirectoryAsync(`\\\\wsl.localhost\\${distro}\\lane-${index}`).catch( + () => {} + ) + } + } + await flushLaneAcquire() + + expect(inFlight).toBe(4) + }) + }) + describe('WSL UNC paths', () => { const wslPath = '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo' @@ -165,6 +238,7 @@ describe('validateWorkingDirectoryAsync', () => { const first = validateWorkingDirectoryAsync(wslPath) const second = validateWorkingDirectoryAsync(wslPath) + await flushLaneAcquire() expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledOnce() releaseProbe() diff --git a/src/main/providers/working-directory-validation.ts b/src/main/providers/working-directory-validation.ts index 21775dc913a..e43a488443c 100644 --- a/src/main/providers/working-directory-validation.ts +++ b/src/main/providers/working-directory-validation.ts @@ -5,10 +5,65 @@ import { existsSync, statSync } from 'node:fs' import { stat } from 'node:fs/promises' import { release } from 'node:os' -import { isWslUncPath } from '../../shared/wsl-paths' +import { isWslUncPath, parseWslUncPath } from '../../shared/wsl-paths' import { wslUncDirectoryExists, wslUncDirectoryExistsAsync } from '../wsl' +import { PrioritySemaphore } from '../../shared/priority-semaphore' const pendingWorkingDirectoryValidations = new Map>() +// Why: a dead UNC share answers `stat` in ~21s on Windows and holds one of +// libuv's 4 default fs threads the whole time, so a handful of distinct paths on +// one unreachable server would starve every other async fs read in the daemon — +// moving the head-of-line stall off the event loop and into the thread pool. +// Matches the per-distro lane in rate-limits/auth-filesystem-operation.ts. +const MAX_CONCURRENT_UNC_VALIDATIONS = 2 + +type UncRouteLane = { + readonly semaphore: PrioritySemaphore + waiters: number +} + +const uncRouteLanes = new Map() + +/** + * Groups paths by the host that must answer for them, so many dead + * subdirectories of one share share a lane. Returns null for local-disk paths, + * which never block long enough to be worth queueing. + */ +function uncRouteKey(cwd: string): string | null { + if (!cwd.startsWith('\\\\')) { + return null + } + const wslInfo = parseWslUncPath(cwd) + if (wslInfo) { + return `wsl:${wslInfo.distro.trim().toLowerCase()}` + } + const server = cwd.slice(2).split(/[\\/]/, 1)[0] + return `unc:${server.toLowerCase()}` +} + +async function withUncRouteLane(cwd: string, run: () => Promise): Promise { + const key = uncRouteKey(cwd) + if (key === null) { + return run() + } + let lane = uncRouteLanes.get(key) + if (!lane) { + lane = { semaphore: new PrioritySemaphore(MAX_CONCURRENT_UNC_VALIDATIONS), waiters: 0 } + uncRouteLanes.set(key, lane) + } + // Counted before acquiring so a queued caller keeps the lane alive. + lane.waiters += 1 + const release = await lane.semaphore.acquire(0) + try { + return await run() + } finally { + release() + lane.waiters -= 1 + if (lane.waiters === 0) { + uncRouteLanes.delete(key) + } + } +} /** Thrown when the caller gave up on a probe that is still running. */ export class WorkingDirectoryValidationAbortedError extends Error { @@ -18,6 +73,12 @@ export class WorkingDirectoryValidationAbortedError extends Error { } } +/** Test seam: both maps are module-level, so an unsettled probe would leak across tests. */ +export function _resetWorkingDirectoryValidationStateForTest(): void { + pendingWorkingDirectoryValidations.clear() + uncRouteLanes.clear() +} + export function formatLocalPtyEnvironmentDiag(extra: Record = {}): string { const systemVersion = (process as NodeJS.Process & { getSystemVersion?: () => string }).getSystemVersion?.() || @@ -87,10 +148,9 @@ export function validateWorkingDirectoryAsync( const started = validation // Why: dropped only on settle. `fs.stat` is uninterruptible, so retiring a // still-running probe on a timer frees no libuv thread — it only lets the - // next caller pin a second one, and a few retries against one dead mount - // exhaust the default pool of 4 and stall every other async fs read in the - // daemon. Callers escape through `signal` instead, so sharing one hung probe - // no longer strands them. + // next caller pin a second one. Callers escape through `signal` instead, so + // sharing one hung probe no longer strands them, and the route lane below + // caps how many distinct paths on one dead host can block at once. const forget = (): void => { if (pendingWorkingDirectoryValidations.get(key) === started) { pendingWorkingDirectoryValidations.delete(key) @@ -116,7 +176,11 @@ export function validateWorkingDirectoryAsync( }) } -async function validateWorkingDirectoryUncached(cwd: string): Promise { +function validateWorkingDirectoryUncached(cwd: string): Promise { + return withUncRouteLane(cwd, () => probeWorkingDirectory(cwd)) +} + +async function probeWorkingDirectory(cwd: string): Promise { if (isWslUncPath(cwd)) { const existsInDistro = await wslUncDirectoryExistsAsync(cwd) if (existsInDistro === false) { diff --git a/src/main/remote-worktree-history-cleanup.test.ts b/src/main/remote-worktree-history-cleanup.test.ts deleted file mode 100644 index ce0f23a60f9..00000000000 --- a/src/main/remote-worktree-history-cleanup.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { deleteRemoteWorktreeHistory } from './remote-worktree-history-cleanup' - -describe('deleteRemoteWorktreeHistory', () => { - it('repeats idempotent cleanup through the PTY owner', async () => { - const deleteWorktreeHistory = vi.fn().mockResolvedValue(undefined) - const provider = { deleteWorktreeHistory } as never - - await deleteRemoteWorktreeHistory(provider, 'repo-1::/remote/wt') - await deleteRemoteWorktreeHistory(provider, 'repo-1::/remote/wt') - - expect(deleteWorktreeHistory).toHaveBeenCalledTimes(2) - }) - - it('degrades safely when an old relay does not expose cleanup', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const provider = { - deleteWorktreeHistory: vi.fn().mockRejectedValue(new Error('unknown request method')) - } as never - - try { - await expect( - deleteRemoteWorktreeHistory(provider, 'repo-1::/remote/wt') - ).resolves.toBeUndefined() - expect(warn).toHaveBeenCalledWith(expect.stringContaining('Remote cleanup unavailable')) - } finally { - warn.mockRestore() - } - }) -}) diff --git a/src/main/remote-worktree-history-cleanup.ts b/src/main/remote-worktree-history-cleanup.ts deleted file mode 100644 index 8842758d15e..00000000000 --- a/src/main/remote-worktree-history-cleanup.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { IPtyProvider } from './providers/pty-provider-contract' - -/** Best-effort cleanup through the PTY owner; older relays may not expose the method. */ -export async function deleteRemoteWorktreeHistory( - provider: IPtyProvider | undefined, - worktreeId: string -): Promise { - try { - await provider?.deleteWorktreeHistory?.(worktreeId) - } catch (error) { - console.warn( - `[pty:history] Remote cleanup unavailable: ${error instanceof Error ? error.message : String(error)}` - ) - } -} diff --git a/src/main/repo-icon-autodetect.test.ts b/src/main/repo-icon-autodetect.test.ts index b808dce4483..f016650490e 100644 --- a/src/main/repo-icon-autodetect.test.ts +++ b/src/main/repo-icon-autodetect.test.ts @@ -144,6 +144,30 @@ describe('detectRepoIcon', () => { await expect(detectRepoIcon({ repoPath, kind: 'folder' })).resolves.toBeUndefined() }) + it('returns no icon for an SSH-hosted repo whose filesystem provider is missing', async () => { + // Why: the same path exists on the client machine — reading it would hand + // back the local repository's icon for a remote repo. + const repoPath = await makeTempRepoDir() + await writeFile(join(repoPath, 'favicon.png'), Buffer.from(PNG_1X1_BASE64, 'base64')) + await writeFile( + join(repoPath, 'package.json'), + JSON.stringify({ homepage: 'https://app.example.com/docs' }) + ) + + await expect( + detectRepoIcon({ repoPath, kind: 'folder', connectionId: 'ssh-target-not-connected' }) + ).resolves.toBeUndefined() + }) + + it('still detects local icons when the repo has no connection', async () => { + const repoPath = await makeTempRepoDir() + await writeFile(join(repoPath, 'favicon.png'), Buffer.from(PNG_1X1_BASE64, 'base64')) + + await expect( + detectRepoIcon({ repoPath, kind: 'folder', connectionId: null }) + ).resolves.toMatchObject({ source: 'file', label: 'favicon.png' }) + }) + it('falls back to the GitHub owner avatar for GitHub repos', async () => { const repoPath = await makeTempRepoDir() await gitExecFileAsync(['init'], { cwd: repoPath }) diff --git a/src/main/repo-icon-autodetect.ts b/src/main/repo-icon-autodetect.ts index 4ea5f5d59d0..202fd907dce 100644 --- a/src/main/repo-icon-autodetect.ts +++ b/src/main/repo-icon-autodetect.ts @@ -103,16 +103,20 @@ export async function detectRepoIcon({ }): Promise { try { const fsProvider = connectionId ? getSshFilesystemProvider(connectionId) : undefined - const fileIcon = await detectRepoFileIcon(repoPath, fsProvider) - if (fileIcon) { - return fileIcon - } + // Why: a remote repoPath with no provider must not be probed on the client + // filesystem — a same-named local path answers for the wrong repository. + if (fsProvider || !connectionId) { + const fileIcon = await detectRepoFileIcon(repoPath, { connectionId, fsProvider }) + if (fileIcon) { + return fileIcon + } - const homepageIcon = fsProvider - ? await detectRemotePackageHomepageIcon(repoPath, fsProvider) - : await detectLocalPackageHomepageIcon(repoPath) - if (homepageIcon) { - return homepageIcon + const homepageIcon = fsProvider + ? await detectRemotePackageHomepageIcon(repoPath, fsProvider) + : await detectLocalPackageHomepageIcon(repoPath) + if (homepageIcon) { + return homepageIcon + } } if (kind === 'git') { diff --git a/src/main/repo-icon-file-detection.test.ts b/src/main/repo-icon-file-detection.test.ts index 5aec20d90d0..e2238b72412 100644 --- a/src/main/repo-icon-file-detection.test.ts +++ b/src/main/repo-icon-file-detection.test.ts @@ -1,7 +1,16 @@ +import { readFile, stat } from 'node:fs/promises' +import type * as FsPromisesModule from 'node:fs/promises' import { describe, expect, it, vi } from 'vitest' import type { FileReadResult, FileStat, IFilesystemProvider } from './providers/types' import { detectRepoFileIcon } from './repo-icon-file-detection' +// Why: the boundary assertion is "no local read happened", which needs the real +// fs entrypoints spied rather than stubbed. +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, stat: vi.fn(actual.stat), readFile: vi.fn(actual.readFile) } +}) + const WEBP_BASE64 = 'UklGRhoAAABXRUJQVlA4IA4AAAAwAQCdASoBAAEAAQIlSkwAAA==' const PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=' @@ -28,7 +37,7 @@ describe('detectRepoFileIcon remote probing', () => { readFile: async () => ({ content: WEBP_BASE64, isBinary: true, mimeType: 'image/webp' }) }) - await expect(detectRepoFileIcon('/repo', provider)).resolves.toEqual({ + await expect(detectRepoFileIcon('/repo', { fsProvider: provider })).resolves.toEqual({ type: 'image', src: `data:image/webp;base64,${WEBP_BASE64}`, source: 'file', @@ -52,7 +61,7 @@ describe('detectRepoFileIcon remote probing', () => { } }) - await expect(detectRepoFileIcon('/repo', provider)).resolves.toMatchObject({ + await expect(detectRepoFileIcon('/repo', { fsProvider: provider })).resolves.toMatchObject({ source: 'file', label: 'favicon.png' }) @@ -75,8 +84,30 @@ describe('detectRepoFileIcon remote probing', () => { } }) - await expect(detectRepoFileIcon('/repo', provider)).resolves.toBeNull() + await expect(detectRepoFileIcon('/repo', { fsProvider: provider })).resolves.toBeNull() expect(maxActiveStats).toBeGreaterThan(1) expect(maxActiveStats).toBeLessThanOrEqual(6) }) }) + +describe('detectRepoFileIcon connection boundary', () => { + it('never reads the client filesystem for a remote repo whose provider is missing', async () => { + vi.mocked(stat).mockClear() + vi.mocked(readFile).mockClear() + + await expect( + detectRepoFileIcon('/repo', { connectionId: 'ssh-target-1', fsProvider: undefined }) + ).resolves.toBeNull() + + expect(stat).not.toHaveBeenCalled() + expect(readFile).not.toHaveBeenCalled() + }) + + it('still probes the local filesystem for a repo with no connection', async () => { + vi.mocked(stat).mockClear() + + await expect(detectRepoFileIcon('/repo', { connectionId: null })).resolves.toBeNull() + + expect(stat).toHaveBeenCalled() + }) +}) diff --git a/src/main/repo-icon-file-detection.ts b/src/main/repo-icon-file-detection.ts index d3339e2cd7b..78ee3a97bec 100644 --- a/src/main/repo-icon-file-detection.ts +++ b/src/main/repo-icon-file-detection.ts @@ -260,7 +260,18 @@ async function detectRemoteImageIcon( export function detectRepoFileIcon( repoPath: string, - fsProvider?: IFilesystemProvider + { + connectionId, + fsProvider + }: { connectionId?: string | null; fsProvider?: IFilesystemProvider } = {} ): Promise { - return fsProvider ? detectRemoteImageIcon(repoPath, fsProvider) : detectLocalImageIcon(repoPath) + if (fsProvider) { + return detectRemoteImageIcon(repoPath, fsProvider) + } + if (connectionId) { + // Why: repoPath lives on the SSH host, so a dropped provider must fail closed — + // a same-named local path would hand back another repository's icon. + return Promise.resolve(null) + } + return detectLocalImageIcon(repoPath) } diff --git a/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts b/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts index 7981366cadb..11b9fc97afd 100644 --- a/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts +++ b/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts @@ -130,6 +130,23 @@ describe('headless hook agent-status projection (#11761)', () => { ) }) + it('publishes a gated turn end as event metadata, not stored agent status', async () => { + const turnCompletedAt = Date.now() + const runtime = await createRuntimeWithHookRows([ + hookRow({ + state: 'working', + interactivePrompt: undefined, + turnCompletedAt + }) + ]) + + const result = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`) + const tab = result.tabs[0] + + expect(tab).toMatchObject({ type: 'terminal', turnCompletedAt }) + expect(tab?.type === 'terminal' && tab.agentStatus).not.toHaveProperty('turnCompletedAt') + }) + it('publishes no hook transport identity to clients', async () => { const agentStatus = await projectAgentStatus([ hookRow({ launchToken: 'lt-secret', promptInteractionKey: 'turn-1' }) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 3938f426b6d..4c2dd2fbdd7 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -6455,8 +6455,7 @@ describe('OrcaRuntimeService', () => { worktreeId: `${TEST_REPO_ID}::/remote/feature` } ]), - shutdown: vi.fn().mockResolvedValue(undefined), - deleteWorktreeHistory: vi.fn().mockResolvedValue(undefined) + shutdown: vi.fn().mockResolvedValue(undefined) } const runtime = new OrcaRuntimeService(remoteStore as never, undefined, { getSshProvider: () => ptyProvider as never @@ -6473,9 +6472,6 @@ describe('OrcaRuntimeService', () => { 'pty-remote', expect.objectContaining({ immediate: true }) ) - expect(ptyProvider.deleteWorktreeHistory).toHaveBeenCalledWith( - `${TEST_REPO_ID}::/remote/feature` - ) expect(ptyProvider.shutdown.mock.invocationCallOrder[0]).toBeLessThan( gitProvider.removeWorktree.mock.invocationCallOrder[0] ) @@ -33144,7 +33140,7 @@ describe('OrcaRuntimeService', () => { expect(spawn).not.toHaveBeenCalled() }) - it('uses portable Unix quoting for mobile agent launch commands in WSL project runtimes', async () => { + it('uses POSIX quoting for mobile agent launch commands in WSL project runtimes', async () => { await withPlatform('win32', async () => { const spawn = vi.fn().mockResolvedValue({ id: 'pty-agent' }) const runtime = new OrcaRuntimeService({ @@ -33182,7 +33178,7 @@ describe('OrcaRuntimeService', () => { expect(spawn).toHaveBeenCalledWith( expect.objectContaining({ - command: `command-code --profile mobile '--note' 'can'"'"'t'`, + command: "command-code --profile mobile '--note' 'can'\\''t'", cwd: TEST_WORKTREE_PATH, worktreeId: TEST_WORKTREE_ID }) @@ -48421,145 +48417,6 @@ describe('OrcaRuntimeService', () => { } }) - it('routes already-missing SSH runtime history cleanup through the PTY owner', async () => { - const repo = { - id: 'repo-runtime-ssh', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: 'blue', - addedAt: 1, - connectionId: 'ssh-1' - } - const worktreeId = `${repo.id}::/remote/already-deleted` - const metaById: Record = { - [worktreeId]: makeWorktreeMeta({ hostId: 'ssh:ssh-1', orcaCreationSource: 'ssh' }) - } - const removeWorktreeMeta = vi.fn((id: string) => { - delete metaById[id] - }) - const runtimeStore = { - ...store, - getRepos: () => [repo], - getRepo: (id: string) => (id === repo.id ? repo : undefined), - getAllWorktreeMeta: () => metaById, - getWorktreeMeta: (id: string) => metaById[id], - removeWorktreeMeta - } - const gitProvider = { - listWorktrees: vi.fn().mockResolvedValue([ - { - path: repo.path, - head: 'main', - branch: 'refs/heads/main', - isBare: false, - isMainWorktree: true - } - ]) - } - const fsProvider = { - stat: vi.fn().mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) - } - const deleteWorktreeHistory = vi.fn().mockResolvedValue(undefined) - const ptyProvider = { deleteWorktreeHistory } as never - registerSshGitProvider(repo.connectionId, gitProvider as never) - registerSshFilesystemProvider(repo.connectionId, fsProvider as never) - const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { - getSshProvider: () => ptyProvider - }) - - try { - await expect(runtime.removeManagedWorktree(`id:${worktreeId}`)).resolves.toEqual({}) - } finally { - unregisterSshGitProvider(repo.connectionId) - unregisterSshFilesystemProvider(repo.connectionId) - } - - expect(deleteWorktreeHistory).toHaveBeenCalledWith(worktreeId) - expect(deleteWorktreeHistory.mock.invocationCallOrder[0]).toBeLessThan( - removeWorktreeMeta.mock.invocationCallOrder[0] - ) - expect(removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'ssh:ssh-1') - }) - - it('routes SSH runtime orphan-directory history cleanup through the PTY owner', async () => { - const repo = { - id: 'repo-runtime-ssh', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: 'blue', - addedAt: 1, - connectionId: 'ssh-1' - } - const worktreePath = '/remote/orphan' - const worktreeId = `${repo.id}::${worktreePath}` - const metaById: Record = { - [worktreeId]: makeWorktreeMeta({ - hostId: 'ssh:ssh-1', - orcaCreatedAt: Date.now(), - orcaCreationSource: 'ssh' - }) - } - const removeWorktreeMeta = vi.fn((id: string) => { - delete metaById[id] - }) - const runtimeStore = { - ...store, - getRepos: () => [repo], - getRepo: (id: string) => (id === repo.id ? repo : undefined), - getAllWorktreeMeta: () => metaById, - getWorktreeMeta: (id: string) => metaById[id], - removeWorktreeMeta - } - const gitProvider = { - listWorktrees: vi.fn().mockResolvedValue([ - { - path: repo.path, - head: 'main', - branch: 'refs/heads/main', - isBare: false, - isMainWorktree: true - } - ]) - } - const fsProvider = { - lstat: vi.fn(async (path: string) => ({ - type: path === `${worktreePath}/.git` ? 'file' : 'directory' - })), - readFile: vi.fn(async (path: string) => ({ - isBinary: false, - content: - path === `${worktreePath}/.git` - ? `gitdir: ${repo.path}/.git/worktrees/orphan\n` - : `${worktreePath}/.git\n` - })), - deletePath: vi.fn().mockResolvedValue(undefined) - } - const deleteWorktreeHistory = vi.fn().mockResolvedValue(undefined) - const ptyProvider = { - listProcesses: vi.fn().mockResolvedValue([]), - shutdown: vi.fn().mockResolvedValue(undefined), - deleteWorktreeHistory - } - registerSshGitProvider(repo.connectionId, gitProvider as never) - registerSshFilesystemProvider(repo.connectionId, fsProvider as never) - const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { - getSshProvider: () => ptyProvider as never - }) - - try { - await expect(runtime.removeManagedWorktree(`id:${worktreeId}`, true)).resolves.toEqual({}) - } finally { - unregisterSshGitProvider(repo.connectionId) - unregisterSshFilesystemProvider(repo.connectionId) - } - - expect(fsProvider.deletePath).toHaveBeenCalledWith(worktreePath, true) - expect(deleteWorktreeHistory).toHaveBeenCalledWith(worktreeId) - expect(deleteWorktreeHistory.mock.invocationCallOrder[0]).toBeLessThan( - removeWorktreeMeta.mock.invocationCallOrder[0] - ) - }) - it('force-removes a legacy Orca-created runtime orphaned worktree directory after Git tracking is gone', async () => { const parentDir = await mkdtemp(join(tmpdir(), 'orca-runtime-orphan-')) const repoPath = join(parentDir, 'repo') diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 166f0bfdb3e..05a1702cbb4 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -421,6 +421,7 @@ import { type RuntimeTerminalSplit, type RuntimeTerminalFocus, type RuntimeTerminalClose, + type RuntimeTerminalListHostScope, type RuntimeTerminalListResult, type RuntimeTerminalOrphanAdoptionRequest, type RuntimeTerminalOrphanAdoptionResult, @@ -516,6 +517,7 @@ import { parsePaneKey } from '../../shared/stable-pane-id' import { parseAppSshPtyId } from '../../shared/ssh-pty-id' +import { getPtyExecutionHost } from '../../shared/terminal-execution-host' import { isValidHostTerminalTabId, isValidTerminalTabId } from '../../shared/terminal-tab-id' import { isWslHookRelayConnectionId } from '../../shared/wsl-hook-relay-contract' import { @@ -1038,7 +1040,6 @@ import { resolveWorktreeSharedDirectories } from '../git/worktree-shared-directories' import { deleteWorktreeHistoryDir } from '../terminal-history-deletion' -import { deleteRemoteWorktreeHistory } from '../remote-worktree-history-cleanup' import { cleanupUnusedWorktreePushTargetRemote, cleanupUnusedWorktreePushTargetRemoteSsh, @@ -1882,6 +1883,10 @@ type RuntimePtyController = { // Why: exact-id mobile polls should not enumerate every local and SSH PTY. hasPty?(ptyId: string): boolean | null listProcesses?(connectionId?: string | null): Promise + listProcessesWithHostScope?(): Promise<{ + processes: PtyProcessInfo[] + hostIds: ExecutionHostId[] + }> serializeBuffer?( ptyId: string, opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } @@ -1926,6 +1931,7 @@ type PtyControllerInventory = Readonly<{ // must consult the unscoped inventory or a misattributed live PTY reads as dead. allLivePtyIds: ReadonlySet terminalIdentityByPtyId: ReadonlyMap + queriedHostIds: ReadonlySet }> type WorktreeStartupDraftPaste = { @@ -5790,6 +5796,9 @@ export class OrcaRuntimeService { if (!workspace) { return null } + if (workspace.executionHostId != null) { + return parseExecutionHostId(workspace.executionHostId)?.id ?? null + } const connectionId = this.resolveFolderWorkspaceConnectionId(workspace) return connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID } @@ -5837,6 +5846,48 @@ export class OrcaRuntimeService { return worktreeIds } + // Every execution host known to this runtime; knowledge is not coverage. + private listKnownExecutionHostIds( + additionalHostIds: Iterable = [], + includeConfiguredHosts = true + ): Set { + const hostIds = new Set([LOCAL_EXECUTION_HOST_ID]) + for (const hostId of this.store?.getWorkspaceSessionHostIds?.() ?? []) { + hostIds.add(hostId) + } + for (const hostId of additionalHostIds) { + hostIds.add(hostId) + } + if (!includeConfiguredHosts) { + return hostIds + } + const repos = this.store?.getRepos?.() ?? [] + for (const repo of repos) { + hostIds.add(getRepoExecutionHostId(repo)) + } + const projectGroups = this.store?.getProjectGroups?.() ?? [] + for (const workspace of this.store?.getFolderWorkspaces?.() ?? []) { + if (workspace.executionHostId != null) { + const explicitHostId = parseExecutionHostId(workspace.executionHostId)?.id + if (explicitHostId) { + hostIds.add(explicitHostId) + } + continue + } + const connection = inferFolderWorkspacePathConnection({ + folderPath: workspace.folderPath, + projectGroupId: workspace.projectGroupId, + connectionId: workspace.connectionId ?? null, + projectGroups, + repos + }) + if (connection.kind === 'ssh') { + hostIds.add(toSshExecutionHostId(connection.connectionId)) + } + } + return hostIds + } + private getWorkspaceSessionHydrationTargets( includeAllPersistedWorktrees: boolean ): Map { @@ -5853,7 +5904,7 @@ export class OrcaRuntimeService { ] as const }) ) - const hostIds = new Set(['local']) + const hostIds = new Set([LOCAL_EXECUTION_HOST_ID]) for (const repo of repos) { hostIds.add(getRepoExecutionHostId(repo)) } @@ -11829,7 +11880,9 @@ export class OrcaRuntimeService { (previous.payload.agentType ?? null) !== (payload.agentType ?? null) || (previous.payload.toolName ?? null) !== (payload.toolName ?? null) || (previous.payload.interactivePrompt ?? null) !== (payload.interactivePrompt ?? null) || - (previous.payload.interrupted ?? false) !== (payload.interrupted ?? false) + (previous.payload.interrupted ?? false) !== (payload.interrupted ?? false) || + (previous.payload.turnCompletedAt ?? null) !== (payload.turnCompletedAt ?? null) || + (previous.payload.lastAssistantMessage ?? null) !== (payload.lastAssistantMessage ?? null) ) } @@ -16683,6 +16736,12 @@ export class OrcaRuntimeService { return { terminals: listedTerminals, + hostScope: this.buildTerminalListHostScope( + targetWorktreeId, + matchingTerminals, + worktreesById.values(), + controllerInventory?.queriedHostIds ?? new Set() + ), ...(visualLayouts.length > 0 ? { visualLayouts } : {}), topologyRevisions: Object.fromEntries( [...new Set(matchingTerminals.map((terminal) => terminal.worktreeId))].map((worktreeId) => [ @@ -16695,6 +16754,52 @@ export class OrcaRuntimeService { } } + // A worktree-scoped request answers for one host only, so name the hosts it + // skipped: absence from a scoped listing is not evidence a worker exited. + private buildTerminalListHostScope( + targetWorktreeId: string | null, + terminals: readonly RuntimeTerminalSummary[], + worktrees: Iterable, + queriedHostIds: ReadonlySet + ): RuntimeTerminalListHostScope { + const knownHostIds = this.listKnownExecutionHostIds( + queriedHostIds, + targetWorktreeId !== FLOATING_TERMINAL_WORKTREE_ID + ) + let resolvedTargetHostId: ExecutionHostId | null = null + for (const worktree of worktrees) { + if (worktree.hostId) { + knownHostIds.add(worktree.hostId) + if (worktree.id === targetWorktreeId) { + resolvedTargetHostId = worktree.hostId + } + } + } + for (const terminal of terminals) { + if (terminal.executionHostId) { + knownHostIds.add(terminal.executionHostId) + } + } + const scopedHostId = targetWorktreeId + ? (resolvedTargetHostId ?? this.tryGetWorkspaceSessionHostIdForWorktree(targetWorktreeId)) + : null + if (scopedHostId) { + knownHostIds.add(scopedHostId) + } + const candidates = targetWorktreeId ? (scopedHostId ? [scopedHostId] : []) : knownHostIds + // Paired runtimes own a separate control plane. Mirrored rows are evidence + // for those rows only; this runtime cannot claim their complete inventory. + const coveredHostIds = new Set( + [...candidates].filter( + (hostId) => queriedHostIds.has(hostId) && parseExecutionHostId(hostId)?.kind !== 'runtime' + ) + ) + return { + hostIds: [...coveredHostIds].sort(), + omittedHostIds: [...knownHostIds].filter((hostId) => !coveredHostIds.has(hostId)).sort() + } + } + async inspectTerminalProcessIncarnationLiveness( processIncarnation: string, serializedHostScope: string | null @@ -25894,7 +25999,6 @@ export class OrcaRuntimeService { .then((gate) => gate.finish(false)) .catch(() => {}) } - await deleteRemoteWorktreeHistory(sshPtyProvider, removalTarget.id) this.clearOptimisticReconcileToken(removalTarget.id) this.removeWorktreeMetadataAndHistory(store, removalTarget.id) this.preservedBranchCleanupByScope.delete(cleanupScopeKey) @@ -25939,7 +26043,6 @@ export class OrcaRuntimeService { console.warn(`[worktree-teardown] failed for ${removalTarget.id}:`, err) }) } - await deleteRemoteWorktreeHistory(folderSshPtyProvider, removalTarget.id) this.removeWorktreeMetadataAndHistory(store, removalTarget.id) this.preservedBranchCleanupByScope.delete(cleanupScopeKey) this.invalidateResolvedWorktreeCache() @@ -26024,10 +26127,6 @@ export class OrcaRuntimeService { removedPushTarget, store ) - await deleteRemoteWorktreeHistory( - this.getSshProviderFn?.(repo.connectionId), - removalTarget.id - ) } else { const removalGate = await this.acquireFileWatcherRemoval(removalTarget.path) let removalCompleted = false @@ -26133,12 +26232,6 @@ export class OrcaRuntimeService { store, localWorktreeGitOptions )) - if (repo.connectionId) { - await deleteRemoteWorktreeHistory( - this.getSshProviderFn?.(repo.connectionId), - removalTarget.id - ) - } this.clearOptimisticReconcileToken(removalTarget.id) this.removeWorktreeMetadataAndHistory(store, removalTarget.id) this.preservedBranchCleanupByScope.delete(cleanupScopeKey) @@ -26232,10 +26325,6 @@ export class OrcaRuntimeService { removedPushTarget, store ) - await deleteRemoteWorktreeHistory( - this.getSshProviderFn?.(repo.connectionId), - removalTarget.id - ) this.rememberPreservedBranchCleanupTarget( removalTarget.id, cleanupHostId, @@ -31380,7 +31469,8 @@ export class OrcaRuntimeService { return { livePtyIds: targetedLiveness, allLivePtyIds: targetedLiveness, - terminalIdentityByPtyId: new Map() + terminalIdentityByPtyId: new Map(), + queriedHostIds: new Set([LOCAL_EXECUTION_HOST_ID]) } } } @@ -31396,8 +31486,32 @@ export class OrcaRuntimeService { } else { this.ptyControllerInventoryGenerationByProvider.set(providerKey, inventoryGeneration) } + const processInventory = + connectionId === undefined && this.ptyController.listProcessesWithHostScope + ? this.ptyController.listProcessesWithHostScope() + : this.ptyController.listProcesses(connectionId).then((processes) => { + const hostIds = new Set() + if (connectionId === undefined || connectionId === null) { + hostIds.add(LOCAL_EXECUTION_HOST_ID) + } else { + hostIds.add(toSshExecutionHostId(connectionId)) + } + if (connectionId === undefined) { + for (const process of processes) { + const hostId = getPtyExecutionHost(process.id) + if ( + hostId && + hostId !== 'foreign' && + parseExecutionHostId(hostId)?.kind === 'ssh' + ) { + hostIds.add(hostId) + } + } + } + return { processes, hostIds: [...hostIds] } + }) const sessionsResult = await withTimeoutResult( - this.ptyController.listProcesses(connectionId), + processInventory, deadline === undefined ? PTY_CONTROLLER_LIST_TIMEOUT_MS : Math.max(1, Math.min(PTY_CONTROLLER_LIST_TIMEOUT_MS, deadline - Date.now())) @@ -31418,7 +31532,8 @@ export class OrcaRuntimeService { if (!isCurrentInventory) { return null } - const sessions = sessionsResult.value + const sessions = sessionsResult.value.processes + const queriedHostIds = new Set(sessionsResult.value.hostIds) const controllerIdentityByPtyId = new Map() const ptyIdByControllerHandle = new Map() const ambiguousControllerPtyIds = new Set() @@ -31619,7 +31734,8 @@ export class OrcaRuntimeService { return { livePtyIds: targetWorktreeId ? selectedLivePtyIds : allLivePtyIds, allLivePtyIds, - terminalIdentityByPtyId: controllerIdentityByPtyId + terminalIdentityByPtyId: controllerIdentityByPtyId, + queriedHostIds } } @@ -31869,10 +31985,26 @@ export class OrcaRuntimeService { connected: provenAbsent ? false : leaf.connected, writable: provenAbsent ? false : leaf.writable, lastOutputAt: leaf.lastOutputAt, - preview: leaf.preview + preview: leaf.preview, + ...this.terminalExecutionHostField(leaf.ptyId, leaf.worktreeId) } } + // Why: the PTY id names its own host when it has one; only a host-less id may + // fall back to the worktree's. A foreign id with no owner stays unset rather + // than inheriting a local worktree's host and reading as local. + private terminalExecutionHostField( + ptyId: string | null, + worktreeId: string + ): { executionHostId?: ExecutionHostId } { + const fromPtyId = getPtyExecutionHost(ptyId) + if (fromPtyId === 'foreign') { + return {} + } + const hostId = fromPtyId ?? this.tryGetWorkspaceSessionHostIdForWorktree(worktreeId) + return hostId ? { executionHostId: hostId } : {} + } + // Returns the worktrees whose stored snapshot object changed during this // sync, so the caller can fan out only actually-changed worktrees. private syncMobileSessionTabs( @@ -32619,6 +32751,31 @@ export class OrcaRuntimeService { : livePty ? this.issuePtyHandle(livePty) : null + const projectedAgentStatus = + agentStatus ?? + this.buildPtyMobileAgentStatus( + mobileStatusPty, + tab, + terminalHandle, + retainedAgentStatus, + getHookRowsForPane + ) + const projectedStatusEntry = projectedAgentStatus.agentStatus as + | (AgentStatusEntry & { turnCompletedAt?: number }) + | undefined + const { turnCompletedAt: projectedTurnCompletedAt, ...clientStatusFields } = + projectedStatusEntry ?? {} + const clientAgentStatus = projectedStatusEntry + ? { agentStatus: clientStatusFields as AgentStatusEntry } + : {} + const rawTurnCompletedAt = + hookAgentStatus?.live?.payload.turnCompletedAt ?? + this.getHookAgentRowForPane(getHookRowsForPane(paneKey)).live?.payload.turnCompletedAt ?? + projectedTurnCompletedAt + const turnCompletedAt = + typeof rawTurnCompletedAt === 'number' && Number.isFinite(rawTurnCompletedAt) + ? rawTurnCompletedAt + : undefined tabs.push({ type: 'terminal', id: tab.id, @@ -32628,14 +32785,8 @@ export class OrcaRuntimeService { ...(tab.ptyId ? { ptyId: tab.ptyId } : {}), ...(tab.terminalTheme ? { terminalTheme: tab.terminalTheme } : {}), ...(launchAgent ? { launchAgent } : {}), - ...(agentStatus ?? - this.buildPtyMobileAgentStatus( - mobileStatusPty, - tab, - terminalHandle, - retainedAgentStatus, - getHookRowsForPane - )), + ...clientAgentStatus, + ...(turnCompletedAt !== undefined ? { turnCompletedAt } : {}), ...(tab.parentLayout ? { parentLayout: tab.parentLayout } : {}), ...(tab.startupCwd ? { startupCwd: tab.startupCwd } : {}), ...(tab.color != null ? { color: tab.color } : {}), @@ -33792,7 +33943,8 @@ export class OrcaRuntimeService { connected: pty.connected, writable: pty.connected, lastOutputAt: pty.lastOutputAt, - preview: pty.preview + preview: pty.preview, + ...this.terminalExecutionHostField(pty.ptyId, pty.worktreeId) } } diff --git a/src/main/runtime/rpc/terminal-list-host-scope-transport.test.ts b/src/main/runtime/rpc/terminal-list-host-scope-transport.test.ts new file mode 100644 index 00000000000..fc4fab66147 --- /dev/null +++ b/src/main/runtime/rpc/terminal-list-host-scope-transport.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RuntimeTerminalListResult } from '../../../shared/runtime-types' +import { RpcDispatcher } from './dispatcher' +import { TERMINAL_METHODS } from './methods/terminal' + +// Request options in this codebase have been dropped by a transport that +// forgot to forward them. The host-scope answer must survive the RPC boundary +// the same way, so assert it on the far side of the dispatcher. +const LIST_RESULT: RuntimeTerminalListResult = { + terminals: [ + { + handle: 'term_remote', + ptyId: 'ssh:box-1@@pty-7', + worktreeId: 'repo-ssh::/remote/wt', + worktreePath: '/remote/wt', + branch: 'main', + tabId: 'tab-1', + leafId: 'leaf-1', + title: 'worker', + connected: true, + writable: true, + lastOutputAt: null, + preview: '', + executionHostId: 'ssh:box-1' + } + ], + totalCount: 1, + truncated: false, + hostScope: { hostIds: ['ssh:box-1'], omittedHostIds: ['local'] } +} + +describe('terminal.list RPC boundary', () => { + it('forwards the execution host and scope the runtime reported', async () => { + const runtime = { + listTerminals: vi.fn(async () => LIST_RESULT), + getRuntimeId: () => 'runtime-a' + } + const dispatcher = new RpcDispatcher({ runtime: runtime as never, methods: TERMINAL_METHODS }) + + const response = await dispatcher.dispatch({ + id: 'request-1', + authToken: 'test-token', + method: 'terminal.list', + params: {} + }) + + expect(response.ok).toBe(true) + const result = (response as { result: RuntimeTerminalListResult }).result + expect(result.terminals[0]?.executionHostId).toBe('ssh:box-1') + expect(result.hostScope).toEqual({ hostIds: ['ssh:box-1'], omittedHostIds: ['local'] }) + }) +}) diff --git a/src/main/runtime/terminal-list-execution-host-scope.test.ts b/src/main/runtime/terminal-list-execution-host-scope.test.ts new file mode 100644 index 00000000000..c0460e0bd08 --- /dev/null +++ b/src/main/runtime/terminal-list-execution-host-scope.test.ts @@ -0,0 +1,380 @@ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { folderWorkspaceKey } from '../../shared/workspace-scope' +import type { FolderWorkspace } from '../../shared/folder-workspace-types' + +// An agent once declared a task exited because `terminal list` came back without +// its worker: the worker was live on an SSH host, and nothing in the response +// said the listing was scoped or which host each row ran on. + +const LOCAL_WORKTREE_ID = 'repo-local::/tmp/local-worktree' +const SSH_WORKTREE_ID = 'repo-ssh::/remote/ssh-worktree' +const LOCAL_LEAF_ID = '11111111-1111-4111-8111-111111111111' +const SSH_LEAF_ID = '22222222-2222-4222-8222-222222222222' +const REMOTE_LEAF_ID = '33333333-3333-4333-8333-333333333333' + +const REPOS = [ + { + id: 'repo-local', + path: '/tmp/local-worktree', + displayName: 'local', + badgeColor: '#000000', + addedAt: 0 + }, + { + id: 'repo-ssh', + path: '/remote/ssh-worktree', + displayName: 'ssh', + badgeColor: '#000000', + addedAt: 0, + connectionId: 'box-1' + } +] + +function makeStore() { + const session: WorkspaceSessionState = getDefaultWorkspaceSession() + return { + getWorkspaceSession: vi.fn(() => session), + getWorkspaceSessionHostIds: vi.fn(() => ['local', 'ssh:box-1']), + getFolderWorkspaces: vi.fn((): FolderWorkspace[] => []), + getProjectGroups: vi.fn(() => []), + setWorkspaceSession: vi.fn(), + getRepos: vi.fn(() => REPOS), + getRepo: vi.fn((id: string) => REPOS.find((repo) => repo.id === id)), + getAllWorktreeMeta: vi.fn(() => ({})), + getWorktreeMeta: vi.fn(() => undefined), + setWorktreeMeta: vi.fn(), + removeWorktreeMeta: vi.fn(), + getSettings: vi.fn(() => ({ workspaceDir: '/tmp/workspaces' })), + getProjects: vi.fn(() => []) + } +} + +function makeSshFolderWorkspace() { + return { + id: 'folder-ssh', + projectGroupId: 'group-1', + name: 'SSH folder', + folderPath: '/remote/folder', + connectionId: 'box-2', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + createdAt: 0, + updatedAt: 0 + } +} + +function makeRuntimeFolderWorkspace() { + return { + ...makeSshFolderWorkspace(), + id: 'folder-runtime', + name: 'Runtime folder', + connectionId: 'stale-box', + executionHostId: 'runtime:env-9' as const + } +} + +type GraphLeaf = { worktreeId: string; leafId: string; ptyId: string } + +function makeRuntime(leaves: GraphLeaf[], store = makeStore()): OrcaRuntimeService { + const runtime = new OrcaRuntimeService(store as never) + runtime.setPtyController({ + spawn: vi.fn(async () => ({ id: 'never' })), + write: () => true, + kill: () => true, + listProcesses: vi.fn(async () => leaves.map((leaf) => ({ id: leaf.ptyId, cwd: '/tmp' }))) + } as never) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: leaves.map((leaf, index) => ({ + tabId: `tab-${index + 1}`, + worktreeId: leaf.worktreeId, + title: '', + activeLeafId: leaf.leafId, + layout: null + })), + leaves: leaves.map((leaf, index) => ({ + tabId: `tab-${index + 1}`, + worktreeId: leaf.worktreeId, + leafId: leaf.leafId, + paneRuntimeId: index + 1, + ptyId: leaf.ptyId, + paneTitle: null, + title: '' + })) + }) + return runtime +} + +describe('listTerminals execution-host identity', () => { + it('names the SSH connection a remote terminal runs on instead of local', async () => { + const runtime = makeRuntime([ + { worktreeId: LOCAL_WORKTREE_ID, leafId: LOCAL_LEAF_ID, ptyId: 'pty-local-1' }, + { worktreeId: SSH_WORKTREE_ID, leafId: SSH_LEAF_ID, ptyId: 'ssh:box-1@@pty-7' } + ]) + + const { terminals } = await runtime.listTerminals() + + const sshRow = terminals.find((terminal) => terminal.ptyId === 'ssh:box-1@@pty-7') + const localRow = terminals.find((terminal) => terminal.ptyId === 'pty-local-1') + expect(sshRow?.executionHostId).toBe('ssh:box-1') + expect(localRow?.executionHostId).toBe('local') + }) + + it('names the paired runtime environment a mirrored terminal belongs to', async () => { + const runtime = makeRuntime([ + { + worktreeId: LOCAL_WORKTREE_ID, + leafId: REMOTE_LEAF_ID, + ptyId: 'remote:env-7@@handle-1' + } + ]) + + const { terminals } = await runtime.listTerminals() + + expect(terminals[0]?.executionHostId).toBe('runtime:env-7') + }) + + it('leaves the host unset — never local — for a paired PTY that names no environment', async () => { + const runtime = makeRuntime([ + { worktreeId: LOCAL_WORKTREE_ID, leafId: REMOTE_LEAF_ID, ptyId: 'remote:handle-1' } + ]) + + const { hostScope, terminals } = await runtime.listTerminals() + + expect(terminals[0]?.executionHostId).toBeUndefined() + expect(hostScope?.hostIds).toEqual(['local']) + }) + + it.each([ + 'remote:env@@%E0%A4%A', + 'remote:%20@@terminal%3Aone', + 'ssh:%E0%A4%A@@pty-7', + 'ssh:%20@@pty-7' + ])('leaves the host unset for a malformed foreign PTY id: %s', async (ptyId) => { + const runtime = makeRuntime([{ worktreeId: LOCAL_WORKTREE_ID, leafId: REMOTE_LEAF_ID, ptyId }]) + + const { hostScope, terminals } = await runtime.listTerminals() + + expect(terminals[0]?.executionHostId).toBeUndefined() + expect(hostScope?.hostIds).toEqual(['local']) + }) +}) + +describe('listTerminals scope declaration', () => { + it('declares every host an unscoped listing covers', async () => { + const runtime = makeRuntime([ + { worktreeId: LOCAL_WORKTREE_ID, leafId: LOCAL_LEAF_ID, ptyId: 'pty-local-1' }, + { worktreeId: SSH_WORKTREE_ID, leafId: SSH_LEAF_ID, ptyId: 'ssh:box-1@@pty-7' } + ]) + + const result = await runtime.listTerminals() + + expect(result.hostScope?.hostIds).toEqual(['local', 'ssh:box-1']) + expect(result.hostScope?.omittedHostIds).toEqual([]) + }) + + it('does not claim a paired runtime was covered from a mirrored row', async () => { + const runtime = makeRuntime([ + { + worktreeId: LOCAL_WORKTREE_ID, + leafId: REMOTE_LEAF_ID, + ptyId: 'remote:env-7@@handle-1' + } + ]) + + const result = await runtime.listTerminals() + + expect(result.hostScope?.hostIds).toEqual(['local']) + expect(result.hostScope?.omittedHostIds).toEqual(['runtime:env-7', 'ssh:box-1']) + }) + + it('keeps a repo-known paired runtime omitted without a mirrored row', async () => { + const baseStore = makeStore() + const repos = [ + REPOS[0]!, + { + id: 'repo-runtime', + path: '/remote/runtime-worktree', + displayName: 'runtime', + badgeColor: '#000000', + addedAt: 0, + executionHostId: 'runtime:env-9' as const + } + ] + const runtime = makeRuntime([], { + ...baseStore, + getRepos: vi.fn(() => repos), + getRepo: vi.fn((id: string) => repos.find((repo) => repo.id === id)), + getWorkspaceSessionHostIds: vi.fn(() => ['local']) + }) + + const result = await runtime.listTerminals() + + expect(result.hostScope?.hostIds).toEqual(['local']) + expect(result.hostScope?.omittedHostIds).toEqual(['runtime:env-9']) + }) + + it('keeps a repo-known paired runtime omitted in a worktree-scoped listing', async () => { + const baseStore = makeStore() + const repos = [ + REPOS[0]!, + { + id: 'repo-runtime', + path: '/remote/runtime-worktree', + displayName: 'runtime', + badgeColor: '#000000', + addedAt: 0, + executionHostId: 'runtime:env-9' as const + } + ] + const runtime = makeRuntime([], { + ...baseStore, + getRepos: vi.fn(() => repos), + getRepo: vi.fn((id: string) => repos.find((repo) => repo.id === id)), + getWorkspaceSessionHostIds: vi.fn(() => ['local']) + }) + + const result = await runtime.listTerminals(`id:${LOCAL_WORKTREE_ID}`) + + expect(result.hostScope?.hostIds).toEqual(['local']) + expect(result.hostScope?.omittedHostIds).toEqual(['runtime:env-9']) + }) + + it('covers a queried SSH host used only by a folder workspace', async () => { + const baseStore = makeStore() + const folderWorkspace = makeSshFolderWorkspace() + const runtime = makeRuntime([], { + ...baseStore, + getRepos: vi.fn(() => [REPOS[0]!]), + getRepo: vi.fn((id: string) => (id === REPOS[0]!.id ? REPOS[0] : undefined)), + getWorkspaceSessionHostIds: vi.fn(() => ['local']), + getFolderWorkspaces: vi.fn(() => [folderWorkspace]) + }) + runtime.setPtyController({ + spawn: vi.fn(async () => ({ id: 'never' })), + write: () => true, + kill: () => true, + listProcesses: vi.fn(async () => []), + listProcessesWithHostScope: vi.fn(async () => ({ + processes: [], + hostIds: ['local', 'ssh:box-2'] + })) + } as never) + + const result = await runtime.listTerminals(`id:${folderWorkspaceKey(folderWorkspace.id)}`) + + expect(result.hostScope?.hostIds).toEqual(['ssh:box-2']) + expect(result.hostScope?.omittedHostIds).toEqual(['local']) + }) + + it('keeps a disconnected folder-workspace SSH host omitted', async () => { + const baseStore = makeStore() + const folderWorkspace = makeSshFolderWorkspace() + const runtime = makeRuntime([], { + ...baseStore, + getRepos: vi.fn(() => [REPOS[0]!]), + getRepo: vi.fn((id: string) => (id === REPOS[0]!.id ? REPOS[0] : undefined)), + getWorkspaceSessionHostIds: vi.fn(() => ['local']), + getFolderWorkspaces: vi.fn(() => [folderWorkspace]) + }) + runtime.setPtyController({ + spawn: vi.fn(async () => ({ id: 'never' })), + write: () => true, + kill: () => true, + listProcesses: vi.fn(async () => { + throw new Error('relay unavailable') + }) + } as never) + + const result = await runtime.listTerminals(`id:${folderWorkspaceKey(folderWorkspace.id)}`) + + expect(result.hostScope?.hostIds).toEqual([]) + expect(result.hostScope?.omittedHostIds).toEqual(['local', 'ssh:box-2']) + }) + + it('does not claim local coverage for a paired-runtime folder workspace', async () => { + const baseStore = makeStore() + const folderWorkspace = makeRuntimeFolderWorkspace() + const runtime = makeRuntime([], { + ...baseStore, + getRepos: vi.fn(() => [REPOS[0]!]), + getRepo: vi.fn((id: string) => (id === REPOS[0]!.id ? REPOS[0] : undefined)), + getWorkspaceSessionHostIds: vi.fn(() => ['local']), + getFolderWorkspaces: vi.fn(() => [folderWorkspace]) + }) + + const result = await runtime.listTerminals(`id:${folderWorkspaceKey(folderWorkspace.id)}`) + + expect(result.hostScope?.hostIds).toEqual([]) + expect(result.hostScope?.omittedHostIds).toEqual(['local', 'runtime:env-9']) + }) + + it('keeps a paired-runtime folder owner omitted in an unscoped listing', async () => { + const baseStore = makeStore() + const folderWorkspace = makeRuntimeFolderWorkspace() + const runtime = makeRuntime([], { + ...baseStore, + getRepos: vi.fn(() => [REPOS[0]!]), + getRepo: vi.fn((id: string) => (id === REPOS[0]!.id ? REPOS[0] : undefined)), + getWorkspaceSessionHostIds: vi.fn(() => ['local']), + getFolderWorkspaces: vi.fn(() => [folderWorkspace]) + }) + + const result = await runtime.listTerminals() + + expect(result.hostScope?.hostIds).toEqual(['local']) + expect(result.hostScope?.omittedHostIds).toEqual(['runtime:env-9']) + }) + + it('keeps a disconnected SSH host omitted when only local inventory answered', async () => { + const runtime = makeRuntime([]) + runtime.setPtyController({ + spawn: vi.fn(async () => ({ id: 'never' })), + write: () => true, + kill: () => true, + listProcesses: vi.fn(async () => []) + } as never) + + const result = await runtime.listTerminals() + + expect(result.hostScope?.hostIds).toEqual(['local']) + expect(result.hostScope?.omittedHostIds).toEqual(['ssh:box-1']) + }) + + it('marks every known host omitted when process inventory is unverifiable', async () => { + const runtime = makeRuntime([]) + runtime.setPtyController({ + spawn: vi.fn(async () => ({ id: 'never' })), + write: () => true, + kill: () => true, + listProcesses: vi.fn(async () => { + throw new Error('relay unavailable') + }) + } as never) + + const result = await runtime.listTerminals() + + expect(result.hostScope?.hostIds).toEqual([]) + expect(result.hostScope?.omittedHostIds).toEqual(['local', 'ssh:box-1']) + }) + + it('reports the hosts a worktree-scoped listing skipped, so an empty result is not absolute', async () => { + const runtime = makeRuntime([ + { worktreeId: SSH_WORKTREE_ID, leafId: SSH_LEAF_ID, ptyId: 'ssh:box-1@@pty-7' } + ]) + + const result = await runtime.listTerminals(`id:${LOCAL_WORKTREE_ID}`) + + expect(result.terminals).toEqual([]) + expect(result.hostScope?.hostIds).toEqual(['local']) + expect(result.hostScope?.omittedHostIds).toEqual(['ssh:box-1']) + }) +}) diff --git a/src/main/shell-templates.ts b/src/main/shell-templates.ts index 0b6bced3760..97288a2b971 100644 --- a/src/main/shell-templates.ts +++ b/src/main/shell-templates.ts @@ -1,7 +1,6 @@ // Why: local PTYs and the daemon/SSH path must use identical ZDOTDIR discovery; // small drift here breaks different terminal transports in different ways. - function quotePosixSingle(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'` } @@ -104,29 +103,6 @@ unset _orca_spawn_orig_zdotdir _orca_user_zdotdir _orca_zshenv_source_dir _orca_ ` } -/** - * Restores the worktree-scoped HISTFILE that macOS `/etc/zshrc` destroys. - * - * That file assigns `HISTFILE=${ZDOTDIR:-$HOME}/.zsh_history` with no - * check-before-set, and it runs before any wrapper file Orca controls — so the - * injected value is already gone, and because ZDOTDIR still points at Orca's - * wrapper dir the replacement lands INSIDE it. Per-worktree history was a - * silent no-op on the primary platform as a result (#11044). - * - * Emitted after the user's own startup file has been sourced, so it is the last - * word, exactly like the CODEX_HOME/OPENCODE_CONFIG_DIR restores beside it. - */ -export const ZSH_HISTFILE_RESTORE_BLOCK = `if [[ -n "\${ORCA_HISTFILE:-}" ]]; then - HISTFILE="$ORCA_HISTFILE" -elif [[ "\${HISTFILE:-}" == "$ZDOTDIR/.zsh_history" ]]; then - # Why also when Orca injected nothing: /etc/zshrc derived this from Orca's - # wrapper ZDOTDIR, so history would accumulate INSIDE the wrapper dir and the - # user's real history would be invisible — the plain #11044 bug, with no - # per-worktree scoping involved. Matching the exact clobbered value means a - # HISTFILE the user set deliberately is never touched. - HISTFILE="\${ORCA_ORIG_ZDOTDIR:-$HOME}/.zsh_history" -fi` - export function getZshStartupFileSourceBlock(options: { fileName: '.zprofile' | '.zshrc' | '.zlogin' homeExpression?: string @@ -192,13 +168,11 @@ fi ` } -// Why: fish has no ZDOTDIR-style wrapper dir, so the marker rides `--init-command`, -// which fish runs AFTER config.fish (verified on 4.7.1) — the same last-word -// guarantee the zsh/bash wrapper files rely on. It fires on fish_prompt, the -// earliest event fish exposes (STA-3417). Unlike zsh's zle-line-init this lands -// just *before* fish arms `?2004h`, which PostReadyFlushGate absorbs. `builtin -// printf` so a user-defined printf can't silently swallow the marker and send -// every launch to the ready timeout. +// Why: fish has no ZDOTDIR-style wrapper dir, so the marker rides `--init-command` +// and fires on fish_prompt — the earliest event fish exposes (STA-3417). Unlike zsh's +// zle-line-init this lands just *before* fish arms `?2004h`, which PostReadyFlushGate +// absorbs. `builtin printf` so a user-defined printf can't silently swallow the marker +// and send every launch to the ready timeout. export function getFishShellReadyInitCommand(escapedMarker: string): string { return `if test "$ORCA_SHELL_READY_MARKER" = 1 function __orca_shell_ready_marker --on-event fish_prompt diff --git a/src/main/ssh/ssh-relay-upload-stage-commands.test.ts b/src/main/ssh/ssh-relay-upload-stage-commands.test.ts index 04f96c2ad81..8afb89228d0 100644 --- a/src/main/ssh/ssh-relay-upload-stage-commands.test.ts +++ b/src/main/ssh/ssh-relay-upload-stage-commands.test.ts @@ -108,16 +108,23 @@ afterEach(() => { } }) +// Why: every case spawns a real interpreter per command, and on non-Windows hosts the PowerShell +// path also forks `/usr/bin/stat` per file-identity lookup — the 8-entry reservation alone measured +// ~50s idle, nearly all of it process-spawn sys time, and the full suite multiplies that under CPU +// contention. Sized for spawn count, not the assertions, which run in microseconds. +const SPAWNED_INTERPRETER_TIMEOUT_MS = 240_000 + describe.each([ ['POSIX', posix] as const, ...((powerShellExecutable ? [['PowerShell', windows] as const] : []) as (readonly [ string, RemoteHostPlatform ])[]) -])('%s relay upload stage commands', (_label, host) => { - it.each([0, 1, 7, 8, 9])( - 'bounds reservation with %i occupied entries', - (count) => { +])( + '%s relay upload stage commands', + { timeout: SPAWNED_INTERPRETER_TIMEOUT_MS }, + (_label, host) => { + it.each([0, 1, 7, 8, 9])('bounds reservation with %i occupied entries', (count) => { const pool = createPool() for (let index = 0; index < Math.min(count, RELAY_UPLOAD_STAGE_SLOT_COUNT); index += 1) { createStage(host, pool, index) @@ -136,91 +143,94 @@ describe.each([ expect(result.status).not.toBe(0) expect(result.stderr).toContain('staging quota is full') } - }, - 120_000 - ) + }) - it('promotes only the post-claim owned payload and removes its fixed stage', () => { - const pool = createPool() - const destination = join(pool, 'destination') - mkdirSync(destination) - createStage(host, pool, 0) - const stage = parseReservedRelayUploadStage( - host, - pool, - owner, - `noise\n__ORCA_UPLOAD_STAGE_SLOT__${owner}:slot-0\n` - ) + it('promotes only the post-claim owned payload and removes its fixed stage', () => { + const pool = createPool() + const destination = join(pool, 'destination') + mkdirSync(destination) + createStage(host, pool, 0) + const stage = parseReservedRelayUploadStage( + host, + pool, + owner, + `noise\n__ORCA_UPLOAD_STAGE_SLOT__${owner}:slot-0\n` + ) - const result = runCommand( - host, - promoteOwnedRelayUploadStageCommand(host, stage, owner, destination) - ) + const result = runCommand( + host, + promoteOwnedRelayUploadStageCommand(host, stage, owner, destination) + ) - expect(result.status, result.stderr).toBe(0) - expect(relayUploadStagePromotionConfirmed(owner, result.stdout)).toBe(true) - expect(readFileSync(join(destination, 'relay.js'), 'utf8')).toBe('relay-0') - expect(existsSync(join(pool, 'slot-0'))).toBe(false) - expect(existsSync(join(pool, 'claim-0'))).toBe(false) - expect(existsSync(join(pool, 'delete-0'))).toBe(false) - }) - - it('rejects a payload reparse point without copying or deleting it', () => { - const pool = createPool() - const destination = join(pool, 'destination') - const foreign = join(pool, 'foreign.js') - mkdirSync(destination) - const stagePath = createStage(host, pool, 0) - rmSync(join(stagePath, 'payload', 'relay.js')) - writeFileSync(foreign, 'foreign') - symlinkSync(foreign, join(stagePath, 'payload', 'relay.js')) - const stage = parseReservedRelayUploadStage( - host, - pool, - owner, - `__ORCA_UPLOAD_STAGE_SLOT__${owner}:slot-0` - ) - - const result = runCommand( - host, - promoteOwnedRelayUploadStageCommand(host, stage, owner, destination) - ) - - expect(result.status, result.stderr).toBe(0) - expect(relayUploadStagePromotionConfirmed(owner, result.stdout)).toBe(false) - expect(existsSync(join(destination, 'relay.js'))).toBe(false) - expect(lstatSync(join(pool, 'slot-0', 'payload', 'relay.js')).isSymbolicLink()).toBe(true) - expect(readFileSync(foreign, 'utf8')).toBe('foreign') - }) - - it('reclaims one stale owned stage but preserves fresh and foreign stages', () => { - const pool = createPool() - createStage(host, pool, 0, owner, false) - createStage(host, pool, 1, '.foreign-owner', true) - createStage(host, pool, 2, owner, true) - - const result = runCommand(host, recoverOneStaleRelayUploadStageCommand(host, pool, 60)) - - expect(result.status, result.stderr).toBe(0) - expect(existsSync(join(pool, 'slot-0'))).toBe(true) - expect(existsSync(join(pool, 'slot-1'))).toBe(true) - expect(existsSync(join(pool, 'slot-2'))).toBe(false) - }, 120_000) - - it('drains fixed stale claim and delete states across repeated deployments', () => { - const pool = createPool() - createStage(host, pool, 0, owner, true, 'claim') - createStage(host, pool, 1, owner, true, 'delete') - - for (let attempt = 0; attempt < 2; attempt += 1) { - const result = runCommand(host, recoverOneStaleRelayUploadStageCommand(host, pool, 60)) expect(result.status, result.stderr).toBe(0) - } + expect(relayUploadStagePromotionConfirmed(owner, result.stdout)).toBe(true) + expect(readFileSync(join(destination, 'relay.js'), 'utf8')).toBe('relay-0') + expect(existsSync(join(pool, 'slot-0'))).toBe(false) + expect(existsSync(join(pool, 'claim-0'))).toBe(false) + expect(existsSync(join(pool, 'delete-0'))).toBe(false) + }) - expect(existsSync(join(pool, 'claim-0'))).toBe(false) - expect(existsSync(join(pool, 'delete-1'))).toBe(false) - }) -}) + // Why: symlink creation in the fixture needs privileges Windows CI doesn't grant. + it.skipIf(process.platform === 'win32')( + 'rejects a payload reparse point without copying or deleting it', + () => { + const pool = createPool() + const destination = join(pool, 'destination') + const foreign = join(pool, 'foreign.js') + mkdirSync(destination) + const stagePath = createStage(host, pool, 0) + rmSync(join(stagePath, 'payload', 'relay.js')) + writeFileSync(foreign, 'foreign') + symlinkSync(foreign, join(stagePath, 'payload', 'relay.js')) + const stage = parseReservedRelayUploadStage( + host, + pool, + owner, + `__ORCA_UPLOAD_STAGE_SLOT__${owner}:slot-0` + ) + + const result = runCommand( + host, + promoteOwnedRelayUploadStageCommand(host, stage, owner, destination) + ) + + expect(result.status, result.stderr).toBe(0) + expect(relayUploadStagePromotionConfirmed(owner, result.stdout)).toBe(false) + expect(existsSync(join(destination, 'relay.js'))).toBe(false) + expect(lstatSync(join(pool, 'slot-0', 'payload', 'relay.js')).isSymbolicLink()).toBe(true) + expect(readFileSync(foreign, 'utf8')).toBe('foreign') + } + ) + + it('reclaims one stale owned stage but preserves fresh and foreign stages', () => { + const pool = createPool() + createStage(host, pool, 0, owner, false) + createStage(host, pool, 1, '.foreign-owner', true) + createStage(host, pool, 2, owner, true) + + const result = runCommand(host, recoverOneStaleRelayUploadStageCommand(host, pool, 60)) + + expect(result.status, result.stderr).toBe(0) + expect(existsSync(join(pool, 'slot-0'))).toBe(true) + expect(existsSync(join(pool, 'slot-1'))).toBe(true) + expect(existsSync(join(pool, 'slot-2'))).toBe(false) + }) + + it('drains fixed stale claim and delete states across repeated deployments', () => { + const pool = createPool() + createStage(host, pool, 0, owner, true, 'claim') + createStage(host, pool, 1, owner, true, 'delete') + + for (let attempt = 0; attempt < 2; attempt += 1) { + const result = runCommand(host, recoverOneStaleRelayUploadStageCommand(host, pool, 60)) + expect(result.status, result.stderr).toBe(0) + } + + expect(existsSync(join(pool, 'claim-0'))).toBe(false) + expect(existsSync(join(pool, 'delete-1'))).toBe(false) + }) + } +) describe('POSIX ownership race fencing', () => { it('restores a replacement directory and preserves the original moved aside before claim', () => { @@ -293,48 +303,52 @@ describe('POSIX ownership race fencing', () => { }) }) -describe.runIf(powerShellExecutable)('PowerShell ownership race fencing', () => { - it('restores an old same-owner replacement whose persisted file ID does not match', () => { - const pool = createPool() - const destination = join(pool, 'destination') - mkdirSync(destination) - createStage(windows, pool, 0) - const stage = parseReservedRelayUploadStage( - windows, - pool, - owner, - `__ORCA_UPLOAD_STAGE_SLOT__${owner}:slot-0` - ) - const prefix = [ - '$script:raced = $false', - 'function Move-Item {', - 'param($LiteralPath, $Destination, $ErrorAction)', - 'if (-not $script:raced) {', - '$script:raced = $true', - 'Microsoft.PowerShell.Management\\Move-Item -LiteralPath $LiteralPath -Destination ($LiteralPath + ".original") -ErrorAction Stop', - '$null = New-Item -ItemType Directory -Path $LiteralPath', - '$null = New-Item -ItemType Directory -Path (Join-Path $LiteralPath "payload")', - '$newPath = (Get-Item -LiteralPath $LiteralPath).FullName', - '$originalPath = (Get-Item -LiteralPath ($LiteralPath + ".original")).FullName', - '[System.IO.File]::WriteAllText((Join-Path $newPath ".orca-upload-owner"), [System.IO.File]::ReadAllText((Join-Path $originalPath ".orca-upload-owner")))', - '[System.IO.File]::WriteAllText((Join-Path $newPath ".orca-upload-identity"), [System.IO.File]::ReadAllText((Join-Path $originalPath ".orca-upload-identity")))', - '(Get-Item -LiteralPath (Join-Path $newPath ".orca-upload-owner") -Force).LastWriteTimeUtc = [DateTime]::UtcNow.AddHours(-2)', - '[System.IO.File]::WriteAllText((Join-Path $newPath "foreign"), "foreign")', - '}', - 'Microsoft.PowerShell.Management\\Move-Item -LiteralPath $LiteralPath -Destination $Destination -ErrorAction $ErrorAction', - '}' - ].join('\n') +describe.runIf(powerShellExecutable)( + 'PowerShell ownership race fencing', + { timeout: SPAWNED_INTERPRETER_TIMEOUT_MS }, + () => { + it('restores an old same-owner replacement whose persisted file ID does not match', () => { + const pool = createPool() + const destination = join(pool, 'destination') + mkdirSync(destination) + createStage(windows, pool, 0) + const stage = parseReservedRelayUploadStage( + windows, + pool, + owner, + `__ORCA_UPLOAD_STAGE_SLOT__${owner}:slot-0` + ) + const prefix = [ + '$script:raced = $false', + 'function Move-Item {', + 'param($LiteralPath, $Destination, $ErrorAction)', + 'if (-not $script:raced) {', + '$script:raced = $true', + 'Microsoft.PowerShell.Management\\Move-Item -LiteralPath $LiteralPath -Destination ($LiteralPath + ".original") -ErrorAction Stop', + '$null = New-Item -ItemType Directory -Path $LiteralPath', + '$null = New-Item -ItemType Directory -Path (Join-Path $LiteralPath "payload")', + '$newPath = (Get-Item -LiteralPath $LiteralPath).FullName', + '$originalPath = (Get-Item -LiteralPath ($LiteralPath + ".original")).FullName', + '[System.IO.File]::WriteAllText((Join-Path $newPath ".orca-upload-owner"), [System.IO.File]::ReadAllText((Join-Path $originalPath ".orca-upload-owner")))', + '[System.IO.File]::WriteAllText((Join-Path $newPath ".orca-upload-identity"), [System.IO.File]::ReadAllText((Join-Path $originalPath ".orca-upload-identity")))', + '(Get-Item -LiteralPath (Join-Path $newPath ".orca-upload-owner") -Force).LastWriteTimeUtc = [DateTime]::UtcNow.AddHours(-2)', + '[System.IO.File]::WriteAllText((Join-Path $newPath "foreign"), "foreign")', + '}', + 'Microsoft.PowerShell.Management\\Move-Item -LiteralPath $LiteralPath -Destination $Destination -ErrorAction $ErrorAction', + '}' + ].join('\n') - const result = runCommand( - windows, - promoteOwnedRelayUploadStageCommand(windows, stage, owner, destination), - prefix - ) + const result = runCommand( + windows, + promoteOwnedRelayUploadStageCommand(windows, stage, owner, destination), + prefix + ) - expect(result.status, result.stderr).toBe(0) - expect(relayUploadStagePromotionConfirmed(owner, result.stdout)).toBe(false) - expect(existsSync(join(pool, 'slot-0', 'foreign'))).toBe(true) - expect(existsSync(join(pool, 'slot-0.original', '.orca-upload-owner'))).toBe(true) - expect(existsSync(join(destination, 'relay.js'))).toBe(false) - }) -}) + expect(result.status, result.stderr).toBe(0) + expect(relayUploadStagePromotionConfirmed(owner, result.stdout)).toBe(false) + expect(existsSync(join(pool, 'slot-0', 'foreign'))).toBe(true) + expect(existsSync(join(pool, 'slot-0.original', '.orca-upload-owner'))).toBe(true) + expect(existsSync(join(destination, 'relay.js'))).toBe(false) + }) + } +) diff --git a/src/main/ssh/ssh-remote-cli-terminal-host-scope.test.ts b/src/main/ssh/ssh-remote-cli-terminal-host-scope.test.ts new file mode 100644 index 00000000000..1e3a9718239 --- /dev/null +++ b/src/main/ssh/ssh-remote-cli-terminal-host-scope.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ + app: { + isPackaged: false, + getAppPath: () => '/host/app' + } +})) +vi.mock('../persistence', () => ({ + getCanonicalUserDataPath: () => '/host/user-data' +})) + +import { OrcaRuntimeService } from '../runtime/orca-runtime' +import type { HostCliPassthroughOptions } from './ssh-remote-cli-host-passthrough' +import { runRemoteOrcaCli } from './ssh-remote-orca-cli' + +// Why: a missing CLI entry forces the legacy in-process bridge, the transport +// an SSH-hosted agent actually reaches `terminal list` through. +const LEGACY_FALLBACK_OPTIONS: HostCliPassthroughOptions = { + execPath: '/host/electron', + cliEntryPath: '/host/app/out/cli/index.js', + userDataPath: '/host/user-data', + entryExists: () => false +} + +describe('remote CLI bridge terminal list', () => { + it('relays the execution host and scope to an SSH-hosted caller', async () => { + const runtime = new OrcaRuntimeService() + vi.spyOn(runtime, 'listTerminals').mockResolvedValue({ + terminals: [ + { + handle: 'term_remote', + ptyId: 'ssh:box-1@@pty-7', + worktreeId: 'repo-ssh::/remote/wt', + worktreePath: '/remote/wt', + branch: 'main', + tabId: 'tab-1', + leafId: 'leaf-1', + title: 'worker', + connected: true, + writable: true, + lastOutputAt: null, + preview: '', + executionHostId: 'ssh:box-1' + } + ], + totalCount: 1, + truncated: false, + hostScope: { hostIds: ['ssh:box-1'], omittedHostIds: ['local'] } + }) + + const result = await runRemoteOrcaCli( + runtime, + { argv: ['terminal', 'list', '--json'], cwd: '/home/alice/repo', env: {} }, + LEGACY_FALLBACK_OPTIONS + ) + + expect(result.exitCode).toBe(0) + const payload = JSON.parse(result.stdout) as { + result: { + terminals: { executionHostId?: string }[] + hostScope?: { hostIds: string[]; omittedHostIds: string[] } + } + } + expect(payload.result.terminals[0]?.executionHostId).toBe('ssh:box-1') + expect(payload.result.hostScope).toEqual({ + hostIds: ['ssh:box-1'], + omittedHostIds: ['local'] + }) + }) +}) diff --git a/src/main/terminal-history-deletion.ts b/src/main/terminal-history-deletion.ts index 1fea1128ebe..b17eaa7ba70 100644 --- a/src/main/terminal-history-deletion.ts +++ b/src/main/terminal-history-deletion.ts @@ -1,39 +1,24 @@ -import { basename, dirname, join } from 'node:path' +import { basename, join } from 'node:path' import { existsSync, mkdirSync, readdirSync, renameSync } from 'node:fs' import { removeHostTree } from './host-tree-removal' -import { deleteFishHistoryFile, resolveFishHistoryDir } from './fish-history-session' -import { readHistoryMeta } from './terminal-history' import { getHistoryRoot, + hashWorktreeId, listWslHistoryRoots, PENDING_DELETE_DIR_NAME } from './terminal-history-paths' -import { hashWorktreeId } from './terminal-history-id' -import { deleteWslFishHistoryFile } from './wsl-fish-history-cleanup' const pendingHistoryTreeRemovals = new Map>() -export const MAX_PENDING_HISTORY_TREE_REMOVALS = 64 // Why: a tombstone that fails once (Windows EBUSY under AV) would otherwise sit on disk for the whole // desktop session — only the next launch re-queues it. Bounded so a genuinely stuck tree stops retrying. export const HISTORY_TREE_REMOVAL_RETRY_DELAYS_MS = [30_000, 120_000] const historyTreeRemovalAttempts = new Map() const historyTreeRemovalRetryTimers = new Map>() -const wslDistroByTombstone = new Map() - -function wslDistroForHistoryRoot(historyRoot: string): string | undefined { - return basename(dirname(historyRoot)) === 'terminal-history-wsl' - ? basename(historyRoot) - : undefined -} function getPendingDeleteRoot(historyRoot: string): string { return join(historyRoot, PENDING_DELETE_DIR_NAME) } -function historyRootForTombstone(dir: string): string { - return dirname(dirname(dir)) -} - /** Move a history tree to a pending-delete tombstone (metadata-only) so the critical path never walks it. */ function tombstoneHistoryTree(dir: string, historyRoot: string): string | null { if (!existsSync(dir)) { @@ -66,7 +51,6 @@ function scheduleHistoryTreeRemovalRetry(dir: string): void { if (retryDelayMs === undefined) { // Out of in-process attempts: the tombstone stays on disk and the next startup drain re-queues it. historyTreeRemovalAttempts.delete(dir) - wslDistroByTombstone.delete(dir) return } historyTreeRemovalAttempts.set(dir, attempt + 1) @@ -78,46 +62,19 @@ function scheduleHistoryTreeRemovalRetry(dir: string): void { historyTreeRemovalRetryTimers.set(dir, timer) } -function scheduleHistoryTreeRemoval(dir: string, wslDistro?: string): void { +function scheduleHistoryTreeRemoval(dir: string): void { if (pendingHistoryTreeRemovals.has(dir)) { return } - // Leave excess tombstones on disk; admission is intentionally bounded. - if ( - pendingHistoryTreeRemovals.size + historyTreeRemovalRetryTimers.size >= - MAX_PENDING_HISTORY_TREE_REMOVALS - ) { - return - } - // A rescan must not cancel a delayed retry for a real failure. + // A rescan (GC / startup drain) hitting the same tombstone supersedes its pending retry. const pendingRetry = historyTreeRemovalRetryTimers.get(dir) if (pendingRetry) { - return + clearTimeout(pendingRetry) + historyTreeRemovalRetryTimers.delete(dir) } - if (wslDistro) { - wslDistroByTombstone.set(dir, wslDistro) - } - let removalSucceeded = false - const cleanupDistro = wslDistroByTombstone.get(dir) - const meta = cleanupDistro ? readHistoryMeta(dir) : null - const cleanup = - cleanupDistro && meta?.fishSession - ? // Why swallow rather than rethrow: fish history cleanup is best effort - // and runs `wsl.exe --exec fish`, which fails outright on a distro that - // has no fish — which is most of them. Rethrowing chained that failure - // into removeHostTree below, so those users' history trees were never - // reclaimed at all. - deleteWslFishHistoryFile(cleanupDistro, meta.fishSession).catch((err: unknown) => { - console.warn( - `[pty:history] Failed to delete WSL fish history: ${err instanceof Error ? err.message : String(err)}` - ) - }) - : null - const removal = (cleanup ? cleanup.then(() => removeHostTree(dir)) : removeHostTree(dir)) + const removal = removeHostTree(dir) .then(() => { - removalSucceeded = true historyTreeRemovalAttempts.delete(dir) - wslDistroByTombstone.delete(dir) }) .catch((err: unknown) => { console.warn( @@ -129,9 +86,6 @@ function scheduleHistoryTreeRemoval(dir: string, wslDistro?: string): void { if (pendingHistoryTreeRemovals.get(dir) === removal) { pendingHistoryTreeRemovals.delete(dir) } - if (removalSucceeded) { - schedulePendingHistoryTreeRemovals(historyRootForTombstone(dir)) - } }) pendingHistoryTreeRemovals.set(dir, removal) } @@ -139,23 +93,11 @@ function scheduleHistoryTreeRemoval(dir: string, wslDistro?: string): void { /** Tombstone one history tree and queue its recursive removal off the caller's critical path. * Returns false when the rename failed, leaving the tree for a later GC pass to reclaim. */ export function scheduleWorktreeHistoryTreeDeletion(dir: string, historyRoot: string): boolean { - // Why first: fish keeps its history in the user's fish data dir, outside this tree, - // so the meta.json naming the session must still be readable when we look it up. - const meta = readHistoryMeta(dir) - const wslDistro = wslDistroForHistoryRoot(historyRoot) - if (meta?.fishSession && !wslDistro) { - // Why both directories: the recorded one is what the PTY's fish saw, this - // process's own is the fallback when meta.json predates that field. - deleteFishHistoryFile(meta.fishSession, [ - ...(meta.fishHistoryDir ? [meta.fishHistoryDir] : []), - resolveFishHistoryDir() - ]) - } const tombstone = tombstoneHistoryTree(dir, historyRoot) if (!tombstone) { return false } - scheduleHistoryTreeRemoval(tombstone, wslDistro) + scheduleHistoryTreeRemoval(tombstone) return true } @@ -167,7 +109,7 @@ export function schedulePendingHistoryTreeRemovals(historyRoot: string): void { } try { for (const entry of readdirSync(pendingRoot)) { - scheduleHistoryTreeRemoval(join(pendingRoot, entry), wslDistroForHistoryRoot(historyRoot)) + scheduleHistoryTreeRemoval(join(pendingRoot, entry)) } } catch { // Non-fatal. @@ -189,7 +131,6 @@ export function cancelPendingHistoryTreeRemovalRetries(): void { } historyTreeRemovalRetryTimers.clear() historyTreeRemovalAttempts.clear() - wslDistroByTombstone.clear() } /** Drain every history root's tombstones and await the in-flight removals. Tests only: production diff --git a/src/main/terminal-history-fish-session.node-pty.test.ts b/src/main/terminal-history-fish-session.node-pty.test.ts deleted file mode 100644 index f4d7b3cf404..00000000000 --- a/src/main/terminal-history-fish-session.node-pty.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -/** - * Real-fish proof for worktree-scoped fish history. - * - * fish IGNORES HISTFILE, so the directory+filename mechanism bash and zsh use does - * not transfer: history lives at `$XDG_DATA_HOME/fish/${fish_history}_history` and - * the only isolation knob is the session NAME. This suite pins the two facts - * `injectHistoryEnv` bets on — that fish picks up `fish_history` from the spawn - * environment (it imports env vars as global variables at startup), and that the - * file it then writes is the one `resolveFishHistoryDir` points at. - * - * Interactive is mandatory: fish writes no history in non-interactive mode, so the - * PTY and the typed line are the test, not scaffolding. DA1/CPR/OSC-11 probes are - * answered here because no real xterm is attached — without them fish stalls ~10s - * on its DA1 read sentinel before painting a prompt. - */ -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import path from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { fishRequirementViolation, resolveFishBinary } from '../shared/fish-binary-requirement' -import { fishHistorySessionName, resolveFishHistoryDir } from './fish-history-session' - -const FISH = resolveFishBinary(4) -const itWithFish = FISH.available ? it : it.skip - -const PROMPT_MARK = 'ORCAHIST> ' -const WORKTREE_HASH = 'deadbeefdeadbeef' -const MARKER = 'echo orca-worktree-scoped-history' - -const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)) - -async function waitUntil(predicate: () => boolean, timeoutMs: number): Promise { - const deadline = Date.now() + timeoutMs - while (Date.now() < deadline) { - if (predicate()) { - return true - } - await sleep(20) - } - return false -} - -describe('fish keeps per-worktree history under the session Orca names', () => { - let home: string | null = null - - // Always runs, so the CI lane cannot report green with the regression below skipped. - it('has the fish this suite needs when CI requires one', () => { - expect(fishRequirementViolation(FISH)).toBeNull() - }) - - afterEach(() => { - if (home) { - rmSync(home, { recursive: true, force: true }) - home = null - } - }) - - itWithFish( - 'writes an interactive command to $XDG_DATA_HOME/fish/_history, not the shared file', - async () => { - const nodePty = await import('node-pty') - - home = mkdtempSync(path.join(tmpdir(), 'orca-fish-history-')) - const dataHome = path.join(home, 'data') - mkdirSync(path.join(home, 'fish'), { recursive: true }) - writeFileSync( - path.join(home, 'fish/config.fish'), - [ - 'set -g fish_greeting ""', - `function fish_prompt; printf '${PROMPT_MARK}'; end`, - 'function fish_right_prompt; end', - '' - ].join('\n') - ) - - const session = fishHistorySessionName(WORKTREE_HASH) - const term = nodePty.spawn(FISH.path as string, ['-l', '-i'], { - name: 'xterm-256color', - cols: 120, - rows: 30, - cwd: home, - // Fully pinned: no ambient HOME/XDG_* reaches fish, so this cannot pass - // only on a machine whose real fish config happens to cooperate. - env: { - PATH: process.env.PATH ?? '/usr/bin:/bin', - HOME: home, - TERM: 'xterm-256color', - // LC_ALL wins over any LANG/LC_* a host might contribute, pinning fish's locale. - LANG: 'en_US.UTF-8', - LC_ALL: 'en_US.UTF-8', - XDG_CONFIG_HOME: home, - XDG_DATA_HOME: dataHome, - // The production injection under test (terminal-history.ts). - fish_history: session - } - }) - - let rendered = '' - term.onData((chunk) => { - rendered += chunk - if (chunk.includes('\x1b[0c') || chunk.includes('\x1b[c')) { - term.write('\x1b[?62;4;6;22c') - } - if (chunk.includes('\x1b[6n')) { - term.write('\x1b[1;1R') - } - if (chunk.includes('\x1b]10;?') || chunk.includes('\x1b]11;?')) { - term.write('\x1b]11;rgb:1e1e/1e1e/1e1e\x1b\\') - } - }) - let exited = false - term.onExit(() => { - exited = true - }) - - expect(await waitUntil(() => rendered.includes(PROMPT_MARK), 15_000)).toBe(true) - term.write(`${MARKER}\r`) - expect(await waitUntil(() => rendered.includes('orca-worktree-scoped-history'), 5_000)).toBe( - true - ) - // fish flushes history on exit, so the read must wait for the process to go. - term.write('exit\r') - expect(await waitUntil(() => exited, 10_000)).toBe(true) - try { - term.kill() - } catch { - // already gone - } - - const scopedPath = path.join( - resolveFishHistoryDir({ XDG_DATA_HOME: dataHome }), - `${session}_history` - ) - expect(scopedPath).toBe(path.join(dataHome, 'fish', `${session}_history`)) - const scoped = readFileSync(scopedPath as string, 'utf8') - // YAML-ish records, not one line per command — any reader must handle this shape. - expect(scoped).toContain(`- cmd: ${MARKER}`) - expect(scoped).toMatch(/^ {2}when: \d+$/m) - - // Isolation: the default session file fish would otherwise have used is absent. - expect(() => readFileSync(path.join(dataHome, 'fish', 'fish_history'), 'utf8')).toThrow() - }, - 40_000 - ) -}) diff --git a/src/main/terminal-history-gc.ts b/src/main/terminal-history-gc.ts index 2f3fdabaff1..2a6d2280945 100644 --- a/src/main/terminal-history-gc.ts +++ b/src/main/terminal-history-gc.ts @@ -1,5 +1,5 @@ import { join } from 'node:path' -import { existsSync, readdirSync, statSync } from 'node:fs' +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs' import { getHistoryRoot, listWslHistoryRoots, @@ -9,9 +9,6 @@ import { schedulePendingHistoryTreeRemovals, scheduleWorktreeHistoryTreeDeletion } from './terminal-history-deletion' -import { readHistoryMeta } from './terminal-history' -import { resolveFishHistoryDir, sweepOrphanedFishHistoryFiles } from './fish-history-session' -import { hashWorktreeId } from './terminal-history-id' // Why 5 minutes: GC runs ~10s after startup, and the live-worktree snapshot is // taken just before. A worktree created between the snapshot and GC execution @@ -28,21 +25,8 @@ let historyGcRunning = false function gcScanRoot( root: string, liveWorktreeIds: Set -): { - totalDirs: number - orphaned: number - pruned: number - totalSizeKB: number - /** Every fish data dir a meta.json in this root names, for the orphan sweep. */ - fishHistoryDirs: Set -} { - const result = { - totalDirs: 0, - orphaned: 0, - pruned: 0, - totalSizeKB: 0, - fishHistoryDirs: new Set() - } +): { totalDirs: number; orphaned: number; pruned: number; totalSizeKB: number } { + const result = { totalDirs: 0, orphaned: 0, pruned: 0, totalSizeKB: 0 } if (!existsSync(root)) { return result } @@ -77,11 +61,11 @@ function gcScanRoot( continue } - const meta = readHistoryMeta(entryPath) - if (meta?.fishHistoryDir) { - result.fishHistoryDirs.add(meta.fishHistoryDir) + const meta = JSON.parse(readFileSync(metaPath, 'utf-8')) as { + worktreeId?: string + createdAt?: string } - if (!meta?.worktreeId) { + if (!meta.worktreeId) { continue } @@ -121,7 +105,6 @@ export function runHistoryGc(liveWorktreeIds: Set): void { // Also scan WSL history directories (each distro has its own subdirectory). const wslTotals = { totalDirs: 0, orphaned: 0, pruned: 0, totalSizeKB: 0 } - const liveFishHistoryDirs = new Set(main.fishHistoryDirs) for (const distroRoot of listWslHistoryRoots()) { schedulePendingHistoryTreeRemovals(distroRoot) const r = gcScanRoot(distroRoot, liveWorktreeIds) @@ -129,27 +112,6 @@ export function runHistoryGc(liveWorktreeIds: Set): void { wslTotals.orphaned += r.orphaned wslTotals.pruned += r.pruned wslTotals.totalSizeKB += r.totalSizeKB - for (const dir of r.fishHistoryDirs) { - liveFishHistoryDirs.add(dir) - } - } - - // Why a sweep on top of per-worktree deletion: a fish history file lives in - // the user's fish data dir, so it outlives the directory that names it. A - // crash between tombstone and removal, or a hand-deleted history dir, leaves - // one with nothing left to point at it. Collecting the dirs the live meta - // files name covers a machine whose XDG_DATA_HOME changed between runs. - const fishDirs = new Set([resolveFishHistoryDir()]) - for (const dir of liveFishHistoryDirs) { - fishDirs.add(dir) - } - const fishOrphans = sweepOrphanedFishHistoryFiles( - new Set([...liveWorktreeIds].map(hashWorktreeId)), - fishDirs, - GC_MIN_AGE_MS - ) - if (fishOrphans > 0) { - console.log(`[pty:history:gc] Swept ${fishOrphans} orphaned fish history file(s)`) } const totalDirs = main.totalDirs + wslTotals.totalDirs diff --git a/src/main/terminal-history-id.ts b/src/main/terminal-history-id.ts deleted file mode 100644 index 38454baa29e..00000000000 --- a/src/main/terminal-history-id.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createHash } from 'node:crypto' - -/** First 16 hex chars of SHA-256 of the worktreeId. */ -export function hashWorktreeId(worktreeId: string): string { - return createHash('sha256').update(worktreeId).digest('hex').slice(0, 16) -} diff --git a/src/main/terminal-history-paths.ts b/src/main/terminal-history-paths.ts index 263f39c8c35..e942dd8bc3f 100644 --- a/src/main/terminal-history-paths.ts +++ b/src/main/terminal-history-paths.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto' import { existsSync, readdirSync } from 'node:fs' import { join } from 'node:path' import { app } from 'electron' @@ -7,7 +8,10 @@ const HISTORY_DIR_NAME_WSL = 'terminal-history-wsl' // Why: rename live history out of the way first so a quit mid-rm still leaves a durable tombstone GC can finish. export const PENDING_DELETE_DIR_NAME = '.pending-delete' -export { hashWorktreeId } from './terminal-history-id' +/** First 16 hex chars of SHA-256 of the worktreeId. */ +export function hashWorktreeId(worktreeId: string): string { + return createHash('sha256').update(worktreeId).digest('hex').slice(0, 16) +} export function getHistoryRoot(): string { return join(app.getPath('userData'), HISTORY_DIR_NAME) diff --git a/src/main/terminal-history-tombstone-retry.test.ts b/src/main/terminal-history-tombstone-retry.test.ts index 67cb9926322..195ee82d9f3 100644 --- a/src/main/terminal-history-tombstone-retry.test.ts +++ b/src/main/terminal-history-tombstone-retry.test.ts @@ -1,13 +1,12 @@ -import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' let userDataDir: string -const { removeHostTreeMock, deleteWslFishHistoryFileMock } = vi.hoisted(() => ({ - removeHostTreeMock: vi.fn<(dir: string) => Promise>(), - deleteWslFishHistoryFileMock: vi.fn<(distro: string, session: string) => Promise>() +const { removeHostTreeMock } = vi.hoisted(() => ({ + removeHostTreeMock: vi.fn<(dir: string) => Promise>() })) vi.mock('electron', () => ({ @@ -20,19 +19,11 @@ vi.mock('./host-tree-removal', () => ({ removeHostTree: removeHostTreeMock })) -vi.mock('./wsl-fish-history-cleanup', () => ({ - deleteWslFishHistoryFile: deleteWslFishHistoryFileMock -})) - import { hashWorktreeId } from './terminal-history-paths' -import { fishHistorySessionName } from './fish-history-session' import { cancelPendingHistoryTreeRemovalRetries, deleteWorktreeHistoryDir, - flushPendingWorktreeHistoryDeletions, - MAX_PENDING_HISTORY_TREE_REMOVALS, - HISTORY_TREE_REMOVAL_RETRY_DELAYS_MS, - schedulePendingHistoryTreeRemovals + HISTORY_TREE_REMOVAL_RETRY_DELAYS_MS } from './terminal-history-deletion' /** A tombstone whose rm fails once used to sit on disk for the rest of the session — only the next @@ -41,11 +32,6 @@ describe('tombstoned history removal retries', () => { beforeEach(() => { userDataDir = mkdtempSync(join(tmpdir(), 'orca-history-retry-')) removeHostTreeMock.mockReset() - deleteWslFishHistoryFileMock.mockReset() - deleteWslFishHistoryFileMock.mockResolvedValue(undefined) - removeHostTreeMock.mockImplementation(async (dir) => { - rmSync(dir, { recursive: true, force: true }) - }) vi.useFakeTimers() }) @@ -85,6 +71,7 @@ describe('tombstoned history removal retries', () => { it('does not re-arm a retry after the removal succeeds', async () => { seedWorktreeHistory('repo-1::/path/clean-wt') + removeHostTreeMock.mockResolvedValue(undefined) deleteWorktreeHistoryDir('repo-1::/path/clean-wt') await vi.advanceTimersByTimeAsync(0) @@ -92,118 +79,4 @@ describe('tombstoned history removal retries', () => { expect(removeHostTreeMock).toHaveBeenCalledTimes(1) }) - - it('admits a bounded tombstone batch and drains the rest from disk', async () => { - const distroRoot = join(userDataDir, 'terminal-history-wsl', 'Ubuntu') - mkdirSync(distroRoot, { recursive: true }) - const releases: (() => void)[] = [] - removeHostTreeMock.mockImplementation( - (dir) => - new Promise((resolve) => - releases.push(() => { - rmSync(dir, { recursive: true, force: true }) - resolve() - }) - ) - ) - - for (let index = 0; index < 1_000; index++) { - const worktreeId = `repo-1::/path/wsl-${index}` - const dir = join(distroRoot, hashWorktreeId(worktreeId)) - mkdirSync(dir, { recursive: true }) - writeFileSync( - join(dir, 'meta.json'), - JSON.stringify({ - worktreeId, - fishSession: fishHistorySessionName(hashWorktreeId(worktreeId)) - }) - ) - } - - for (let index = 0; index < 1_000; index++) { - deleteWorktreeHistoryDir(`repo-1::/path/wsl-${index}`) - } - await vi.advanceTimersByTimeAsync(0) - expect(removeHostTreeMock).toHaveBeenCalledTimes(64) - expect(deleteWslFishHistoryFileMock).toHaveBeenCalledTimes(64) - expect(releases).toHaveLength(64) - - while (releases.length > 0) { - releases.splice(0).forEach((release) => release()) - await vi.advanceTimersByTimeAsync(0) - } - expect(removeHostTreeMock).toHaveBeenCalledTimes(1_000) - expect(deleteWslFishHistoryFileMock).toHaveBeenCalledTimes(1_000) - }) - - it('caps persistent failures and leaves excess tombstones for a later disk batch', async () => { - const distroRoot = join(userDataDir, 'terminal-history-wsl', 'Ubuntu') - mkdirSync(distroRoot, { recursive: true }) - // Why removeHostTree and not the fish cleanup: a failing fish cleanup no - // longer blocks tree removal (most distros have no fish), so the tree - // removal itself has to be the thing that fails to exercise the cap. - removeHostTreeMock.mockRejectedValue(new Error('EBUSY')) - for (let index = 0; index < 1_000; index++) { - const worktreeId = `repo-1::/path/fail-${index}` - const dir = join(distroRoot, hashWorktreeId(worktreeId)) - mkdirSync(dir, { recursive: true }) - writeFileSync( - join(dir, 'meta.json'), - JSON.stringify({ - worktreeId, - fishSession: fishHistorySessionName(hashWorktreeId(worktreeId)) - }) - ) - deleteWorktreeHistoryDir(worktreeId) - } - await vi.advanceTimersByTimeAsync(0) - expect(vi.getTimerCount()).toBe(64) - expect(readdirSync(join(distroRoot, '.pending-delete'))).toHaveLength(1_000) - - for (const delay of HISTORY_TREE_REMOVAL_RETRY_DELAYS_MS) { - await vi.advanceTimersByTimeAsync(delay) - expect(vi.getTimerCount()).toBeLessThanOrEqual(64) - } - await vi.advanceTimersByTimeAsync(0) - removeHostTreeMock.mockReset() - removeHostTreeMock.mockImplementation(async (dir) => { - rmSync(dir, { recursive: true, force: true }) - }) - schedulePendingHistoryTreeRemovals(distroRoot) - await flushPendingWorktreeHistoryDeletions() - expect(removeHostTreeMock.mock.calls.length).toBeGreaterThan( - MAX_PENDING_HISTORY_TREE_REMOVALS * (HISTORY_TREE_REMOVAL_RETRY_DELAYS_MS.length + 1) - ) - expect(readdirSync(join(distroRoot, '.pending-delete'))).toHaveLength(0) - }) - - it('still removes the history tree when WSL fish cleanup fails', async () => { - // Why this matters: `injectWslFishHistoryEnv` records a fishSession for EVERY - // WSL terminal, and cleanup shells out to `wsl.exe --exec fish`. On a distro - // with no fish that always fails — which used to chain into removeHostTree and - // leak the bash/zsh history tree for those users permanently. - const distroRoot = join(userDataDir, 'terminal-history-wsl', 'Ubuntu') - mkdirSync(distroRoot, { recursive: true }) - deleteWslFishHistoryFileMock.mockRejectedValue(new Error('fish: command not found')) - removeHostTreeMock.mockImplementation(async (dir) => { - rmSync(dir, { recursive: true, force: true }) - }) - const worktreeId = 'repo-1::/path/no-fish' - const dir = join(distroRoot, hashWorktreeId(worktreeId)) - mkdirSync(dir, { recursive: true }) - writeFileSync( - join(dir, 'meta.json'), - JSON.stringify({ - worktreeId, - fishSession: fishHistorySessionName(hashWorktreeId(worktreeId)) - }) - ) - - deleteWorktreeHistoryDir(worktreeId) - await flushPendingWorktreeHistoryDeletions() - - expect(deleteWslFishHistoryFileMock).toHaveBeenCalled() - expect(removeHostTreeMock).toHaveBeenCalled() - expect(readdirSync(join(distroRoot, '.pending-delete'))).toHaveLength(0) - }) }) diff --git a/src/main/terminal-history.test.ts b/src/main/terminal-history.test.ts index c1f76aa0c44..c1900e3d0b5 100644 --- a/src/main/terminal-history.test.ts +++ b/src/main/terminal-history.test.ts @@ -7,39 +7,23 @@ const { mkdirSyncMock, writeFileSyncMock, readFileSyncMock, - lstatSyncMock, rmSyncMock, renameSyncMock, readdirSyncMock, statSyncMock, - openSyncMock, - fstatSyncMock, - closeSyncMock, getPathMock, - rmAsyncMock, - deleteWslFishHistoryFileMock + rmAsyncMock } = vi.hoisted(() => ({ existsSyncMock: vi.fn(), mkdirSyncMock: vi.fn(), writeFileSyncMock: vi.fn(), readFileSyncMock: vi.fn(), - lstatSyncMock: vi.fn(), rmSyncMock: vi.fn(), renameSyncMock: vi.fn(), readdirSyncMock: vi.fn(), statSyncMock: vi.fn(), - openSyncMock: vi.fn((_path?: string | Buffer) => 1), - fstatSyncMock: vi.fn(() => ({ - dev: 1n, - ino: 2n, - birthtimeNs: 3n, - isFile: () => true, - isDirectory: () => true - })), - closeSyncMock: vi.fn(), getPathMock: vi.fn(), - rmAsyncMock: vi.fn(async () => undefined), - deleteWslFishHistoryFileMock: vi.fn(async () => undefined) + rmAsyncMock: vi.fn(async () => undefined) })) vi.mock('fs', () => ({ @@ -47,14 +31,10 @@ vi.mock('fs', () => ({ mkdirSync: mkdirSyncMock, writeFileSync: writeFileSyncMock, readFileSync: readFileSyncMock, - lstatSync: lstatSyncMock, rmSync: rmSyncMock, renameSync: renameSyncMock, readdirSync: readdirSyncMock, - statSync: statSyncMock, - openSync: openSyncMock, - fstatSync: fstatSyncMock, - closeSync: closeSyncMock + statSync: statSyncMock })) // Spread the real module: this factory replaces node:fs/promises for the whole import graph, so a @@ -80,31 +60,21 @@ vi.mock('./wsl', () => ({ toLinuxPath: toLinuxPathMock })) -vi.mock('./wsl-fish-history-cleanup', () => ({ - deleteWslFishHistoryFile: deleteWslFishHistoryFileMock -})) - import { resolveShellKind, ensureHistoryDir, injectHistoryEnv, - MAX_HISTORY_META_BYTES, - updateHistoryEnvForFallback, - type HistoryInjectionResult + updateHistFileForFallback } from './terminal-history' -import { fishHistorySessionName } from './fish-history-session' import { hashWorktreeId } from './terminal-history-paths' import { - cancelPendingHistoryTreeRemovalRetries, deleteWorktreeHistoryDir, flushPendingWorktreeHistoryDeletions } from './terminal-history-deletion' - import { runHistoryGc, scheduleHistoryGc } from './terminal-history-gc' describe('terminal-history', () => { afterEach(() => { - cancelPendingHistoryTreeRemovalRetries() vi.useRealTimers() }) @@ -112,30 +82,9 @@ describe('terminal-history', () => { vi.clearAllMocks() // Why: clearAllMocks keeps implementations, so a throwing rename from one test would leak forward. renameSyncMock.mockReset() - readFileSyncMock.mockReset() - readdirSyncMock.mockReset() - rmAsyncMock.mockReset() - rmAsyncMock.mockResolvedValue(undefined) getPathMock.mockReturnValue('/fake/userData') existsSyncMock.mockReturnValue(true) statSyncMock.mockReturnValue({ isDirectory: () => true, size: 100 }) - openSyncMock.mockImplementation(() => 1) - fstatSyncMock.mockReturnValue({ - dev: 1n, - ino: 2n, - birthtimeNs: 3n, - isFile: () => true, - isDirectory: () => true - }) - lstatSyncMock.mockReturnValue({ - dev: 1n, - ino: 2n, - birthtimeNs: 3n, - size: 100, - isDirectory: () => true, - isFile: () => true, - isSymbolicLink: () => false - }) }) describe('resolveShellKind', () => { @@ -279,101 +228,12 @@ describe('terminal-history', () => { expect(result.histFile).toBeNull() }) - it('scopes fish by session name instead of HISTFILE, which fish ignores', () => { + it('does not inject HISTFILE for fish (Phase 2)', () => { const env: Record = {} const result = injectHistoryEnv(env, 'repo-1::/path/wt', '/usr/bin/fish', '/path/wt') expect(env.HISTFILE).toBeUndefined() expect(result.shell).toBe('fish') - expect(result.fishSession).toBe(fishHistorySessionName(hashWorktreeId('repo-1::/path/wt'))) - expect(env.fish_history).toBe(result.fishSession) - // Deletion can only find the out-of-tree fish file through this record. - expect(writeFileSyncMock).toHaveBeenCalledWith( - expect.stringContaining('meta.json'), - expect.stringContaining(`"fishSession":"${result.fishSession}"`), - expect.anything() - ) - }) - - it('records the history DIRECTORY from the SPAWN env, not this process env', () => { - // The file is written by the PTY's fish, and the two envs disagree whenever - // Orca was launched with a different XDG_DATA_HOME than the shells it spawns. - // Pinned so the assertion cannot accidentally read the developer's own value. - const originalDataHome = process.env.XDG_DATA_HOME - process.env.XDG_DATA_HOME = ['', 'main', 'process', 'data'].join(sep) - try { - const env: Record = { XDG_DATA_HOME: ['', 'spawn', 'data'].join(sep) } - injectHistoryEnv(env, 'repo-1::/path/wt', '/usr/bin/fish', '/path/wt') - const meta = JSON.parse(writeFileSyncMock.mock.calls.at(-1)?.[1] as string) as Record< - string, - string - > - - expect(meta.fishHistoryDir).toBe(['', 'spawn', 'data', 'fish'].join(sep)) - } finally { - if (originalDataHome === undefined) { - delete process.env.XDG_DATA_HOME - } else { - process.env.XDG_DATA_HOME = originalDataHome - } - } - }) - - it('replaces oversized metadata without parsing it', () => { - statSyncMock.mockReturnValue({ isDirectory: () => true, size: MAX_HISTORY_META_BYTES + 1 }) - const env = { XDG_DATA_HOME: ['', 'active'].join(sep) } - - injectHistoryEnv(env, 'repo-1::/path/wt', '/usr/bin/fish', '/path/wt') - - expect(readFileSyncMock).not.toHaveBeenCalled() - const meta = JSON.parse(writeFileSyncMock.mock.calls.at(-1)?.[1] as string) as Record< - string, - string - > - expect(meta.fishHistoryDir).toBe(['', 'active', 'fish'].join(sep)) - }) - - it('gives two fish worktrees different history sessions', () => { - const envA: Record = {} - injectHistoryEnv(envA, 'repo-1::/path/wt-a', '/usr/bin/fish', '/path/wt-a') - const envB: Record = {} - injectHistoryEnv(envB, 'repo-1::/path/wt-b', '/usr/bin/fish', '/path/wt-b') - - expect(envA.fish_history).not.toBe(envB.fish_history) - }) - - it('drops an ORCA_HISTFILE inherited from a parent Orca PTY', () => { - // Why: an Orca terminal opened from inside another Orca terminal inherits - // it, and the zsh wrapper would then re-export the PARENT worktree's - // history path here. Credit: caught by @innocarpe in #11146. - const env: Record = { ORCA_HISTFILE: ['', 'other', 'wt', 'zsh_history'].join(sep) } - - injectHistoryEnv(env, 'repo-1::/path/wt', '/bin/zsh', '/path/wt') - - expect(env.ORCA_HISTFILE).toBe(env.HISTFILE) - expect(env.ORCA_HISTFILE).not.toContain('other') - }) - - it('drops an inherited ORCA_HISTFILE even when it injects nothing', () => { - // The dangerous variant: the early return would otherwise leave the stale - // value pointing the wrapper at another worktree, overriding HISTFILE. - const env: Record = { - HISTFILE: ['', 'mine', 'zsh_history'].join(sep), - ORCA_HISTFILE: ['', 'other', 'wt', 'zsh_history'].join(sep) - } - - injectHistoryEnv(env, 'repo-1::/path/wt', '/bin/zsh', '/path/wt') - - expect(env.HISTFILE).toBe(['', 'mine', 'zsh_history'].join(sep)) - expect(env.ORCA_HISTFILE).toBeUndefined() - }) - - it('preserves a caller-supplied fish_history', () => { - const env: Record = { fish_history: 'mine' } - const result = injectHistoryEnv(env, 'repo-1::/path/wt', '/usr/bin/fish', '/path/wt') - - expect(env.fish_history).toBe('mine') - expect(result.fishSession).toBeNull() }) it('degrades gracefully when directory creation fails', () => { @@ -389,19 +249,12 @@ describe('terminal-history', () => { }) }) - describe('updateHistoryEnvForFallback', () => { - const zshInjection = (): HistoryInjectionResult => ({ - shell: 'zsh', - histFile: '/fake/userData/terminal-history/abc123/zsh_history', - fishSession: null, - historyDir: '/fake/userData/terminal-history/abc123' - }) - + describe('updateHistFileForFallback', () => { it('updates HISTFILE to match fallback shell', () => { const env: Record = { HISTFILE: '/fake/userData/terminal-history/abc123/zsh_history' } - updateHistoryEnvForFallback(env, '/bin/bash', zshInjection()) + updateHistFileForFallback(env, '/bin/bash') expect(env.HISTFILE).toBe('/fake/userData/terminal-history/abc123/bash_history') }) @@ -409,45 +262,18 @@ describe('terminal-history', () => { const env: Record = { HISTFILE: '/fake/userData/terminal-history/abc123/zsh_history' } - updateHistoryEnvForFallback(env, '/bin/sh', zshInjection()) + updateHistFileForFallback(env, '/bin/sh') expect(env.HISTFILE).toBeUndefined() }) - it('is a no-op when nothing was injected', () => { + it('is a no-op when HISTFILE is not set', () => { const env: Record = {} - updateHistoryEnvForFallback(env, '/bin/bash', { - shell: 'unknown', - histFile: null, - fishSession: null, - historyDir: null - }) + updateHistFileForFallback(env, '/bin/bash') expect(env.HISTFILE).toBeUndefined() }) - - it('swaps an injected fish session for the fallback shell HISTFILE', () => { - const env: Record = { fish_history: 'orca_abc123' } - updateHistoryEnvForFallback(env, '/bin/bash', { - shell: 'fish', - histFile: null, - fishSession: 'orca_abc123', - historyDir: '/fake/userData/terminal-history/abc123' - }) - expect(env.fish_history).toBeUndefined() - expect(env.HISTFILE).toBe('/fake/userData/terminal-history/abc123/bash_history') - }) - - it('keeps a caller-supplied fish_history the injection never claimed', () => { - const env: Record = { fish_history: 'mine' } - updateHistoryEnvForFallback(env, '/bin/bash', zshInjection()) - expect(env.fish_history).toBe('mine') - }) }) describe('deleteWorktreeHistoryDir', () => { - const worktreeId = 'repo-1::/path/wt' - const session = fishHistorySessionName(hashWorktreeId(worktreeId)) - const historyFilename = `${session}_history` - it('tombstones then async-removes the history directory without recursive rmSync', async () => { existsSyncMock.mockReturnValue(true) deleteWorktreeHistoryDir('repo-1::/path/wt') @@ -460,84 +286,6 @@ describe('terminal-history', () => { await flushPendingWorktreeHistoryDeletions() }) - it('deletes the fish history file in the directory meta.json recorded', async () => { - const recordedDir = ['', 'spawn', 'data', 'fish'].join(sep) - existsSyncMock.mockReturnValue(true) - lstatSyncMock.mockReturnValue({ isFile: () => true }) - readFileSyncMock.mockReturnValue( - JSON.stringify({ worktreeId, fishSession: session, fishHistoryDir: recordedDir }) - ) - - deleteWorktreeHistoryDir('repo-1::/path/wt') - - expect(rmSyncMock).toHaveBeenCalledWith([recordedDir, historyFilename].join(sep)) - await flushPendingWorktreeHistoryDeletions() - }) - - it('also tries this process fish dir, for meta written before that field', async () => { - const originalDataHome = process.env.XDG_DATA_HOME - process.env.XDG_DATA_HOME = ['', 'main', 'data'].join(sep) - try { - existsSyncMock.mockReturnValue(true) - lstatSyncMock.mockReturnValue({ isFile: () => true }) - readFileSyncMock.mockReturnValue(JSON.stringify({ worktreeId, fishSession: session })) - - deleteWorktreeHistoryDir('repo-1::/path/wt') - - expect(rmSyncMock).toHaveBeenCalledWith( - ['', 'main', 'data', 'fish', historyFilename].join(sep) - ) - } finally { - if (originalDataHome === undefined) { - delete process.env.XDG_DATA_HOME - } else { - process.env.XDG_DATA_HOME = originalDataHome - } - } - await flushPendingWorktreeHistoryDeletions() - }) - - it('ignores a fishSession that does not belong to this history directory', async () => { - // Why: the session name is re-derived from the directory's own hash, so a - // tampered meta.json naming someone else's session cannot steer the delete. - existsSyncMock.mockReturnValue(true) - lstatSyncMock.mockReturnValue({ isFile: () => true }) - readFileSyncMock.mockReturnValue( - JSON.stringify({ worktreeId, fishSession: 'orca_deadbeefdeadbeef' }) - ) - - deleteWorktreeHistoryDir('repo-1::/path/wt') - - expect(rmSyncMock).not.toHaveBeenCalledWith(expect.stringContaining('orca_deadbeefdeadbeef')) - await flushPendingWorktreeHistoryDeletions() - }) - - it('refuses to unlink a fish history path that is not a regular file', async () => { - existsSyncMock.mockReturnValue(true) - lstatSyncMock.mockReturnValue({ isFile: () => false }) - readFileSyncMock.mockReturnValue(JSON.stringify({ worktreeId, fishSession: session })) - - deleteWorktreeHistoryDir('repo-1::/path/wt') - - expect(rmSyncMock).not.toHaveBeenCalled() - await flushPendingWorktreeHistoryDeletions() - }) - - it('cleans WSL Fish history through the owning distro before removing metadata', async () => { - readFileSyncMock.mockReturnValue(JSON.stringify({ worktreeId, fishSession: session })) - readdirSyncMock.mockImplementation((path: string) => { - if (path.endsWith('terminal-history-wsl')) { - return ['Ubuntu'] - } - return [] - }) - - deleteWorktreeHistoryDir(worktreeId) - await flushPendingWorktreeHistoryDeletions() - - expect(deleteWslFishHistoryFileMock).toHaveBeenCalledWith('Ubuntu', session) - }) - it('leaves the live directory alone when the tombstone rename fails', async () => { existsSyncMock.mockReturnValue(true) renameSyncMock.mockImplementation(() => { @@ -561,7 +309,6 @@ describe('terminal-history', () => { }) it('retries pending tombstones on flush after a failed async rm (app-quit durability)', async () => { - let leftoverTombstonePresent = true existsSyncMock.mockImplementation((p: string) => { const path = String(p) if (path.includes('.pending-delete') && !path.endsWith('.pending-delete')) { @@ -574,13 +321,11 @@ describe('terminal-history', () => { }) readdirSyncMock.mockImplementation((p: string) => { if (String(p).endsWith('.pending-delete')) { - return leftoverTombstonePresent ? ['leftover-tombstone'] : [] + return ['leftover-tombstone'] } return [] }) - rmAsyncMock.mockImplementation(async () => { - leftoverTombstonePresent = false - }) + rmAsyncMock.mockResolvedValue(undefined) await flushPendingWorktreeHistoryDeletions() expect(rmAsyncMock).toHaveBeenCalledWith( expect.stringContaining('leftover-tombstone'), @@ -643,34 +388,6 @@ describe('terminal-history', () => { ) }) - it('continues GC after one orphan tombstone fails', async () => { - existsSyncMock.mockImplementation((path: string) => !path.includes('terminal-history-wsl')) - readdirSyncMock.mockImplementation((dir: string) => { - if (dir.endsWith('.pending-delete')) { - return [] - } - if (dir.endsWith('terminal-history')) { - return ['broken', 'healthy'] - } - return ['meta.json'] - }) - statSyncMock.mockReturnValue({ isDirectory: () => true, size: 100 }) - readFileSyncMock.mockReturnValue( - JSON.stringify({ - worktreeId: 'orphan', - createdAt: new Date(Date.now() - 10 * 60 * 1000).toISOString() - }) - ) - renameSyncMock.mockImplementationOnce(() => { - throw new Error('busy') - }) - - expect(() => runHistoryGc(new Set())).not.toThrow() - expect(renameSyncMock).toHaveBeenCalledTimes(2) - expect(rmAsyncMock).toHaveBeenCalledTimes(1) - await flushPendingWorktreeHistoryDeletions() - }) - it('skips recently-created directories to avoid TOCTOU race', () => { existsSyncMock.mockImplementation((p: string) => { if (p.includes('terminal-history-wsl')) { @@ -700,15 +417,14 @@ describe('terminal-history', () => { it('does not throw when history root does not exist', () => { existsSyncMock.mockReturnValue(false) expect(() => runHistoryGc(new Set())).not.toThrow() - expect(readdirSyncMock).not.toHaveBeenCalledWith('/fake/userData/terminal-history') + expect(readdirSyncMock).not.toHaveBeenCalled() }) it('drains delete tombstones asynchronously instead of scanning them as worktrees', async () => { - let tombstonePresent = true existsSyncMock.mockImplementation((p: string) => !String(p).includes('terminal-history-wsl')) readdirSyncMock.mockImplementation((dir: string) => { if (String(dir).endsWith('.pending-delete')) { - return tombstonePresent ? ['abc123.1700000000000.deadbeef'] : [] + return ['abc123.1700000000000.deadbeef'] } if (String(dir).endsWith('terminal-history')) { return ['.pending-delete'] @@ -716,9 +432,6 @@ describe('terminal-history', () => { return ['meta.json'] }) statSyncMock.mockReturnValue({ isDirectory: () => true, size: 100 }) - rmAsyncMock.mockImplementation(async () => { - tombstonePresent = false - }) runHistoryGc(new Set()) diff --git a/src/main/terminal-history.ts b/src/main/terminal-history.ts index c15f903c2de..38dd7968264 100644 --- a/src/main/terminal-history.ts +++ b/src/main/terminal-history.ts @@ -1,18 +1,10 @@ import { join, basename } from 'node:path' -import { mkdirSync, existsSync, readFileSync, statSync, writeFileSync } from 'node:fs' -import { - fishHistorySessionName, - isSafeFishHistorySession, - resolveFishHistoryDir -} from './fish-history-session' +import { mkdirSync, existsSync, writeFileSync } from 'node:fs' import { parseWslPath, toLinuxPath } from './wsl' -import { getHistoryRoot, getHistoryRootWsl } from './terminal-history-paths' -import { hashWorktreeId } from './terminal-history-id' +import { getHistoryRoot, getHistoryRootWsl, hashWorktreeId } from './terminal-history-paths' type ShellKind = 'zsh' | 'bash' | 'fish' | 'pwsh' | 'powershell' | 'cmd' | 'unknown' -export const MAX_HISTORY_META_BYTES = 32 * 1024 - // ─── Shell Detection ─────────────────────────────────────────────── /** Resolve the shell kind from a shell binary path. @@ -41,16 +33,14 @@ export function resolveShellKind(shellPath: string): ShellKind { return 'unknown' } -/** Map shell kind to the filename used inside the history directory. - * fish is absent on purpose: it ignores HISTFILE and keeps history in its own - * data dir keyed by session name (see fish-history-session.ts). */ +/** Map shell kind to the filename used inside the history directory. */ function historyFilename(shell: ShellKind): string | null { switch (shell) { case 'zsh': return 'zsh_history' case 'bash': return 'bash_history' - // Phase 2: PowerShell and cmd use different mechanisms + // Phase 2: fish and PowerShell use different mechanisms case 'fish': case 'pwsh': case 'powershell': @@ -78,81 +68,17 @@ export function ensureHistoryDir(worktreeHash: string, wslDistro?: string): stri } } -/** Write meta.json alongside history files, for debuggability and for GC. - * `fishSession` is load-bearing, not diagnostic: fish history lives outside - * this directory, so deletion can only find it by the name recorded here. - * `fishHistoryDir` is resolved from the SPAWN env, which is the one fish - * follows — this process's own may differ. */ -function writeMetaFile( - dir: string, - worktreeId: string, - fish?: { session: string; historyDir?: string } -): void { +/** Write meta.json alongside history files for debuggability. */ +function writeMetaFile(dir: string, worktreeId: string): void { try { const metaPath = join(dir, 'meta.json') - const existing = existsSync(metaPath) ? readHistoryMeta(dir) : null - if ( - existing && - (!fish || - (existing.fishSession === fish.session && existing.fishHistoryDir === fish.historyDir)) - ) { - return - } - writeFileSync( - metaPath, - JSON.stringify({ - worktreeId, - createdAt: existing?.createdAt ?? new Date().toISOString(), - ...(fish ? { fishSession: fish.session } : {}), - ...(fish?.historyDir ? { fishHistoryDir: fish.historyDir } : {}) - }), - { mode: 0o600 } - ) - } catch { - // Non-fatal — a missing meta.json only costs GC attribution. - } -} - -export type HistoryDirMeta = { - worktreeId?: string - createdAt?: string - /** fish session name whose history file lives in the user's fish data dir. */ - fishSession?: string - /** Directory that session's history file was written to, as the PTY saw it. */ - fishHistoryDir?: string -} - -/** Read one history directory's meta.json, or null when it is absent or unparseable. */ -export function readHistoryMeta(dir: string): HistoryDirMeta | null { - try { - const metaPath = join(dir, 'meta.json') - if (statSync(metaPath).size > MAX_HISTORY_META_BYTES) { - return null - } - const raw: unknown = JSON.parse(readFileSync(metaPath, 'utf-8')) - if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { - return null - } - const record = raw as Record - // Why re-derive: the session name is a pure function of this directory's own - // hash, so a meta.json naming someone else's session cannot steer deletion. - const expectedFishSession = fishHistorySessionName(basename(dir).split('.')[0]) - const fishSession = - isSafeFishHistorySession(record.fishSession) && record.fishSession === expectedFishSession - ? record.fishSession - : undefined - const fishHistoryDir = - fishSession && typeof record.fishHistoryDir === 'string' && record.fishHistoryDir - ? record.fishHistoryDir - : undefined - return { - ...(typeof record.worktreeId === 'string' ? { worktreeId: record.worktreeId } : {}), - ...(typeof record.createdAt === 'string' ? { createdAt: record.createdAt } : {}), - ...(fishSession ? { fishSession } : {}), - ...(fishHistoryDir ? { fishHistoryDir } : {}) + if (!existsSync(metaPath)) { + writeFileSync(metaPath, JSON.stringify({ worktreeId, createdAt: new Date().toISOString() }), { + mode: 0o600 + }) } } catch { - return null + // Non-fatal — meta.json is purely for diagnostics. } } @@ -161,10 +87,6 @@ export function readHistoryMeta(dir: string): HistoryDirMeta | null { export type HistoryInjectionResult = { shell: ShellKind histFile: string | null - /** fish session name exported as `fish_history`; null when fish is not the shell. */ - fishSession: string | null - /** Worktree history dir as the spawned shell sees it (Linux-visible under WSL). */ - historyDir: string | null } /** Build shell-specific history env overrides for a PTY spawn. @@ -181,31 +103,18 @@ export function injectHistoryEnv( cwd: string, options: { wslDistro?: string | null } = {} ): HistoryInjectionResult { - // Why unconditionally first: ORCA_HISTFILE is Orca-owned, and an Orca PTY - // launched from inside another Orca PTY inherits the parent's. Left in place, - // the zsh wrapper would re-export a PREVIOUS worktree's history path into this - // shell — the cross-worktree leak this feature exists to prevent — and it would - // also override a caller-supplied HISTFILE on the early return below. - // Credit: caught by @innocarpe in #11146. - delete spawnEnv.ORCA_HISTFILE - const shell = resolveShellKind(shellPath) - const result: HistoryInjectionResult = { - shell, - histFile: null, - fishSession: null, - historyDir: null - } + const result: HistoryInjectionResult = { shell, histFile: null } const filename = historyFilename(shell) - if (!filename && shell !== 'fish') { - // Unknown shell or Phase 2 shell (pwsh, cmd) — leave unchanged. + if (!filename) { + // Unknown shell or Phase 2 shell (fish, pwsh, cmd) — leave unchanged. return result } - // Check-before-set: if the caller already provided the shell's history knob, - // preserve it. Same pattern Ghostty, Kitty, and VS Code use for HISTFILE (§6). - if (shell === 'fish' ? spawnEnv.fish_history : spawnEnv.HISTFILE) { + // Check-before-set: if the caller already provided HISTFILE, preserve it. + // This follows the pattern used by Ghostty, Kitty, and VS Code (§6). + if (spawnEnv.HISTFILE) { return result } @@ -221,92 +130,46 @@ export function injectHistoryEnv( return result } - if (!filename) { - // fish: the directory holds no history, only the meta.json that lets deletion - // find the session file fish keeps in its own data dir. fish never runs as the - // inner WSL shell, so histDir needs no /mnt conversion here. - const session = fishHistorySessionName(worktreeHash) - // Resolve from the SPAWN env: that is the XDG_DATA_HOME/HOME fish will see, - // which need not match the one this process was launched with. - writeMetaFile(histDir, worktreeId, { session, historyDir: resolveFishHistoryDir(spawnEnv) }) - spawnEnv.fish_history = session - result.fishSession = session - result.historyDir = histDir - return result - } - writeMetaFile(histDir, worktreeId) const histFilePath = join(histDir, filename) // For WSL, convert the Windows path to a Linux-visible path. spawnEnv.HISTFILE = wslDistro ? toLinuxPath(histFilePath) : histFilePath - // Why a second variable: macOS `/etc/zshrc` assigns HISTFILE unconditionally - // (`HISTFILE=${ZDOTDIR:-$HOME}/.zsh_history`) and runs before Orca's wrapper - // .zshrc, so by then the injected value is gone from HISTFILE itself. The - // wrapper restores it from here once the user's own config has loaded (#11044). - spawnEnv.ORCA_HISTFILE = spawnEnv.HISTFILE result.histFile = spawnEnv.HISTFILE - result.historyDir = spawnEnv.HISTFILE.replace(/[/\\][^/\\]+$/, '') return result } -/** WSL's outer executable hides the login shell, so carry both history knobs. */ -export function injectWslFishHistoryEnv( +/** Update HISTFILE in spawnEnv when shell fallback changes the shell kind. + * For example, if zsh fails and bash takes over, the HISTFILE should point + * to bash_history instead of zsh_history. */ +export function updateHistFileForFallback( spawnEnv: Record, - worktreeId: string, - wslDistro: string -): string | null { - if (spawnEnv.fish_history) { - return null - } - const worktreeHash = hashWorktreeId(worktreeId) - const historyDir = ensureHistoryDir(worktreeHash, wslDistro) - if (!historyDir) { - return null - } - const session = fishHistorySessionName(worktreeHash) - // Why no historyDir: this session's file lives inside the WSL distro, so a - // path resolved from THIS process's Windows environment names an unrelated - // host directory — which the host GC sweep would then scan. WSL cleanup goes - // through `deleteWslFishHistoryFile`, which resolves the path in the distro. - writeMetaFile(historyDir, worktreeId, { session }) - spawnEnv.fish_history = session - return session -} - -/** Re-point the history env when shell fallback changes the shell kind — e.g. zsh - * fails and bash takes over, so HISTFILE must name bash_history. A fish primary - * injected `fish_history` instead, which the fallback shell cannot use. */ -export function updateHistoryEnvForFallback( - spawnEnv: Record, - fallbackShellPath: string, - injected: HistoryInjectionResult + fallbackShellPath: string ): void { - // Only ever undo what this spawn injected; a caller-supplied value stays. - if (injected.fishSession && spawnEnv.fish_history === injected.fishSession) { - delete spawnEnv.fish_history - } - if (!injected.historyDir) { + if (!spawnEnv.HISTFILE) { return } - const newFilename = historyFilename(resolveShellKind(fallbackShellPath)) + const newShell = resolveShellKind(fallbackShellPath) + const newFilename = historyFilename(newShell) if (!newFilename) { - // Fallback to an unknown shell — drop the override so it uses its own default. + // Fallback to an unknown shell — remove HISTFILE override entirely + // so the shell uses its own default. delete spawnEnv.HISTFILE - delete spawnEnv.ORCA_HISTFILE return } - spawnEnv.HISTFILE = `${injected.historyDir}/${newFilename}` - spawnEnv.ORCA_HISTFILE = spawnEnv.HISTFILE + + // Replace the filename portion of the HISTFILE path. + const dir = spawnEnv.HISTFILE.replace(/[/\\][^/\\]+$/, '') + spawnEnv.HISTFILE = `${dir}/${newFilename}` } /** Log the history injection result for diagnostics. */ export function logHistoryInjection(worktreeId: string, result: HistoryInjectionResult): void { const truncatedId = worktreeId.length > 60 ? `${worktreeId.slice(0, 60)}...` : worktreeId console.log( - `[pty:history] worktreeId=${truncatedId} shell=${result.shell} histFile=${result.histFile ?? 'none'} fishSession=${result.fishSession ?? 'none'}` + `[pty:history] worktreeId=${truncatedId} shell=${result.shell} histFile=${result.histFile ?? 'none'}` ) } diff --git a/src/main/text-generation/commit-message-command-backslash-mode.test.ts b/src/main/text-generation/commit-message-command-backslash-mode.test.ts new file mode 100644 index 00000000000..db828261817 --- /dev/null +++ b/src/main/text-generation/commit-message-command-backslash-mode.test.ts @@ -0,0 +1,41 @@ +/** + * The decision that turns on literal-backslash parsing (#11375). + * + * Why its own suite: this is the only place that reads the platform, so it is + * where the fix can be wrong in the direction that matters — applying Windows + * rules to a command that will actually run under a POSIX shell. + */ +import { describe, expect, it } from 'vitest' +import { commandBackslashMode } from './commit-message-text-generation' + +const LOCAL = { kind: 'local' as const, cwd: 'C:\\repo' } +const REMOTE = { + kind: 'remote' as const, + cwd: '/repo', + execute: async () => ({ ok: true }) as never, + missingBinaryLocation: 'the remote host' +} + +describe('commandBackslashMode', () => { + it('reads backslashes literally only for a native Windows local target', () => { + expect(commandBackslashMode(LOCAL, 'win32')).toBe('literal') + }) + + it.each(['darwin', 'linux'])('keeps POSIX escaping on %s', (platform) => { + expect(commandBackslashMode(LOCAL, platform)).toBe('escape') + }) + + it('keeps POSIX escaping for a WSL target, which runs a Linux binary', () => { + expect(commandBackslashMode({ ...LOCAL, wslDistro: 'Ubuntu' }, 'win32')).toBe('escape') + }) + + it('keeps POSIX escaping for a remote target, whose platform we cannot see', () => { + // A Windows client driving a Linux host is the case this protects. + expect(commandBackslashMode(REMOTE, 'win32')).toBe('escape') + }) + + it('defaults to this process platform when none is given', () => { + const expected = process.platform === 'win32' ? 'literal' : 'escape' + expect(commandBackslashMode(LOCAL)).toBe(expected) + }) +}) diff --git a/src/main/text-generation/commit-message-text-generation.ts b/src/main/text-generation/commit-message-text-generation.ts index 655c6de0e7d..e4077b3bddf 100644 --- a/src/main/text-generation/commit-message-text-generation.ts +++ b/src/main/text-generation/commit-message-text-generation.ts @@ -43,6 +43,7 @@ import { planCommitMessageGeneration, type CommitMessagePlan } from '../../shared/commit-message-plan' +import type { CommandTemplateBackslash } from '../../shared/commit-message-prompt' import { LOCAL_COMMIT_MESSAGE_HOST_KEY } from '../../shared/commit-message-host-key' import { resolveSourceControlAiForOperation, @@ -295,13 +296,14 @@ function finalizeModelDiscoveryOutput( function planModelDiscovery( spec: AgentModelProbeSpec, - agentCommandOverride?: string + agentCommandOverride?: string, + backslash: CommandTemplateBackslash = 'escape' ): { ok: true; plan: CommitMessagePlan } | { ok: false; error: string } { const modelDiscovery = spec.modelDiscovery if (!modelDiscovery) { return { ok: false, error: `${spec.label} does not support dynamic model discovery.` } } - const command = planAgentBinary(modelDiscovery.binary, agentCommandOverride) + const command = planAgentBinary(modelDiscovery.binary, agentCommandOverride, backslash) if (!command.ok) { return command } @@ -341,7 +343,15 @@ export async function discoverCommitMessageModelsLocal( const spawnEnv = env ?? process.env let discoveryStdin: string | null = null try { - const planned = planModelDiscovery(spec, agentCommandOverride) + const planned = planModelDiscovery( + spec, + agentCommandOverride, + commandBackslashMode({ + kind: 'local', + cwd: options.cwd ?? '', + wslDistro: options.wslDistro + }) + ) if (!planned.ok) { markProcessClosed() resolve({ success: false, error: planned.error }) @@ -823,6 +833,21 @@ function runLocalPlan( return { result, processClosed } } +/** + * How the user's command override should read `\`. + * + * `'literal'` only when the command provably runs on native Windows: a LOCAL + * target, on win32, with no WSL distro. A WSL target runs a Linux binary inside + * the distro, and a remote target runs on a host whose platform this process + * cannot see — POSIX escaping stays the default for both. + */ +export function commandBackslashMode( + target: CommitMessageGenerationTarget, + platform: NodeJS.Platform = process.platform +): CommandTemplateBackslash { + return platform === 'win32' && target.kind === 'local' && !target.wslDistro ? 'literal' : 'escape' +} + type LocalGenerationTarget = Extract function runLocalPlanForAgent( @@ -1101,7 +1126,10 @@ export async function generateCommitMessageFromContext( linkedIssue: formatLinkedIssueTemplateValue(context.linkedIssue) }) : buildCommitMessagePrompt(context, params.customPrompt ?? '') - const planned = planCommitMessageGeneration(params, prompt) + const planned = planCommitMessageGeneration( + { ...params, backslash: commandBackslashMode(target) }, + prompt + ) if (!planned.ok) { return { success: false, error: planned.error } } @@ -1173,7 +1201,10 @@ export async function generatePullRequestFieldsFromContext( linkedIssue: formatLinkedIssueTemplateValue(context.linkedIssue) }) : buildPullRequestFieldsPrompt(context, params.customPrompt ?? '') - const planned = planCommitMessageGeneration(params, prompt) + const planned = planCommitMessageGeneration( + { ...params, backslash: commandBackslashMode(target) }, + prompt + ) if (!planned.ok) { return { success: false, @@ -1223,7 +1254,10 @@ export async function generateBranchNameFromContext( assistantMessage: context.assistantMessage ?? '' }) : buildBranchNamePrompt(context, params.customPrompt ?? '') - const planned = planCommitMessageGeneration(params, prompt) + const planned = planCommitMessageGeneration( + { ...params, backslash: commandBackslashMode(target) }, + prompt + ) if (!planned.ok) { return { success: false, error: planned.error } } diff --git a/src/main/wsl-fish-history-cleanup.test.ts b/src/main/wsl-fish-history-cleanup.test.ts deleted file mode 100644 index 3893093c01a..00000000000 --- a/src/main/wsl-fish-history-cleanup.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - __resetWslFishHistoryCleanups, - deleteWslFishHistoryFile, - flushWslFishHistoryCleanups -} from './wsl-fish-history-cleanup' - -// Why reset on BOTH sides: the cleanup queue is module state, so one test's -// pending work would otherwise serialize behind — or deadlock — the next. -beforeEach(() => { - __resetWslFishHistoryCleanups() -}) - -afterEach(async () => { - await flushWslFishHistoryCleanups() - __resetWslFishHistoryCleanups() -}) - -describe('deleteWslFishHistoryFile', () => { - it('uses direct argv and bounds a distro cleanup subprocess', async () => { - const run = vi.fn().mockResolvedValue({ stdout: '', stderr: '' }) - - await deleteWslFishHistoryFile('Ubuntu Test', 'orca_0123456789abcdef', run) - - expect(run).toHaveBeenCalledWith( - 'wsl.exe', - [ - '--distribution', - 'Ubuntu Test', - '--exec', - 'fish', - '--command', - expect.stringContaining('orca_0123456789abcdef_history') - ], - { timeout: 5_000, windowsHide: true } - ) - }) - - it('rejects an unsafe session before spawning', async () => { - const run = vi.fn() - - await deleteWslFishHistoryFile('Ubuntu', '../../user-history', run) - - expect(run).not.toHaveBeenCalled() - }) - - it('coalesces concurrent requests for the same distro and session', async () => { - // Why: the GC sweep and an explicit worktree delete can queue the same - // tombstone, and one wsl.exe launch per cleanup is enough. - let settle!: () => void - const run = vi.fn().mockReturnValue( - new Promise((resolve) => { - settle = () => resolve({ stdout: '', stderr: '' }) - }) - ) - const session = 'orca_0123456789abcdef' - - const first = deleteWslFishHistoryFile('Ubuntu', session, run) - const second = deleteWslFishHistoryFile('Ubuntu', session, run) - - expect(second).toBe(first) - expect(run).toHaveBeenCalledTimes(1) - settle() - await Promise.all([first, second]) - }) - - - it('keeps distinct distros independent of each other', async () => { - const run = vi.fn().mockResolvedValue({ stdout: '', stderr: '' }) - - await Promise.all([ - deleteWslFishHistoryFile('Ubuntu', 'orca_0123456789abcdef', run), - deleteWslFishHistoryFile('Debian', 'orca_0123456789abcdef', run) - ]) - - expect(run).toHaveBeenCalledTimes(2) - }) - - it('permits a retry once a failed cleanup settles', async () => { - const run = vi.fn().mockRejectedValueOnce(new Error('distro offline')).mockResolvedValue({ - stdout: '', - stderr: '' - }) - const session = 'orca_0123456789abcdef' - - await expect(deleteWslFishHistoryFile('Ubuntu', session, run)).rejects.toThrow('distro offline') - await expect(deleteWslFishHistoryFile('Ubuntu', session, run)).resolves.toBeUndefined() - expect(run).toHaveBeenCalledTimes(2) - }) -}) diff --git a/src/main/wsl-fish-history-cleanup.ts b/src/main/wsl-fish-history-cleanup.ts deleted file mode 100644 index 18aebb650a2..00000000000 --- a/src/main/wsl-fish-history-cleanup.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { execFile } from 'node:child_process' -import { promisify } from 'node:util' -import { isSafeFishHistorySession } from './fish-history-session' - -const execFileAsync = promisify(execFile) - -/** Deduped by distro+session, so a rescan that re-queues the same tombstone - * joins the running call instead of spawning a second `wsl.exe`. - * - * Not otherwise rate-limited: the caller admits at most - * MAX_PENDING_HISTORY_TREE_REMOVALS tombstones at a time, so fanout is bounded - * but can still be dozens of `wsl.exe` launches during a startup GC drain on a - * machine with many dead WSL fish worktrees. Narrowing that belongs with the - * tombstone scheduler that owns the admission, not here. */ -const cleanupsInFlight = new Map>() - -/** Why run this INSIDE the distro: the history file lives on the distro's own - * filesystem under its `$XDG_DATA_HOME`/`$HOME`, which only a shell in there - * can resolve. `string match -qr '^/'` rejects a relative XDG_DATA_HOME the - * same way fish itself does before falling back. */ -function fishCleanupScript(session: string): string { - return [ - 'set -l data_home $XDG_DATA_HOME', - 'string match -qr "^/" -- $data_home; or set data_home "$HOME/.local/share"', - `command rm -f -- "$data_home/fish/${session}_history"` - ].join('; ') -} - -export function deleteWslFishHistoryFile( - distro: string, - session: string, - run: typeof execFileAsync = execFileAsync -): Promise { - if (!distro.trim() || !isSafeFishHistorySession(session)) { - return Promise.resolve() - } - const key = `${distro}\0${session}` - const existing = cleanupsInFlight.get(key) - if (existing) { - return existing - } - const cleanup = runCleanup(distro, session, run).finally(() => { - cleanupsInFlight.delete(key) - }) - cleanupsInFlight.set(key, cleanup) - return cleanup -} - -async function runCleanup( - distro: string, - session: string, - run: typeof execFileAsync -): Promise { - await run( - 'wsl.exe', - ['--distribution', distro, '--exec', 'fish', '--command', fishCleanupScript(session)], - { timeout: 5_000, windowsHide: true } - ) -} - -/** Test-only: drop in-flight state so one test's pending work cannot reach the next. */ -export function __resetWslFishHistoryCleanups(): void { - cleanupsInFlight.clear() -} - -/** Await every in-flight cleanup; for deterministic shutdown and tests. - * Why snapshot-and-drain rather than re-reading the map in the loop condition: - * an entry that has settled but whose `.finally` has not yet removed it would - * make `while (size > 0)` re-await a resolved promise forever, starving the - * event loop instead of returning. */ -export async function flushWslFishHistoryCleanups(): Promise { - const awaited = new Set>() - let pending = [...cleanupsInFlight.values()] - while (pending.length > 0) { - for (const cleanup of pending) { - awaited.add(cleanup) - } - await Promise.allSettled(pending) - pending = [...cleanupsInFlight.values()].filter((cleanup) => !awaited.has(cleanup)) - } -} diff --git a/src/main/zsh-scoped-histfile.live-shell.test.ts b/src/main/zsh-scoped-histfile.live-shell.test.ts deleted file mode 100644 index 98af8914ec6..00000000000 --- a/src/main/zsh-scoped-histfile.live-shell.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -/** - * Real-zsh proof that a worktree-scoped HISTFILE survives shell startup. - * - * macOS `/etc/zshrc` assigns `HISTFILE=${ZDOTDIR:-$HOME}/.zsh_history` with no - * check-before-set, and it runs before every wrapper file Orca controls. So the - * value `injectHistoryEnv` put in the spawn env is already gone by the time the - * user reaches a prompt — and because ZDOTDIR still points at Orca's wrapper - * dir, the replacement lands inside it. Per-worktree history was therefore a - * silent no-op on the primary platform (#11044). - * - * Only a real zsh can show this: the string the wrapper emits looks correct - * either way, and the whole bug lives in what /etc/zshrc does between the spawn - * env and the first prompt. - */ -import { execFileSync, spawnSync } from 'node:child_process' -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' -import { getZshShellReadyRcfileContent } from './providers/local-pty-shell-ready-wrapper-generation' -import { getZshEnvTemplate, ZSH_HISTFILE_RESTORE_BLOCK } from './shell-templates' - -// Why probe and execute the same binary: guarding on `zsh` from PATH but then -// running a hardcoded `/bin/zsh` lets the guard pass on a host that installs zsh -// elsewhere, and the test fails for a missing binary rather than a wrapper -// defect. The absolute path is resolved once so the sandboxed PATH below cannot -// lose it. -const hasZsh = process.platform !== 'win32' && spawnSync('zsh', ['--version']).status === 0 -const ZSH_PATH = hasZsh - ? (spawnSync('sh', ['-c', 'command -v zsh'], { encoding: 'utf8' }).stdout || '').trim() - : '' -const itWithZsh = hasZsh ? it : it.skip - -function runLoginZsh(home: string, zdotdir: string, env: Record): string { - // -o noglobalrcs is deliberately NOT passed: /etc/zshrc is the thing under test. - return execFileSync(ZSH_PATH, ['-li', '-c', 'echo "RESULT=$HISTFILE"'], { - encoding: 'utf8', - timeout: 20_000, - env: { - PATH: '/usr/bin:/bin', - HOME: home, - ZDOTDIR: zdotdir, - ORCA_ORIG_ZDOTDIR: home, - ORCA_ZSHENV_SOURCE_DIR: home, - ...env - } - }) -} - -describe('worktree-scoped HISTFILE survives zsh startup', () => { - const withWrapper = (run: (home: string, zdotdir: string) => void): void => { - const home = mkdtempSync(join(tmpdir(), 'orca-scoped-histfile-')) - const zdotdir = join(home, 'shell-ready', 'zsh') - mkdirSync(zdotdir, { recursive: true }) - writeFileSync(join(zdotdir, '.zshenv'), getZshEnvTemplate(zdotdir)) - writeFileSync(join(zdotdir, '.zshrc'), getZshShellReadyRcfileContent()) - writeFileSync(join(zdotdir, '.zlogin'), `${ZSH_HISTFILE_RESTORE_BLOCK}\n`) - try { - run(home, zdotdir) - } finally { - rmSync(home, { recursive: true, force: true }) - } - } - - itWithZsh('keeps the injected path that a system zshrc would otherwise clobber', () => { - withWrapper((home, zdotdir) => { - const scoped = join(home, 'orca-history', 'zsh_history') - - const output = runLoginZsh(home, zdotdir, { HISTFILE: scoped, ORCA_HISTFILE: scoped }) - - expect(output).toContain(`RESULT=${scoped}`) - }) - }) - - itWithZsh('never leaves history inside Orca’s own wrapper directory', () => { - withWrapper((home, zdotdir) => { - const scoped = join(home, 'orca-history', 'zsh_history') - - const output = runLoginZsh(home, zdotdir, { HISTFILE: scoped, ORCA_HISTFILE: scoped }) - - // The exact failure mode of #11044: history written into shell-ready/zsh. - expect(output).not.toContain(zdotdir) - }) - }) - - itWithZsh('leaves HISTFILE exactly as an unwrapped zsh would when Orca injects nothing', () => { - // Why compared against an unwrapped run rather than asserted non-empty: what - // zsh defaults to is platform-specific. macOS `/etc/zshrc` assigns HISTFILE, - // so it is always set there; a stock Ubuntu zsh has no such file and leaves - // it EMPTY. The contract is that Orca's wrapper does not change it either - // way, which is the same assertion on both. - withWrapper((home, zdotdir) => { - const wrapped = runLoginZsh(home, zdotdir, {}) - const unwrapped = execFileSync(ZSH_PATH, ['-li', '-c', 'echo "RESULT=$HISTFILE"'], { - encoding: 'utf8', - timeout: 20_000, - env: { PATH: '/usr/bin:/bin', HOME: home } - }) - - const histfileOf = (output: string): string => - /^RESULT=(.*)$/m.exec(output)?.[1]?.trim() ?? '' - expect(histfileOf(wrapped)).toBe(histfileOf(unwrapped)) - expect(wrapped).not.toContain('ORCA_HISTFILE') - }) - }) -}) diff --git a/src/preload/api/browser-api.ts b/src/preload/api/browser-api.ts index 6b352eff527..2810e8b35f2 100644 --- a/src/preload/api/browser-api.ts +++ b/src/preload/api/browser-api.ts @@ -1,4 +1,8 @@ import type { BrowserSetAnnotationViewportBridgeArgs } from '../../shared/browser-annotation-viewport-bridge' +import type { + BrowserWebAuthnAccountRequest, + BrowserWebAuthnAccountResponse +} from '../../shared/browser-webauthn-account' import type { BrowserSetGrabModeArgs, BrowserSetGrabModeResult, @@ -48,6 +52,11 @@ export type BrowserApi = { webContentsId: number }) => Promise unregisterGuest: (args: { browserPageId: string }) => Promise + onWebAuthnAccountRequest: ( + callback: (request: BrowserWebAuthnAccountRequest) => void + ) => () => void + onWebAuthnAccountRequestClosed: (callback: (event: { requestId: string }) => void) => () => void + respondWebAuthnAccount: (response: BrowserWebAuthnAccountResponse) => Promise openDevTools: (args: { browserPageId: string }) => Promise setViewportOverride: (args: { browserPageId: string diff --git a/src/preload/index.ts b/src/preload/index.ts index d6405f171be..2c7beed48a2 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -63,6 +63,10 @@ import type { import type { PluginConsentRequest } from '../shared/plugins/plugin-consent-request' import type { PluginChangeEvent } from '../shared/plugins/plugin-change-event' import type { BrowserViewportOverride } from '../shared/browser-workspace-types' +import type { + BrowserWebAuthnAccountRequest, + BrowserWebAuthnAccountResponse +} from '../shared/browser-webauthn-account' import type { SearchResult } from '../shared/code-search-types' import type { FilesystemPathFlavor, @@ -2691,6 +2695,29 @@ const api = { unregisterGuest: (args: { browserPageId: string }): Promise => ipcRenderer.invoke('browser:unregisterGuest', args), + onWebAuthnAccountRequest: ( + callback: (request: BrowserWebAuthnAccountRequest) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + request: BrowserWebAuthnAccountRequest + ): void => callback(request) + ipcRenderer.on('browser:webauthn-account-requested', listener) + return () => ipcRenderer.removeListener('browser:webauthn-account-requested', listener) + }, + + onWebAuthnAccountRequestClosed: ( + callback: (event: { requestId: string }) => void + ): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { requestId: string }): void => + callback(data) + ipcRenderer.on('browser:webauthn-account-request-closed', listener) + return () => ipcRenderer.removeListener('browser:webauthn-account-request-closed', listener) + }, + + respondWebAuthnAccount: (response: BrowserWebAuthnAccountResponse): Promise => + ipcRenderer.invoke('browser:respondWebAuthnAccount', response), + openDevTools: (args: { browserPageId: string }): Promise => ipcRenderer.invoke('browser:openDevTools', args), diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 16d256e72a9..c077378ebda 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -2,7 +2,7 @@ import type { IPty } from 'node-pty' import type * as NodePty from 'node-pty' import { existsSync } from 'node:fs' -import { basename, join } from 'node:path' +import { join } from 'node:path' import { randomUUID } from 'node:crypto' import { resolveWindowsGitBashShellPath } from '../main/git-bash' import { WINDOWS_GIT_BASH_SHELL } from '../shared/windows-terminal-shell' @@ -79,12 +79,6 @@ import { type AgentSessionOwnerBinding } from '../shared/agent-session-host-authority' import { createPtySlaveEchoProbe, readPtySlavePath } from '../shared/pty-slave-line-discipline-echo' -import { - deleteRelayFishHistory, - deleteRelayHistory, - injectRelayFishHistoryEnv, - injectRelayHistoryEnv -} from './terminal-history' // Why: only Linux compiles node-pty (no prebuilt), so the build-tools remedy is a closable setup gap // there and wrong advice anywhere node-pty ships one. The relay only sees an unloadable binding, never @@ -164,7 +158,6 @@ type ManagedPty = { shellPathEnv?: string envToDelete: string[] gitCredentialPromptGuarded: boolean - historyIsolationEnabled?: boolean startupCommand?: ManagedStartupCommand physicalExit?: PhysicalExitTracker forceKillSent?: boolean @@ -334,8 +327,6 @@ type SerializedPtyEntry = { envToDelete?: string[] /** Optional for state serialized by relays predating the credential guard. */ gitCredentialPromptGuarded?: boolean - /** Optional for state serialized by relays predating scoped history. */ - historyIsolationEnabled?: boolean agentSessionOwners?: AgentSessionOwnerBinding[] } @@ -869,13 +860,6 @@ export class PtyHandler { this.dispatcher.onRequest('pty.serialize', (p) => this.serialize(p)) this.dispatcher.onRequest('pty.revive', (p) => this.revive(p)) this.dispatcher.onRequest('pty.getProfiles', async () => listShellProfiles()) - this.dispatcher.onRequest('pty.deleteWorktreeHistory', async (p) => { - if (typeof p.worktreeId === 'string') { - deleteRelayFishHistory(p.worktreeId) - deleteRelayHistory(p.worktreeId) - } - return { ok: true } - }) this.dispatcher.onRequest('pty.closeStartupQueryAuthority', (p) => this.closeStartupQueryAuthority(p) ) @@ -1403,8 +1387,7 @@ export class PtyHandler { context?: RequestContext ): Promise { const env = params.env as Record | undefined - const worktreeId = - typeof params.worktreeId === 'string' ? params.worktreeId : env?.ORCA_WORKTREE_ID + const worktreeId = env?.ORCA_WORKTREE_ID const worktreePath = worktreeId ? splitWorktreeId(worktreeId)?.worktreePath : undefined const cwd = typeof params.cwd === 'string' ? params.cwd : resolveDefaultCwd() const finishCreation = this.beginPtyCreation([worktreePath, cwd]) @@ -1520,9 +1503,7 @@ export class PtyHandler { const shellOverride = typeof params.shellOverride === 'string' ? params.shellOverride.trim() : '' const resolvedShellOverride = resolvePtyShellOverride(shellOverride) - const requestedEnvShell = - process.platform !== 'win32' && typeof env?.SHELL === 'string' ? env.SHELL.trim() : '' - const shell = resolvedShellOverride || requestedEnvShell || resolveDefaultShell() + const shell = resolvedShellOverride || resolveDefaultShell() let id: string do { id = `pty-${this.nextId++}` @@ -1544,15 +1525,6 @@ export class PtyHandler { { id, paneKey, shell, command, launchAgent }, envToDelete ) - const worktreeId = - typeof params.worktreeId === 'string' ? params.worktreeId : env?.ORCA_WORKTREE_ID - const historyIsolationEnabled = params.historyIsolationEnabled === true - if (historyIsolationEnabled && worktreeId && basename(shell).toLowerCase().startsWith('fish')) { - injectRelayFishHistoryEnv(spawnEnv, worktreeId) - } - if (historyIsolationEnabled && worktreeId) { - injectRelayHistoryEnv(spawnEnv, worktreeId, shell) - } const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(spawnEnv, command) // Why: SSH PTYs bypass main's host-env builder, so apply the guard after the relay merges its authoritative env. const gitCredentialPromptGuarded = applyTerminalGitCredentialPromptGuard(spawnEnv, { @@ -1619,6 +1591,7 @@ export class PtyHandler { paneKey: typeof params.paneKey === 'string' ? params.paneKey : paneKey, tabId: typeof params.tabId === 'string' ? params.tabId : tabId } + const worktreeId = typeof env?.ORCA_WORKTREE_ID === 'string' ? env.ORCA_WORKTREE_ID : undefined const startupIngressIntent = params.startupIngressVersion === PTY_STARTUP_INGRESS_VERSION ? parsePtyStartupIngressIntent(params.startupIngress) @@ -1639,7 +1612,6 @@ export class PtyHandler { ...(explicitTerm !== undefined ? { explicitTerm } : {}), envToDelete, gitCredentialPromptGuarded, - ...(historyIsolationEnabled ? { historyIsolationEnabled: true } : {}), shellPath: shell, shellCwd: cwd, shellPathEnv: spawnEnv.PATH, @@ -2062,7 +2034,6 @@ export class PtyHandler { ...(managed.explicitTerm !== undefined ? { explicitTerm: managed.explicitTerm } : {}), envToDelete: managed.envToDelete, gitCredentialPromptGuarded: managed.gitCredentialPromptGuarded, - ...(managed.historyIsolationEnabled ? { historyIsolationEnabled: true } : {}), ...(managed.terminalHandle ? { terminalHandle: managed.terminalHandle } : {}) }) } @@ -2126,22 +2097,11 @@ export class PtyHandler { // Why: serialized state may come from an older/untrusted client; reapply fresh-spawn bounds. const envToDelete = sanitizeEnvToDelete(entry.envToDelete) const shell = resolveDefaultShell() - const historyIsolationEnabled = entry.historyIsolationEnabled === true const spawnEnv = this.buildSpawnEnv( revivedEnv, { id: entry.id, paneKey: entry.paneKey, shell }, envToDelete ) - if ( - historyIsolationEnabled && - entry.worktreeId && - basename(shell).toLowerCase().startsWith('fish') - ) { - injectRelayFishHistoryEnv(spawnEnv, entry.worktreeId) - } - if (historyIsolationEnabled && entry.worktreeId) { - injectRelayHistoryEnv(spawnEnv, entry.worktreeId, shell) - } // Why: revive lacks the original launch command, so reuse the fresh-spawn guard decision (legacy defaults to unguarded). const gitCredentialPromptGuarded = entry.gitCredentialPromptGuarded === true if (gitCredentialPromptGuarded) { @@ -2177,7 +2137,6 @@ export class PtyHandler { ...(explicitTerm !== undefined ? { explicitTerm } : {}), envToDelete, gitCredentialPromptGuarded, - ...(historyIsolationEnabled ? { historyIsolationEnabled: true } : {}), ownerBackend: resolvePtyOwnerBackend({ platform: process.platform, shellPath: shell diff --git a/src/relay/pty-shell-launch.ts b/src/relay/pty-shell-launch.ts index 1af77e87b7c..eaa9ac15e4c 100644 --- a/src/relay/pty-shell-launch.ts +++ b/src/relay/pty-shell-launch.ts @@ -1,14 +1,28 @@ +import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' -import { join } from 'node:path' -import { ensureOverlayRestoreWrappers } from './pty-shell-overlay-wrappers' +import { dirname, join } from 'node:path' +import { getPosixOmpShellWrapper } from '../main/pty/omp-shell-wrapper' +import { + BASH_PROMPT_COMMAND_COMPOSITION_BLOCK, + getZshFinalZdotdirRestoreBlock, + getZshShellReadyMarkerRegistrationBlock, + SHELL_STARTUP_IDENTITY_MARKER_BLOCK, + getZshStartupFileSourceBlock +} from '../main/shell-templates' + const RELAY_SHELL_READY_DIR = '.orca-relay/shell-ready' const POSIX_LOGIN_ARGS = ['-l'] +const SHELL_READY_MARKER_ESCAPED = '\\033]777;orca-shell-ready\\007' export type RelayShellLaunchConfig = { args: string[] env: Record } +function quotePosixSingle(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'` +} + function shellBasename(shellPath: string): string { return shellPath.replace(/\\/g, '/').split('/').pop()?.toLowerCase() ?? '' } @@ -67,6 +81,189 @@ function resolveOriginalZdotdir(env: Record): string { ) } +function ensureOverlayRestoreWrappers(root: string): void { + const zshDir = join(root, 'zsh') + const bashDir = join(root, 'bash') + + const zshEnv = `# Orca relay zsh overlay wrapper +${SHELL_STARTUP_IDENTITY_MARKER_BLOCK} +export ORCA_ORIG_ZDOTDIR="\${ORCA_ORIG_ZDOTDIR:-$HOME}" +case "\${ORCA_ORIG_ZDOTDIR%/}" in + */shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;; +esac +[[ -f "$ORCA_ORIG_ZDOTDIR/.zshenv" ]] && source "$ORCA_ORIG_ZDOTDIR/.zshenv" +export ORCA_USER_ZDOTDIR="\${ZDOTDIR:-\${ORCA_ORIG_ZDOTDIR:-$HOME}}" +case "\${ORCA_USER_ZDOTDIR%/}" in + */shell-ready/zsh) export ORCA_USER_ZDOTDIR="$HOME" ;; +esac +export ZDOTDIR=${quotePosixSingle(zshDir)} +` + const zshProfile = `# Orca relay zsh overlay wrapper +${getZshStartupFileSourceBlock({ + fileName: '.zprofile', + homeExpression: '"${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}"' +})} +` + const zshRc = `# Orca relay zsh overlay wrapper +${getZshStartupFileSourceBlock({ + fileName: '.zshrc', + homeExpression: '"${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}"', + interactiveOnly: true +})} +if [[ ! -o login ]]; then + # Why: remote startup files can re-export user defaults after relay spawn. + [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" + [[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" + [[ -n "\${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && case ":$PATH:" in *:"\${ORCA_REMOTE_CLI_BIN_DIR}":*) ;; *) export PATH="\${ORCA_REMOTE_CLI_BIN_DIR}:$PATH" ;; esac + ${getPosixOmpShellWrapper()} +fi +if [[ ! -o login ]]; then +${getZshFinalZdotdirRestoreBlock('"${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}"')} +fi +` + const zshLogin = `# Orca relay zsh overlay wrapper +${getZshStartupFileSourceBlock({ + fileName: '.zlogin', + homeExpression: '"${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}"', + interactiveOnly: true +})} +# Why: .zlogin is the final zsh login startup file before the prompt. +[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" +[[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" +[[ -n "\${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && case ":$PATH:" in *:"\${ORCA_REMOTE_CLI_BIN_DIR}":*) ;; *) export PATH="\${ORCA_REMOTE_CLI_BIN_DIR}:$PATH" ;; esac +${getPosixOmpShellWrapper()} +${getZshFinalZdotdirRestoreBlock('"${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}"')} +${getZshShellReadyMarkerRegistrationBlock(SHELL_READY_MARKER_ESCAPED)} +` + const bashRc = `# Orca relay bash overlay wrapper +${SHELL_STARTUP_IDENTITY_MARKER_BLOCK} +[[ -f /etc/profile ]] && source /etc/profile +if [[ -f "$HOME/.bash_profile" ]]; then + source "$HOME/.bash_profile" +elif [[ -f "$HOME/.bash_login" ]]; then + source "$HOME/.bash_login" +elif [[ -f "$HOME/.profile" ]]; then + source "$HOME/.profile" +fi +# Why: enable bracketed paste so Orca can deliver a multiline startup prompt as +# a single literal paste (ESC[200~…ESC[201~); without it, older readline builds +# treat each embedded newline as Enter and mangle the prompt into PS2 +# continuation. Modern readline defaults this on; force it for the rest. +[[ $- == *i* ]] && bind 'set enable-bracketed-paste on' 2>/dev/null +# Why: remote startup files can re-export user defaults after relay spawn. +[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" +[[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" +[[ -n "\${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && case ":$PATH:" in *:"\${ORCA_REMOTE_CLI_BIN_DIR}":*) ;; *) export PATH="\${ORCA_REMOTE_CLI_BIN_DIR}:$PATH" ;; esac +${getPosixOmpShellWrapper()} +# Why: SSH bash sessions need the same command lifecycle markers as local +# bash so agent rows stop showing "working" when the foreground command exits. +__orca_initializing_wrapper=1 +__orca_osc133_precmd() { + local exit_code=$? + __orca_in_prompt_command=1 + if [[ -n "\${__orca_in_command:-}" ]]; then + printf "\\033]133;D;%s\\007" "$exit_code" + unset __orca_in_command + fi + printf "\\033]133;A\\007" + return "$exit_code" +} +__orca_osc133_prompt_done() { + unset __orca_in_prompt_command; __orca_adopt_outer_debug_trap + trap '__orca_osc133_preexec' DEBUG +} +__orca_osc133_preexec() { + if [[ -n "\${__orca_prompt_status_capture_command:-}" && "$BASH_COMMAND" == "$__orca_prompt_status_capture_command" ]]; then + unset __orca_initial_prompt + __orca_in_legacy_prompt_wrapper=1 + return 0 + fi + if [[ -n "\${__orca_initializing_wrapper:-}\${__orca_in_debug_capture:-}\${__orca_initial_prompt:-}\${__orca_in_prompt_dispatch:-}\${__orca_in_legacy_prompt_wrapper:-}\${__orca_in_prompt_command:-}" ]]; then + [[ -z "\${__orca_initializing_wrapper:-}\${__orca_in_debug_capture:-}" ]] || return 0 + if [[ -n "\${__orca_initial_prompt:-}" && "$BASH_COMMAND" == "__orca_osc133_precmd" ]]; then + unset __orca_initial_prompt; return 0 + fi + if [[ -n "\${__orca_in_prompt_dispatch:-}" ]]; then + [[ -n "\${__orca_dispatching_user_prompt_command:-}" ]] || return 0 + if [[ "\${FUNCNAME[1]:-}" == "__orca_run_prompt_command_array" ]]; then + case "$BASH_COMMAND" in + '(( __orca_exit_code == 0 ))'|'__orca_restore_prompt_status "$__orca_exit_code"'|'eval "$__orca_prompt_part"'|'eval "$__orca_final_prompt_command"'|__orca_dispatching_user_prompt_command=*|__orca_osc133_precmd|__orca_osc133_prompt_done|__orca_prompt_mark) return 0 ;; + esac + fi + elif [[ "\${FUNCNAME[1]:-}" == "__orca_run_prompt_command_array" || "$BASH_COMMAND" == "__orca_run_prompt_command_array" ]]; then + return 0 + fi + [[ -z "\${__orca_in_legacy_prompt_wrapper:-}" || -n "\${__orca_dispatching_user_prompt_command:-}" ]] || return 0 + if [[ -n "\${__orca_in_prompt_command:-}" && "$BASH_COMMAND" == "__orca_in_debug_capture=1" ]]; then + return 0 + fi + fi + case "\${FUNCNAME[1]:-}" in __orca_osc133_*|__orca_prompt_mark|__orca_restore_prompt_status) return 0 ;; esac + case "$BASH_COMMAND" in __orca_osc133_precmd|__orca_osc133_prompt_done|__orca_prompt_mark) return 0 ;; esac + __orca_run_user_debug_trap + [[ -z "\${__orca_in_prompt_command:-}" ]] || return 0 + [[ -z "\${__orca_in_command:-}" ]] || return 0 + printf "\\033]133;C\\007" + __orca_in_command=1 +} +${BASH_PROMPT_COMMAND_COMPOSITION_BLOCK} +__orca_prepend_prompt_command "__orca_osc133_precmd" +# Why: SSH startup commands are renderer-delivered; emit the same internal +# readiness marker as local shells only when that delivery mode asks for it. +if [[ "\${ORCA_SHELL_READY_MARKER:-0}" == "1" ]]; then + __orca_prompt_mark() { + printf "${SHELL_READY_MARKER_ESCAPED}" + } + __orca_append_prompt_command "__orca_prompt_mark" +fi +__orca_append_prompt_command '__orca_in_debug_capture=1; __orca_prompt_had_functrace=""; if [[ -o functrace ]]; then __orca_prompt_had_functrace=1; set +T; fi; __orca_outer_debug_trap_spec="$(trap -p DEBUG)"; [[ -z "$__orca_prompt_had_functrace" ]] || set -T; unset __orca_prompt_had_functrace __orca_in_debug_capture' +__orca_append_prompt_command "__orca_osc133_prompt_done" +__orca_had_functrace="" +[[ -o functrace ]] && __orca_had_functrace=1 +set +T +__orca_debug_trap_spec="$(trap -p DEBUG)" +[[ -z "$__orca_had_functrace" ]] || set -T +if [[ -n "$__orca_debug_trap_spec" && "$__orca_debug_trap_spec" != "trap -- '__orca_osc133_preexec' DEBUG" ]]; then + __orca_debug_trap_command="\${__orca_debug_trap_spec#trap -- }" + __orca_debug_trap_command="\${__orca_debug_trap_command% DEBUG}" + eval "__orca_user_debug_trap=$__orca_debug_trap_command" +fi +unset __orca_debug_trap_spec __orca_debug_trap_command __orca_had_functrace +unset -f __orca_normalize_prompt_command_part __orca_normalize_prompt_command __orca_prepend_prompt_command __orca_append_prompt_command +unset __orca_prompt_command_normalized +# Why: arm DEBUG after wrapper setup so the relay rcfile itself does not emit +# fake command-start/end markers before the first prompt. +__orca_initial_prompt=1 +trap '__orca_osc133_preexec' DEBUG +unset __orca_initializing_wrapper +` + + const files = [ + [join(zshDir, '.zshenv'), zshEnv], + [join(zshDir, '.zprofile'), zshProfile], + [join(zshDir, '.zshrc'), zshRc], + [join(zshDir, '.zlogin'), zshLogin], + [join(bashDir, 'rcfile'), bashRc] + ] as const + + for (const [path, content] of files) { + mkdirSync(dirname(path), { recursive: true }) + let existing: string | null = null + try { + existing = readFileSync(path, 'utf8') + } catch { + existing = null + } + // Why: relay wrapper files persist under ~/.orca-relay across app + // upgrades. Existence alone is not enough; stale wrappers would miss + // later fixes such as preserving post-.zshenv ZDOTDIR. + if (existing !== content) { + writeFileSync(path, content, 'utf8') + } + chmodSync(path, 0o644) + } +} + export function getRelayShellLaunchConfig( shellPath: string, env: Record, diff --git a/src/relay/pty-shell-overlay-wrappers.ts b/src/relay/pty-shell-overlay-wrappers.ts deleted file mode 100644 index 1fa2e9b821a..00000000000 --- a/src/relay/pty-shell-overlay-wrappers.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' -import { dirname, join } from 'node:path' -import { getPosixOmpShellWrapper } from '../main/pty/omp-shell-wrapper' -import { - BASH_PROMPT_COMMAND_COMPOSITION_BLOCK, - getZshFinalZdotdirRestoreBlock, - getZshShellReadyMarkerRegistrationBlock, - SHELL_STARTUP_IDENTITY_MARKER_BLOCK, - ZSH_HISTFILE_RESTORE_BLOCK, - getZshStartupFileSourceBlock -} from '../main/shell-templates' - -/** Writes the zsh/bash overlay wrapper files a relay-spawned shell sources. - * Split from pty-shell-launch.ts so the launch-config decisions stay readable - * next to each other rather than buried under ~150 lines of shell templates. */ - -const SHELL_READY_MARKER_ESCAPED = '\\033]777;orca-shell-ready\\007' - -function quotePosixSingle(value: string): string { - return `'${value.replace(/'/g, `'\\''`)}'` -} - -export function ensureOverlayRestoreWrappers(root: string): void { - const zshDir = join(root, 'zsh') - const bashDir = join(root, 'bash') - - const zshEnv = `# Orca relay zsh overlay wrapper -${SHELL_STARTUP_IDENTITY_MARKER_BLOCK} -export ORCA_ORIG_ZDOTDIR="\${ORCA_ORIG_ZDOTDIR:-$HOME}" -case "\${ORCA_ORIG_ZDOTDIR%/}" in - */shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;; -esac -[[ -f "$ORCA_ORIG_ZDOTDIR/.zshenv" ]] && source "$ORCA_ORIG_ZDOTDIR/.zshenv" -export ORCA_USER_ZDOTDIR="\${ZDOTDIR:-\${ORCA_ORIG_ZDOTDIR:-$HOME}}" -case "\${ORCA_USER_ZDOTDIR%/}" in - */shell-ready/zsh) export ORCA_USER_ZDOTDIR="$HOME" ;; -esac -export ZDOTDIR=${quotePosixSingle(zshDir)} -` - const zshProfile = `# Orca relay zsh overlay wrapper -${getZshStartupFileSourceBlock({ - fileName: '.zprofile', - homeExpression: '"${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}"' -})} -` - const zshRc = `# Orca relay zsh overlay wrapper -${getZshStartupFileSourceBlock({ - fileName: '.zshrc', - homeExpression: '"${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}"', - interactiveOnly: true -})} -if [[ ! -o login ]]; then - # Why: remote startup files can re-export user defaults after relay spawn. - [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" - [[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" - [[ -n "\${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && case ":$PATH:" in *:"\${ORCA_REMOTE_CLI_BIN_DIR}":*) ;; *) export PATH="\${ORCA_REMOTE_CLI_BIN_DIR}:$PATH" ;; esac - ${getPosixOmpShellWrapper()} -${ZSH_HISTFILE_RESTORE_BLOCK} -fi -if [[ ! -o login ]]; then -${getZshFinalZdotdirRestoreBlock('"${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}"')} -fi -` - const zshLogin = `# Orca relay zsh overlay wrapper -${getZshStartupFileSourceBlock({ - fileName: '.zlogin', - homeExpression: '"${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}"', - interactiveOnly: true -})} -# Why: .zlogin is the final zsh login startup file before the prompt. -[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" -[[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" -[[ -n "\${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && case ":$PATH:" in *:"\${ORCA_REMOTE_CLI_BIN_DIR}":*) ;; *) export PATH="\${ORCA_REMOTE_CLI_BIN_DIR}:$PATH" ;; esac -${getPosixOmpShellWrapper()} -${ZSH_HISTFILE_RESTORE_BLOCK} -${getZshFinalZdotdirRestoreBlock('"${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}"')} -${getZshShellReadyMarkerRegistrationBlock(SHELL_READY_MARKER_ESCAPED)} -` - const bashRc = `# Orca relay bash overlay wrapper -${SHELL_STARTUP_IDENTITY_MARKER_BLOCK} -[[ -f /etc/profile ]] && source /etc/profile -if [[ -f "$HOME/.bash_profile" ]]; then - source "$HOME/.bash_profile" -elif [[ -f "$HOME/.bash_login" ]]; then - source "$HOME/.bash_login" -elif [[ -f "$HOME/.profile" ]]; then - source "$HOME/.profile" -fi -# Why: enable bracketed paste so Orca can deliver a multiline startup prompt as -# a single literal paste (ESC[200~…ESC[201~); without it, older readline builds -# treat each embedded newline as Enter and mangle the prompt into PS2 -# continuation. Modern readline defaults this on; force it for the rest. -[[ $- == *i* ]] && bind 'set enable-bracketed-paste on' 2>/dev/null -# Why: remote startup files can re-export user defaults after relay spawn. -[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" -[[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" -[[ -n "\${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && case ":$PATH:" in *:"\${ORCA_REMOTE_CLI_BIN_DIR}":*) ;; *) export PATH="\${ORCA_REMOTE_CLI_BIN_DIR}:$PATH" ;; esac -${getPosixOmpShellWrapper()} -${ZSH_HISTFILE_RESTORE_BLOCK} -# Why: SSH bash sessions need the same command lifecycle markers as local -# bash so agent rows stop showing "working" when the foreground command exits. -__orca_initializing_wrapper=1 -__orca_osc133_precmd() { - local exit_code=$? - __orca_in_prompt_command=1 - if [[ -n "\${__orca_in_command:-}" ]]; then - printf "\\033]133;D;%s\\007" "$exit_code" - unset __orca_in_command - fi - printf "\\033]133;A\\007" - return "$exit_code" -} -__orca_osc133_prompt_done() { - unset __orca_in_prompt_command; __orca_adopt_outer_debug_trap - trap '__orca_osc133_preexec' DEBUG -} -__orca_osc133_preexec() { - if [[ -n "\${__orca_prompt_status_capture_command:-}" && "$BASH_COMMAND" == "$__orca_prompt_status_capture_command" ]]; then - unset __orca_initial_prompt - __orca_in_legacy_prompt_wrapper=1 - return 0 - fi - if [[ -n "\${__orca_initializing_wrapper:-}\${__orca_in_debug_capture:-}\${__orca_initial_prompt:-}\${__orca_in_prompt_dispatch:-}\${__orca_in_legacy_prompt_wrapper:-}\${__orca_in_prompt_command:-}" ]]; then - [[ -z "\${__orca_initializing_wrapper:-}\${__orca_in_debug_capture:-}" ]] || return 0 - if [[ -n "\${__orca_initial_prompt:-}" && "$BASH_COMMAND" == "__orca_osc133_precmd" ]]; then - unset __orca_initial_prompt; return 0 - fi - if [[ -n "\${__orca_in_prompt_dispatch:-}" ]]; then - [[ -n "\${__orca_dispatching_user_prompt_command:-}" ]] || return 0 - if [[ "\${FUNCNAME[1]:-}" == "__orca_run_prompt_command_array" ]]; then - case "$BASH_COMMAND" in - '(( __orca_exit_code == 0 ))'|'__orca_restore_prompt_status "$__orca_exit_code"'|'eval "$__orca_prompt_part"'|'eval "$__orca_final_prompt_command"'|__orca_dispatching_user_prompt_command=*|__orca_osc133_precmd|__orca_osc133_prompt_done|__orca_prompt_mark) return 0 ;; - esac - fi - elif [[ "\${FUNCNAME[1]:-}" == "__orca_run_prompt_command_array" || "$BASH_COMMAND" == "__orca_run_prompt_command_array" ]]; then - return 0 - fi - [[ -z "\${__orca_in_legacy_prompt_wrapper:-}" || -n "\${__orca_dispatching_user_prompt_command:-}" ]] || return 0 - if [[ -n "\${__orca_in_prompt_command:-}" && "$BASH_COMMAND" == "__orca_in_debug_capture=1" ]]; then - return 0 - fi - fi - case "\${FUNCNAME[1]:-}" in __orca_osc133_*|__orca_prompt_mark|__orca_restore_prompt_status) return 0 ;; esac - case "$BASH_COMMAND" in __orca_osc133_precmd|__orca_osc133_prompt_done|__orca_prompt_mark) return 0 ;; esac - __orca_run_user_debug_trap - [[ -z "\${__orca_in_prompt_command:-}" ]] || return 0 - [[ -z "\${__orca_in_command:-}" ]] || return 0 - printf "\\033]133;C\\007" - __orca_in_command=1 -} -${BASH_PROMPT_COMMAND_COMPOSITION_BLOCK} -__orca_prepend_prompt_command "__orca_osc133_precmd" -# Why: SSH startup commands are renderer-delivered; emit the same internal -# readiness marker as local shells only when that delivery mode asks for it. -if [[ "\${ORCA_SHELL_READY_MARKER:-0}" == "1" ]]; then - __orca_prompt_mark() { - printf "${SHELL_READY_MARKER_ESCAPED}" - } - __orca_append_prompt_command "__orca_prompt_mark" -fi -__orca_append_prompt_command '__orca_in_debug_capture=1; __orca_prompt_had_functrace=""; if [[ -o functrace ]]; then __orca_prompt_had_functrace=1; set +T; fi; __orca_outer_debug_trap_spec="$(trap -p DEBUG)"; [[ -z "$__orca_prompt_had_functrace" ]] || set -T; unset __orca_prompt_had_functrace __orca_in_debug_capture' -__orca_append_prompt_command "__orca_osc133_prompt_done" -__orca_had_functrace="" -[[ -o functrace ]] && __orca_had_functrace=1 -set +T -__orca_debug_trap_spec="$(trap -p DEBUG)" -[[ -z "$__orca_had_functrace" ]] || set -T -if [[ -n "$__orca_debug_trap_spec" && "$__orca_debug_trap_spec" != "trap -- '__orca_osc133_preexec' DEBUG" ]]; then - __orca_debug_trap_command="\${__orca_debug_trap_spec#trap -- }" - __orca_debug_trap_command="\${__orca_debug_trap_command% DEBUG}" - eval "__orca_user_debug_trap=$__orca_debug_trap_command" -fi -unset __orca_debug_trap_spec __orca_debug_trap_command __orca_had_functrace -unset -f __orca_normalize_prompt_command_part __orca_normalize_prompt_command __orca_prepend_prompt_command __orca_append_prompt_command -unset __orca_prompt_command_normalized -# Why: arm DEBUG after wrapper setup so the relay rcfile itself does not emit -# fake command-start/end markers before the first prompt. -__orca_initial_prompt=1 -trap '__orca_osc133_preexec' DEBUG -unset __orca_initializing_wrapper -` - - const files = [ - [join(zshDir, '.zshenv'), zshEnv], - [join(zshDir, '.zprofile'), zshProfile], - [join(zshDir, '.zshrc'), zshRc], - [join(zshDir, '.zlogin'), zshLogin], - [join(bashDir, 'rcfile'), bashRc] - ] as const - - for (const [path, content] of files) { - mkdirSync(dirname(path), { recursive: true }) - let existing: string | null = null - try { - existing = readFileSync(path, 'utf8') - } catch { - existing = null - } - // Why: relay wrapper files persist under ~/.orca-relay across app - // upgrades. Existence alone is not enough; stale wrappers would miss - // later fixes such as preserving post-.zshenv ZDOTDIR. - if (existing !== content) { - writeFileSync(path, content, 'utf8') - } - chmodSync(path, 0o644) - } -} diff --git a/src/relay/terminal-history.test.ts b/src/relay/terminal-history.test.ts deleted file mode 100644 index b6c59e332d3..00000000000 --- a/src/relay/terminal-history.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' -import { homedir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { hashWorktreeId } from '../main/terminal-history-id' -import { deleteRelayHistory, injectRelayHistoryEnv } from './terminal-history' - -const worktreeId = 'relay-test::/remote/worktree' -const historyDir = join(homedir(), '.orca-remote', 'terminal-history') -const historyPrefix = hashWorktreeId(worktreeId) - -afterEach(() => { - for (const filename of ['bash_history', 'zsh_history']) { - rmSync(join(historyDir, `${historyPrefix}-${filename}`), { force: true }) - } -}) - -describe('relay shell history', () => { - it.each(['/bin/bash', '/usr/bin/zsh'])('scopes %s without replacing caller HISTFILE', (shell) => { - const env: Record = {} - const dir = injectRelayHistoryEnv(env, worktreeId, shell) - expect(dir).toBe(historyDir) - expect(env.HISTFILE).toBe( - join( - historyDir, - `${historyPrefix}-${shell.endsWith('bash') ? 'bash_history' : 'zsh_history'}` - ) - ) - - const custom = { HISTFILE: '/custom/history' } - expect(injectRelayHistoryEnv(custom, worktreeId, shell)).toBeNull() - expect(custom.HISTFILE).toBe('/custom/history') - }) - - it('does not scope unsupported shells and cleans up idempotently', () => { - const env: Record = {} - expect(injectRelayHistoryEnv(env, worktreeId, '/bin/fish')).toBeNull() - deleteRelayHistory(worktreeId) - deleteRelayHistory(worktreeId) - expect(existsSync(join(historyDir, `${historyPrefix}-bash_history`))).toBe(false) - }) - - it.skipIf(process.platform === 'win32')('refuses a pre-existing final symlink', () => { - mkdirSync(historyDir, { recursive: true }) - const target = join(historyDir, 'relay-unrelated-history') - const path = join(historyDir, `${historyPrefix}-bash_history`) - writeFileSync(target, 'unrelated') - symlinkSync(target, path) - - expect(injectRelayHistoryEnv({}, worktreeId, '/bin/bash')).toBeNull() - expect(readFileSync(target, 'utf8')).toBe('unrelated') - rmSync(path) - rmSync(target) - }) -}) diff --git a/src/relay/terminal-history.ts b/src/relay/terminal-history.ts deleted file mode 100644 index 68c86737b41..00000000000 --- a/src/relay/terminal-history.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { - closeSync, - constants as fsConstants, - fstatSync, - lstatSync, - mkdirSync, - openSync, - unlinkSync -} from 'node:fs' -import { homedir } from 'node:os' -import { basename, join } from 'node:path' -import { hashWorktreeId } from '../main/terminal-history-id' -import { - deleteFishHistoryFile, - fishHistorySessionName, - resolveFishHistoryDir -} from '../main/fish-history-session' - -const HISTORY_ROOT = join(homedir(), '.orca-remote', 'terminal-history') - -function historyFilename(shell: string): string | null { - const name = basename(shell).toLowerCase() - if (name.startsWith('bash')) { - return 'bash_history' - } - if (name.startsWith('zsh')) { - return 'zsh_history' - } - return null -} - -export function injectRelayHistoryEnv( - env: Record, - worktreeId: string, - shell: string -): string | null { - // Why first: same reason as the desktop path — an inherited ORCA_HISTFILE - // would otherwise survive every early return below and let the remote wrapper - // re-export another worktree's history path. - delete env.ORCA_HISTFILE - if (env.HISTFILE) { - return null - } - const filename = historyFilename(shell) - if (!filename) { - return null - } - try { - mkdirSync(HISTORY_ROOT, { recursive: true, mode: 0o700 }) - const rootStat = lstatSync(HISTORY_ROOT) - if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) { - return null - } - const path = join(HISTORY_ROOT, `${hashWorktreeId(worktreeId)}-${filename}`) - let existing: ReturnType | null = null - try { - const stat = lstatSync(path, { bigint: true }) - if (stat.isSymbolicLink() || !stat.isFile()) { - return null - } - existing = stat - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - return null - } - } - const fd = openSync( - path, - fsConstants.O_RDWR | - fsConstants.O_CREAT | - (process.platform === 'win32' ? 0 : fsConstants.O_NOFOLLOW), - 0o600 - ) - const actual = fstatSync(fd, { bigint: true }) - if ( - !actual.isFile() || - (existing && (actual.dev !== existing.dev || actual.ino !== existing.ino)) - ) { - closeSync(fd) - return null - } - closeSync(fd) - env.HISTFILE = path - // Why a second variable: a remote macOS `/etc/zshrc` assigns HISTFILE - // unconditionally before the wrapper runs, so the injected value is gone by - // the first prompt. The wrapper restores it from here (#11044) — the same - // contract the desktop PTY path uses. - env.ORCA_HISTFILE = path - return HISTORY_ROOT - } catch { - return null - } -} - -export function deleteRelayHistory(worktreeId: string): void { - try { - const rootStat = lstatSync(HISTORY_ROOT) - if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) { - return - } - for (const filename of ['bash_history', 'zsh_history']) { - const path = join(HISTORY_ROOT, `${hashWorktreeId(worktreeId)}-${filename}`) - try { - if (!lstatSync(path).isSymbolicLink()) { - unlinkSync(path) - } - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - throw error - } - } - } - } catch (error) { - console.warn( - `[pty:history] Failed to delete relay shell history: ${error instanceof Error ? error.message : String(error)}` - ) - } -} - -/** fish keeps history in its own data dir under a session NAME, so the relay - * isolates it the same way the desktop app does and deletes by that name. - * No metadata file is needed: the name is a pure function of the worktree id. */ -export function injectRelayFishHistoryEnv(env: Record, worktreeId: string): void { - if (env.fish_history) { - return - } - env.fish_history = fishHistorySessionName(hashWorktreeId(worktreeId)) -} - -export function deleteRelayFishHistory(worktreeId: string, env?: NodeJS.ProcessEnv): void { - deleteFishHistoryFile(fishHistorySessionName(hashWorktreeId(worktreeId)), [ - resolveFishHistoryDir(env) - ]) -} diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index b586b424b35..b6dfd73718f 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -2,6 +2,7 @@ import { useCallback } from 'react' import { Toaster } from '@/components/ui/sonner' import { TooltipProvider } from '@/components/ui/tooltip' import { ConfirmationDialogProvider } from './components/confirmation-dialog' +import { BrowserWebAuthnAccountDialog } from './components/browser-webauthn-account-dialog' import { LinkRoutingPreferenceDialogProvider } from './components/link-routing-preference-dialog' import { SkillFreshnessNudge } from './components/skills/SkillFreshnessNudge' import PinnedTabCloseDialog from './components/terminal-pane/PinnedTabCloseDialog' @@ -75,6 +76,7 @@ function App(): React.JSX.Element { floatingWorkspace={floatingWorkspace} onboardingGate={onboardingGate} /> + diff --git a/src/renderer/src/assets/terminal-container-geometry.test.ts b/src/renderer/src/assets/terminal-container-geometry.test.ts index db079c9095f..5e2bfcc0258 100644 --- a/src/renderer/src/assets/terminal-container-geometry.test.ts +++ b/src/renderer/src/assets/terminal-container-geometry.test.ts @@ -5,9 +5,7 @@ const terminalCss = fs.readFileSync(new URL('./terminal.css', import.meta.url), describe('terminal container geometry', () => { it('keeps the hidden link tooltip out of the fitted terminal height', () => { - expect(terminalCss).toMatch( - /\.xterm-container\s*{[^}]*height:\s*calc\(100% - var\(--pane-padding-y, 4px\)\);/s - ) + expect(terminalCss).toMatch(/\.xterm-container\s*{[^}]*height:\s*100%;/s) expect(terminalCss).toMatch( /\.pane\[data-has-title\] \.xterm-container\s*{[^}]*height:\s*calc\(100% - var\(--orca-pane-title-height\)\);/s ) @@ -15,4 +13,30 @@ describe('terminal container geometry', () => { /\.pane-link-tooltip\s*{[^}]*height:\s*var\(--orca-terminal-link-tooltip-height\);/s ) }) + + it('insets the xterm grid on both axes from pane padding', () => { + expect(terminalCss).toMatch( + /\.xterm-container \.xterm\s*{[^}]*padding-top:\s*var\(--pane-padding-y, 4px\);/s + ) + expect(terminalCss).toMatch( + /\.xterm-container \.xterm\s*{[^}]*padding-right:\s*var\(--pane-padding-x, 4px\);/s + ) + expect(terminalCss).toMatch( + /\.xterm-container \.xterm\s*{[^}]*padding-bottom:\s*var\(--pane-padding-y, 4px\);/s + ) + expect(terminalCss).toMatch( + /\.xterm-container \.xterm\s*{[^}]*padding-left:\s*var\(--pane-padding-x, 4px\);/s + ) + expect(terminalCss).toMatch(/\.xterm-container\s*{[^}]*width:\s*100%;/s) + expect(terminalCss).not.toMatch(/\.xterm-container\s*{[^}]*margin-left:/s) + }) + + it('matches live DOM and WebGL background layers across translucent padding bands', () => { + expect(terminalCss).toMatch( + /\.xterm-container \.xterm::before\s*{[^}]*border-color:\s*var\(--orca-terminal-live-background, transparent\);/s + ) + expect(terminalCss).toMatch( + /\.xterm-container\[data-terminal-renderer='webgl'\] \.xterm::after\s*{[^}]*border-color:\s*var\(--orca-terminal-live-background, transparent\);/s + ) + }) }) diff --git a/src/renderer/src/assets/terminal.css b/src/renderer/src/assets/terminal.css index ee5e0af01f5..66a8a18828a 100644 --- a/src/renderer/src/assets/terminal.css +++ b/src/renderer/src/assets/terminal.css @@ -30,6 +30,42 @@ width: 100%; } +.xterm-container .xterm { + box-sizing: border-box; + height: 100%; + width: 100%; + /* Why on .xterm, not the container: FitAddon subtracts this element's + padding when proposing cols/rows, and the themed background (including + opacity) fills the inset. Container margins only framed the start edges + and stacked a darker gutter at fractional opacity. */ + padding-top: var(--pane-padding-y, 4px); + padding-right: var(--pane-padding-x, 4px); + padding-bottom: var(--pane-padding-y, 4px); + padding-left: var(--pane-padding-x, 4px); +} + +/* xterm paints the live viewport background again under the grid. Mirror that + layer in the padding bands, including runtime OSC 11 color changes. */ +.xterm-container .xterm::before, +.xterm-container[data-terminal-renderer='webgl'] .xterm::after { + box-sizing: border-box; + position: absolute; + inset: 0; + content: ''; + pointer-events: none; + border-style: solid; + border-width: var(--pane-padding-y, 4px) var(--pane-padding-x, 4px); +} + +.xterm-container .xterm::before { + border-color: var(--orca-terminal-live-background, transparent); +} + +/* WebGL adds one source-over background layer beyond the DOM renderer. */ +.xterm-container[data-terminal-renderer='webgl'] .xterm::after { + border-color: var(--orca-terminal-live-background, transparent); +} + .pane-manager-root .xterm-viewport { /* Why: xterm/VS Code keep the scrollbar gutter stable. `auto` lets Linux DOM scrollbars appear/disappear, which can feed resize/reflow jitter. */ @@ -177,7 +213,7 @@ .pane[data-terminal-attention] .xterm-container::after { content: ''; position: absolute; - inset: 0 4px 4px 0; + inset: 0; z-index: 5; pointer-events: none; border-radius: 2px; @@ -498,16 +534,14 @@ border-color: var(--ring); } -/* Baseline xterm-container layout — padding is driven by CSS custom properties - so Ghostty-imported window-padding-x/y can be applied without inline styles. - The fallback 4px preserves the legacy default. */ +/* Container fills the pane so the .xterm background (and its padding) is the + terminal surface. Title offset stays here because the title overlay is a + sibling of the container, not of .xterm. */ .xterm-container { box-sizing: border-box; position: relative; - width: calc(100% - var(--pane-padding-x, 4px)); - height: calc(100% - var(--pane-padding-y, 4px)); - margin-top: var(--pane-padding-y, 4px); - margin-left: var(--pane-padding-x, 4px); + width: 100%; + height: 100%; } /* When a pane has a title, shift the terminal content down to make room. diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index 5ed25ee81c3..fdea4edf9ed 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -1,6375 +1,7 @@ -/* eslint-disable max-lines -- Why: the GH item dialog keeps its header, conversation, files, and checks tabs co-located so the read-only PR/Issue surface stays in one place while this view evolves. */ -import React, { - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, - useSyncExternalStore -} from 'react' -import { useVirtualizer } from '@tanstack/react-virtual' -import { useShallow } from 'zustand/react/shallow' -import type { editor as monacoEditor } from 'monaco-editor' -import { - ArrowRight, - Ban, - Check, - CheckCircle2, - ChevronDown, - ChevronLeft, - ChevronRight, - CircleDashed, - CircleDot, - Copy, - ExternalLink, - FileText, - FolderKanban, - GitMerge, - GitPullRequest, - GitPullRequestClosed, - ListChecks, - Link2, - LoaderCircle, - MessageSquare, - MessageSquarePlus, - MoveRight, - PanelLeftOpen, - Pencil, - Plus, - RefreshCw, - Search, - Send, - Settings, - UserMinus, - UserPlus, - Wrench, - X -} from 'lucide-react' -import { toast } from 'sonner' -import { Button } from '@/components/ui/button' -import { ButtonGroup } from '@/components/ui/button-group' -import { Input } from '@/components/ui/input' -import { useMountedRef } from '@/hooks/useMountedRef' -import { useConfirmationDialog } from '@/components/confirmation-dialog-context' -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger -} from '@/components/ui/accordion' -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' -import CommentMarkdown from '@/components/sidebar/CommentMarkdown' -import { cn } from '@/lib/utils' -import { DiffSectionItem } from '@/components/editor/DiffSectionItem' -import type { DecoratedDiffComment } from '@/components/diff-comments/decorated-diff-comment' -import { - CombinedDiffFileTree, - createCombinedDiffSectionIndexMap, - handleCombinedDiffFileTreeNavigation -} from '@/components/editor/CombinedDiffFileTree' -import { - getDiffSectionEstimatedHeight, - isIntrinsicHeightImageDiff -} from '@/components/editor/diff-section-layout' -import type { DiffSection } from '@/components/editor/diff-section-types' -import { removeDiffSectionMeasuredHeight } from '@/components/editor/diff-section-height-cache' -import { - getCombinedDiffBranchEntriesInTreeOrder, - type CombinedDiffFileTreeEntry -} from '@/components/editor/combined-diff-file-tree-model' -import { - getStoredTextDiffContent, - getStoredTextDiffResult -} from '@/components/editor/large-diff-section-content' -import { CHECK_COLOR, CHECK_ICON } from '@/components/right-sidebar/checks-panel-content' -import { - beginGitHubChecksTabDetails, - createGitHubChecksTabState, - resetGitHubChecksTabForSource, - resolveGitHubChecksTabState, - settleGitHubChecksTabDetails, - toggleGitHubChecksTabExpandedKey, - updateGitHubChecksTabLocalChecks, - type CheckDetailsLoadState -} from '@/components/github-checks-tab-state' -import { - clearGitHubLinkCopied, - createGitHubLinkCopyState, - markGitHubLinkCopied, - resolveGitHubLinkCopyState -} from '@/components/github-link-copy-state' -import { - resolveGitHubBodyDraft, - shouldSyncGitHubBodyDraft -} from '@/components/github-body-draft-state' -import { - filterPRCommentsByAudience, - getPRCommentAudienceCounts, - type PRCommentAudienceFilter -} from '../../../shared/pr-comment-audience' -import { - getPRCommentAudienceEmptyLabel, - getPrCommentAudienceFilters -} from '@/lib/pr-comment-audience-labels' -import { usePRBotAuthorOverrides } from '@/lib/pr-bot-author-overrides' -import { - getPRCommentGroupCount, - getPRCommentGroupId, - getPRCommentGroupRoot, - groupPRComments, - isResolvedPRCommentGroup, - type PRCommentGroup -} from '../../../shared/pr-comment-groups' -import { - PR_COMMENT_OPEN_AUTHOR_CLASS, - PR_COMMENT_RESOLVED_AUTHOR_CLASS, - PR_COMMENT_RESOLVED_CONTAINER_CLASS -} from '@/lib/pr-comment-resolution-classes' -import { - getCommentReplyTargetCandidates, - resolveCommentReplyTarget -} from '@/components/comment-reply-target-state' -import { - attachPRReviewReplyParent, - canPostPRReviewThreadReply -} from '@/components/right-sidebar/pr-comments-ai-launch-ack' -import { buildPRCommentConversationReplyBody } from '@/components/right-sidebar/pr-comment-fixing-reply-body' -import { useAppStore } from '@/store' -import { useAllWorktrees } from '@/store/selectors' -import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' -import { withGitHubCheckDetailsTimeout } from '@/runtime/github-check-details-timeout' -import { useRepoLabels, useRepoAssignees, useImmediateMutation } from '@/hooks/useIssueMetadata' -import { useRepoLabelsBySlug, useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata' -import { GitHubMarkdownComposer } from '@/components/github/GitHubMarkdownComposer' -import { - getCommentBodySubmitState, - hasBoundedCommentBodyText -} from '@/lib/comment-body-submit-state' -import { onGitHubWorkItemDetailsCacheMutation } from '@/lib/github-work-item-details-cache-events' -import { lookupGitHubWorkItemDetailsForSource } from '@/lib/github-work-item-source-lookup' -import { - canUseGitHubRepoContext, - getGitHubMutationRoutingSettings, - getGitHubRuntimeRepoId, - getGitHubSourceRuntimeHost -} from '@/lib/github-source-runtime-context' -import IssueSourceIndicator, { sameGitHubOwnerRepo } from '@/components/github/IssueSourceIndicator' -import { - getGitHubPRReviewerRows, - normalizeGitHubReviewerLogins, - parseGitHubReviewerInputLogins -} from '@/components/github-pr-reviewer-display' -import { - filterGitHubPRReviewerCandidates, - getGitHubPRReviewerQueryState -} from '@/components/github/github-pr-reviewer-candidate-filter' -import { presentGitHubPRMergeState } from '@/components/github-pr-merge-state' -import { - GITHUB_PR_MERGE_METHOD_LABELS, - resolveGitHubPRMergeMethods -} from '../../../shared/github/pull-request-merge-methods' -import { githubRepoIdentityKey } from '../../../shared/github/repository-identity-key' -import { - findGithubIssueWorkspaceAttachment, - getGithubWorkItemWorkspaceAttachmentLabel -} from '@/lib/github-work-item-workspace-attachment' -import { startFixChecksAgent } from '@/lib/fix-checks-agent-launch' -import { activateAndRevealWorktree } from '@/lib/worktree-activation' -import { buildFixBrokenChecksPrompt, getBrokenChecks } from '@/components/pr-checks-fix-prompt' -import type { GitBranchChangeEntry, GitDiffResult } from '../../../shared/git-diff-compare-types' -import type { PRCheckDetail } from '../../../shared/github/check-types' -import type { - GitHubIssueTimelineItem, - GitHubIssueTimelineTarget, - PRComment -} from '../../../shared/github/comment-types' -import type { - GitHubAssignableUser, - GitHubOwnerRepo, - GitHubPRFile, - GitHubPRFileContents, - GitHubPRFileViewedState, - GitHubPRMergeMethod -} from '../../../shared/github/pull-request-types' -import type { GitHubWorkItem, GitHubWorkItemDetails } from '../../../shared/github/work-item-types' -import { - getTaskSourceCacheScope, - getTaskSourceRuntimeSettings, - type TaskSourceContext -} from '../../../shared/task-source-context' -import { PER_REPO_FETCH_LIMIT } from '../../../shared/work-items' -import { translate } from '@/i18n/i18n' -import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' -import { - buildTaskPageGitHubCloseUpdate, - getTaskPageGitHubDuplicateCandidates, - getTaskPageGitHubDuplicateTargetErrorMessage, - validateTaskPageGitHubDuplicateTarget, - type TaskPageGitHubCloseAction -} from '@/components/task-page-github-status-actions' -import { assertTaskPageGitHubDialogStateAuthority } from '@/components/task-page-github-dialog-state-authority' -import { sortChecksBySeverity } from '../../../shared/pr-check-severity-order' -import { - getCheckConclusion, - getCheckCountChips, - getCheckCounts, - getChecksSummaryLabel -} from '@/components/pr-check-counts' -import { - normalizeItemDialogTab, - parseOwnerRepoFromItemUrl, - resolvePullRequestRepo, - type GitHubWorkItemProjectOrigin, - type ItemDialogTab -} from '@/components/github/github-work-item-identity' -import { - addIssueCommentForRepo, - addPRReviewCommentForRepo, - addPRReviewCommentReplyForRepo, - notifyWorkItemDetailsMutation, - setPRFileViewedForRepo -} from '@/components/github/github-work-item-comment-mutations' -import { - runIssueUpdate, - runPullRequestStateUpdate, - runWorkItemBodyUpdate -} from '@/components/github/github-work-item-edit-mutations' -import { - PR_FILE_CONTENT_CACHE_MAX_BYTES, - getRetainedPRFileContentsByteCount, - isPRFileViewed -} from '@/components/github/pr-file-content-size' -import { - PR_DIFF_OVERSCAN, - getPRFileContentsRenderLimit, - getPRFileDiffResult, - getPRFileSectionKey, - gitHubPRFileToBranchEntry, - type PRFilesCombinedDiffViewerProps -} from '@/components/github/pr-file-diff-mapping' -import { - buildRequestedReviewUsers, - formatRelativeTime, - getStateLabel, - mergeReviewerSuggestions, - ReviewerAvatar -} from '@/components/github/work-item-state-presentation' -import { - formatCheckTimestamp, - getCheckDetailsKey, - getCheckStatusLabel -} from '@/components/github/pr-check-presentation' -import { CommentCodeContext } from '@/components/github/CommentCodeContext' -import { CommentReactions } from '@/components/github/CommentReactions' -import { PRAssigneesPanel } from '@/components/github/PRAssigneesPanel' -import { PRViewedCheckbox } from '@/components/github/PRViewedCheckbox' +import GitHubItemDialog from './github-item-dialog/open-dialog/github-item-dialog' -export type { ItemDialogTab } +export type { ItemDialogTab } from './github/github-work-item-identity' +export type { GitHubItemDialogProjectOrigin } from './github-item-dialog/load-item-details/github-item-dialog-types' +export { invalidateWorkItemDetailsCacheForKey } from './github-item-dialog/load-item-details/work-item-details-cache' -/** Re-exported so Project-view callers keep a stable import path. */ -export type GitHubItemDialogProjectOrigin = GitHubWorkItemProjectOrigin - -function getGitHubRepositoryLabelsUrl(itemUrl: string): string | null { - try { - const parsed = new URL(itemUrl) - if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { - return null - } - const segments = parsed.pathname.split('/').filter(Boolean) - if (segments.length < 2) { - return null - } - // Why: preserve the origin so GitHub Enterprise URLs keep working while re-pathing to the repo-scoped labels page. - parsed.pathname = `/${segments[0]}/${segments[1]}/labels` - parsed.search = '' - parsed.hash = '' - return parsed.toString() - } catch { - return null - } -} - -type GitHubItemDialogProps = { - workItem: GitHubWorkItem | null - repoPath: string | null - repoId?: string | null - sourceContext?: TaskSourceContext | null - initialTab?: ItemDialogTab - backLabel?: string - /** Called when the user clicks the primary CTA to start work from this item. */ - onUse: (item: GitHubWorkItem) => void - onReviewRequestsChange?: ( - itemKey: { id: string; repoId: string }, - reviewRequests: GitHubAssignableUser[] - ) => void - onClose: () => void - /** Optional Project-origin context; when set, edits route via slug-addressed IPCs against the row's repo (slug routing wins for writes). */ - projectOrigin?: GitHubItemDialogProjectOrigin -} - -function getStateTone(item: GitHubWorkItem): string { - if (item.type === 'pr') { - if (item.state === 'merged') { - return 'border-purple-500/30 bg-purple-500/10 text-purple-600 dark:text-purple-300' - } - if (item.state === 'draft') { - return 'border-slate-500/30 bg-slate-500/10 text-slate-600 dark:text-slate-300' - } - if (item.state === 'closed') { - return 'border-rose-500/30 bg-rose-500/10 text-rose-600 dark:text-rose-300' - } - return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300' - } - if (item.state === 'closed') { - // Why: keep closed issues neutral (may be completed/resolved); red is reserved for PR closed-without-merge. - return 'border-ring/50 bg-primary/10 text-foreground' - } - return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300' -} - -function WorkItemStateBadge({ - item, - className -}: { - item: GitHubWorkItem - className?: string -}): React.JSX.Element { - return ( - - {getStateLabel(item)} - - ) -} - -function PRReviewersPanel({ - item, - loading, - repoPath, - sourceContext, - projectOrigin, - onReviewersRequested -}: { - item: GitHubWorkItem - loading: boolean - repoPath: string | null - sourceContext?: TaskSourceContext | null - projectOrigin?: GitHubItemDialogProjectOrigin - onReviewersRequested: (reviewRequests: GitHubAssignableUser[]) => void -}): React.JSX.Element { - const [open, setOpen] = useState(false) - const [reviewerInput, setReviewerInput] = useState('') - const [activeReviewerCursor, setActiveReviewerCursor] = useState({ - resetKey: '', - index: 0 - }) - const [submitting, setSubmitting] = useState(false) - const [localReviewRequests, setLocalReviewRequests] = useState( - () => item.reviewRequests ?? [] - ) - const [reviewRequestsSource, setReviewRequestsSource] = useState(() => ({ - itemId: item.id, - repoId: item.repoId, - reviewRequests: item.reviewRequests - })) - const patchWorkItem = useAppStore((s) => s.patchWorkItem) - const repoOwnerSettings = useAppStore( - useShallow((s) => getSettingsForRepoRuntimeOwner(s, item.repoId ?? null)) - ) - const sourceSettings = useMemo( - () => - sourceContext?.provider === 'github' - ? ({ - ...repoOwnerSettings, - ...getTaskSourceRuntimeSettings(sourceContext) - } as typeof repoOwnerSettings) - : repoOwnerSettings, - [repoOwnerSettings, sourceContext] - ) - const reviewerInputRef = useRef(null) - const reviewerInputFocusFrameRef = useRef(null) - const reviewerPanelMountedRef = useRef(true) - - const cancelReviewerInputFocusFrame = useCallback((): void => { - if (reviewerInputFocusFrameRef.current !== null) { - cancelAnimationFrame(reviewerInputFocusFrameRef.current) - reviewerInputFocusFrameRef.current = null - } - }, []) - - const scheduleReviewerInputFocus = useCallback((): void => { - if (!reviewerPanelMountedRef.current) { - return - } - cancelReviewerInputFocusFrame() - reviewerInputFocusFrameRef.current = requestAnimationFrame(() => { - reviewerInputFocusFrameRef.current = null - reviewerInputRef.current?.focus() - }) - }, [cancelReviewerInputFocusFrame]) - - useEffect(() => { - reviewerPanelMountedRef.current = true - return () => { - reviewerPanelMountedRef.current = false - cancelReviewerInputFocusFrame() - } - }, [cancelReviewerInputFocusFrame]) - - // Why: clear stale optimistic reviewer requests on item switch/refetch before paint; a passive Effect would leave one stale render. - if ( - reviewRequestsSource.itemId !== item.id || - reviewRequestsSource.repoId !== item.repoId || - reviewRequestsSource.reviewRequests !== item.reviewRequests - ) { - setReviewRequestsSource({ - itemId: item.id, - repoId: item.repoId, - reviewRequests: item.reviewRequests - }) - setLocalReviewRequests(item.reviewRequests ?? []) - } - - const reviewerSeedUsers = useMemo(() => { - const byLogin = new Map() - const add = (user: GitHubAssignableUser): void => { - if (!user.login) { - return - } - byLogin.set(user.login.toLowerCase(), user) - } - for (const user of localReviewRequests) { - add(user) - } - for (const review of item.latestReviews ?? []) { - add({ - login: review.login, - name: null, - avatarUrl: review.avatarUrl ?? '' - }) - } - if (item.author) { - add({ login: item.author, name: null, avatarUrl: '' }) - } - return Array.from(byLogin.values()) - }, [item.author, item.latestReviews, localReviewRequests]) - - const reviewRepo = useMemo( - () => resolvePullRequestRepo(item, projectOrigin), - [item, projectOrigin] - ) - const reviewerMetadataBySlug = useRepoAssigneesBySlug( - open && reviewRepo ? reviewRepo.owner : null, - open && reviewRepo ? reviewRepo.repo : null, - reviewerSeedUsers.map((user) => user.login), - sourceSettings, - reviewRepo?.host - ) - const reviewerMetadataByPath = useRepoAssignees( - open && !reviewRepo ? repoPath : null, - open && !reviewRepo ? item.repoId : null, - sourceSettings - ) - const reviewerMetadata = reviewRepo ? reviewerMetadataBySlug : reviewerMetadataByPath - const displayItem = { ...item, reviewRequests: localReviewRequests } - const reviewers = getGitHubPRReviewerRows(displayItem) - const authorLogin = item.author?.toLowerCase() ?? null - const reviewerCandidates = useMemo( - () => - mergeReviewerSuggestions(reviewerMetadata.data, reviewerSeedUsers).filter( - (user) => user.login.toLowerCase() !== authorLogin - ), - [authorLogin, reviewerMetadata.data, reviewerSeedUsers] - ) - const reviewerCandidatesByLogin = useMemo( - () => new Map(reviewerCandidates.map((user) => [user.login.toLowerCase(), user])), - [reviewerCandidates] - ) - const selectedReviewerLogins = useMemo( - () => - new Set( - localReviewRequests.map((reviewer) => reviewer.login.trim().toLowerCase()).filter(Boolean) - ), - [localReviewRequests] - ) - const reviewerQueryState = useMemo( - () => getGitHubPRReviewerQueryState(reviewerInput), - [reviewerInput] - ) - const reviewerQuery = reviewerQueryState.query - const filteredReviewerCandidates = useMemo( - () => - filterGitHubPRReviewerCandidates({ - candidates: reviewerCandidates, - queryState: reviewerQueryState - }), - [reviewerCandidates, reviewerQueryState] - ) - const suggestedReviewerRows = useMemo( - () => - reviewerQuery.length === 0 && !reviewerQueryState.isTooLarge - ? reviewerSeedUsers - .filter((user) => !selectedReviewerLogins.has(user.login.toLowerCase())) - .filter((user) => user.login.toLowerCase() !== authorLogin) - .map((user) => reviewerCandidatesByLogin.get(user.login.toLowerCase()) ?? user) - .slice(0, 1) - : [], - [ - authorLogin, - reviewerCandidatesByLogin, - reviewerQuery.length, - reviewerQueryState.isTooLarge, - reviewerSeedUsers, - selectedReviewerLogins - ] - ) - const everyoneElseReviewerRows = useMemo(() => { - const suggestedLogins = new Set(suggestedReviewerRows.map((user) => user.login.toLowerCase())) - return filteredReviewerCandidates.filter( - (user) => !suggestedLogins.has(user.login.toLowerCase()) - ) - }, [filteredReviewerCandidates, suggestedReviewerRows]) - const actionableReviewerRows = useMemo( - () => [...suggestedReviewerRows, ...everyoneElseReviewerRows], - [everyoneElseReviewerRows, suggestedReviewerRows] - ) - - const reviewerCursorResetKey = `${reviewerQuery}\u0000${actionableReviewerRows.length}` - if (activeReviewerCursor.resetKey !== reviewerCursorResetKey) { - setActiveReviewerCursor({ resetKey: reviewerCursorResetKey, index: 0 }) - } - const activeReviewerIndex = - activeReviewerCursor.resetKey === reviewerCursorResetKey ? activeReviewerCursor.index : 0 - const setActiveReviewerIndex = useCallback( - (nextIndex: number | ((current: number) => number)): void => { - setActiveReviewerCursor((current) => { - const currentIndex = current.resetKey === reviewerCursorResetKey ? current.index : 0 - return { - resetKey: reviewerCursorResetKey, - index: typeof nextIndex === 'function' ? nextIndex(currentIndex) : nextIndex - } - }) - }, - [reviewerCursorResetKey] - ) - - const hasReviewerMetadata = - item.reviewDecision !== undefined || - localReviewRequests.length > 0 || - item.reviewRequests !== undefined || - item.latestReviews !== undefined - const canRequestReview = - !!repoPath || getActiveRuntimeTarget(sourceSettings).kind === 'environment' - - const handleRequestReview = async (requestedLogins?: string[]): Promise => { - if (submitting) { - return - } - const logins = normalizeGitHubReviewerLogins( - requestedLogins ?? parseGitHubReviewerInputLogins(reviewerInput), - selectedReviewerLogins - ) - if (logins.length === 0) { - toast.error(translate('auto.components.GitHubItemDialog.94ab23a9f9', 'Enter a reviewer')) - return - } - if (localReviewRequests.length + logins.length > 15) { - toast.error( - translate( - 'auto.components.GitHubItemDialog.12e761610e', - 'You can request up to 15 reviewers' - ) - ) - return - } - const target = getActiveRuntimeTarget(sourceSettings) - if (target.kind !== 'environment' && !repoPath) { - toast.error( - translate( - 'auto.components.GitHubItemDialog.b4af16bf43', - 'No repo context available for this pull request.' - ) - ) - return - } - setSubmitting(true) - try { - const runtimeRepo = getGitHubRuntimeRepoId(sourceContext, item.repoId) - const result = - target.kind === 'environment' - ? await callRuntimeRpc<{ ok: boolean; error?: string }>( - target, - 'github.requestPRReviewers', - { - repo: runtimeRepo, - prNumber: item.number, - reviewers: logins, - prRepo: reviewRepo - }, - { timeoutMs: 30_000 } - ) - : await window.api.gh.requestPRReviewers({ - repoPath: repoPath ?? '', - repoId: item.repoId, - sourceContext, - prNumber: item.number, - reviewers: logins, - prRepo: reviewRepo - }) - if (!reviewerPanelMountedRef.current) { - return - } - if (!result.ok) { - toast.error( - result.error ?? - translate('auto.components.GitHubItemDialog.c42d942b75', 'Failed to request reviewer') - ) - return - } - const nextReviewRequests = buildRequestedReviewUsers( - logins, - reviewerCandidates, - localReviewRequests - ) - setLocalReviewRequests(nextReviewRequests) - patchWorkItem(item.id, { reviewRequests: nextReviewRequests }, item.repoId, { - sourceContext - }) - onReviewersRequested(nextReviewRequests) - if (target.kind === 'environment') { - notifyWorkItemDetailsMutation( - { - repoPath: repoPath ?? '', - repoId: item.repoId, - sourceContext, - type: 'pr', - number: item.number - }, - { local: false } - ) - } - setReviewerInput('') - useAppStore.getState().recordFeatureInteraction('github-tasks') - toast.success( - logins.length === 1 - ? translate('auto.components.GitHubItemDialog.ea985e657f', 'Reviewer requested') - : translate('auto.components.GitHubItemDialog.c016e4bac3', 'Reviewers requested') - ) - } catch { - if (reviewerPanelMountedRef.current) { - toast.error( - translate('auto.components.GitHubItemDialog.c42d942b75', 'Failed to request reviewer') - ) - } - } finally { - if (reviewerPanelMountedRef.current) { - setSubmitting(false) - } - } - } - - const handleRemoveReviewers = async (reviewersToRemove: string[]): Promise => { - if (submitting) { - return - } - const selected = new Set(localReviewRequests.map((reviewer) => reviewer.login.toLowerCase())) - const logins = reviewersToRemove - .map((reviewer) => reviewer.trim().replace(/^@/, '')) - .filter((reviewer) => reviewer.length > 0 && selected.has(reviewer.toLowerCase())) - if (logins.length === 0) { - return - } - const target = getActiveRuntimeTarget(sourceSettings) - if (target.kind !== 'environment' && !repoPath) { - toast.error( - translate( - 'auto.components.GitHubItemDialog.b4af16bf43', - 'No repo context available for this pull request.' - ) - ) - return - } - setSubmitting(true) - try { - const runtimeRepo = getGitHubRuntimeRepoId(sourceContext, item.repoId) - const result = - target.kind === 'environment' - ? await callRuntimeRpc<{ ok: boolean; error?: string }>( - target, - 'github.removePRReviewers', - { - repo: runtimeRepo, - prNumber: item.number, - reviewers: logins, - prRepo: reviewRepo - }, - { timeoutMs: 30_000 } - ) - : await window.api.gh.removePRReviewers({ - repoPath: repoPath ?? '', - repoId: item.repoId, - sourceContext, - prNumber: item.number, - reviewers: logins, - prRepo: reviewRepo - }) - if (!reviewerPanelMountedRef.current) { - return - } - if (!result.ok) { - toast.error( - result.error ?? - translate('auto.components.GitHubItemDialog.73487fb975', 'Failed to remove reviewer') - ) - return - } - const removed = new Set(logins.map((login) => login.toLowerCase())) - const nextReviewRequests = localReviewRequests.filter( - (reviewer) => !removed.has(reviewer.login.toLowerCase()) - ) - setLocalReviewRequests(nextReviewRequests) - patchWorkItem(item.id, { reviewRequests: nextReviewRequests }, item.repoId, { - sourceContext - }) - onReviewersRequested(nextReviewRequests) - if (target.kind === 'environment') { - notifyWorkItemDetailsMutation( - { - repoPath: repoPath ?? '', - repoId: item.repoId, - sourceContext, - type: 'pr', - number: item.number - }, - { local: false } - ) - } - setReviewerInput('') - useAppStore.getState().recordFeatureInteraction('github-tasks') - toast.success( - logins.length === 1 - ? translate('auto.components.GitHubItemDialog.69515bff81', 'Reviewer removed') - : translate('auto.components.GitHubItemDialog.2e69540652', 'Reviewers removed') - ) - } catch { - if (reviewerPanelMountedRef.current) { - toast.error( - translate('auto.components.GitHubItemDialog.73487fb975', 'Failed to remove reviewer') - ) - } - } finally { - if (reviewerPanelMountedRef.current) { - setSubmitting(false) - } - } - } - - const requestReviewer = async (reviewer: GitHubAssignableUser): Promise => { - await (selectedReviewerLogins.has(reviewer.login.toLowerCase()) - ? handleRemoveReviewers([reviewer.login]) - : handleRequestReview([reviewer.login])) - scheduleReviewerInputFocus() - } - - const handleReviewerPickerOpenChange = (nextOpen: boolean): void => { - setOpen(nextOpen) - if (nextOpen) { - scheduleReviewerInputFocus() - return - } - setReviewerInput('') - } - - const renderReviewerPickerRow = ( - reviewer: GitHubAssignableUser, - options: { suggested: boolean; activeIndex: number } - ): React.JSX.Element => { - const selected = selectedReviewerLogins.has(reviewer.login.toLowerCase()) - const active = actionableReviewerRows[activeReviewerIndex]?.login === reviewer.login - return ( - - ) - } - - return ( -
-
- {translate('auto.components.GitHubItemDialog.dc8a092c57', 'Reviewers')} - - - - - { - event.preventDefault() - }} - > -
- setReviewerInput(event.target.value)} - disabled={submitting || !canRequestReview} - placeholder={translate( - 'auto.components.GitHubItemDialog.bb42774171', - 'Type or choose a user' - )} - aria-label={translate('auto.components.GitHubItemDialog.934add88b6', 'Reviewer')} - aria-expanded={open} - aria-haspopup="listbox" - className="h-8 min-w-0 cursor-text rounded-md border-border/50 bg-background text-xs" - onKeyDown={(event) => { - if (event.key === 'ArrowDown' && actionableReviewerRows.length > 0) { - event.preventDefault() - setActiveReviewerIndex( - (current) => (current + 1) % actionableReviewerRows.length - ) - return - } - if (event.key === 'ArrowUp' && actionableReviewerRows.length > 0) { - event.preventDefault() - setActiveReviewerIndex( - (current) => - (current - 1 + actionableReviewerRows.length) % - actionableReviewerRows.length - ) - return - } - if (event.key === 'Enter') { - event.preventDefault() - const activeReviewer = actionableReviewerRows[activeReviewerIndex] - if (activeReviewer) { - void requestReviewer(activeReviewer) - return - } - void handleRequestReview() - return - } - if (event.key === 'Escape') { - event.preventDefault() - handleReviewerPickerOpenChange(false) - } - }} - /> -
-
- {reviewerMetadata.loading ? ( -
- {translate('auto.components.GitHubItemDialog.a98433e73d', 'Loading...')} -
- ) : filteredReviewerCandidates.length > 0 ? ( - <> - {suggestedReviewerRows.length > 0 ? ( - <> -
- {translate('auto.components.GitHubItemDialog.c2b21818e1', 'Suggestions')} -
- {suggestedReviewerRows.map((reviewer, index) => - renderReviewerPickerRow(reviewer, { - suggested: true, - activeIndex: index - }) - )} - - ) : null} -
- {translate('auto.components.GitHubItemDialog.1ffce94a8b', 'Everyone else')} -
- {everyoneElseReviewerRows.length > 0 ? ( - everyoneElseReviewerRows.map((reviewer, index) => - renderReviewerPickerRow(reviewer, { - suggested: false, - activeIndex: suggestedReviewerRows.length + index - }) - ) - ) : ( -
- {translate( - 'auto.components.GitHubItemDialog.70e84e3d0b', - 'No matching reviewers.' - )} -
- )} - - ) : ( -
- {reviewerMetadata.error ?? - (hasReviewerMetadata - ? translate( - 'auto.components.GitHubItemDialog.70e84e3d0b', - 'No matching reviewers.' - ) - : translate( - 'auto.components.GitHubItemDialog.3f79ffc8b7', - 'Open the PR details to view current reviewers.' - ))} -
- )} -
-
-
-
- {loading && !hasReviewerMetadata ? ( -
- - {translate('auto.components.GitHubItemDialog.6a45771d47', 'Loading reviewers')} -
- ) : reviewers.length > 0 ? ( -
- {reviewers.map((reviewer) => { - const canRemoveReviewer = selectedReviewerLogins.has(reviewer.login.toLowerCase()) - return ( -
- -
-
- {reviewer.login} -
- {reviewer.name ? ( -
- {reviewer.name} -
- ) : null} -
- - {reviewer.stateLabel} - - {canRemoveReviewer ? ( - - - - - - {translate('auto.components.GitHubItemDialog.5c1c973855', 'Remove reviewer')} - - - ) : null} -
- ) - })} -
- ) : ( -
- {translate('auto.components.GitHubItemDialog.36f9ac4a47', 'No reviewers requested.')} -
- )} -
- ) -} - -// Why: SWR cache for work-item details so reopening paints instantly instead of paying IPC + `gh` startup; keyed to avoid source/type collisions, LRU-bounded, FRESH_MS refetch on open. See docs/gh-work-item-drawer-cache.md. -const WORK_ITEM_DETAILS_CACHE_MAX = 50 -const WORK_ITEM_DETAILS_FRESH_MS = 30_000 -const WORK_ITEM_DETAILS_UNAVAILABLE_MESSAGE = 'Unable to load details for this GitHub item.' -type WorkItemDetailsCacheEntry = { - details: GitHubWorkItemDetails | null - fetchedAt: number - pending?: Promise - error?: string -} -const workItemDetailsCache = new Map() - -// Why: drawers subscribe via useSyncExternalStore so a cached item paints synchronously; snapshot stability relies on every write replacing entry identity (delete+set). -const workItemDetailsCacheListeners = new Set<() => void>() -function subscribeWorkItemDetailsCache(listener: () => void): () => void { - workItemDetailsCacheListeners.add(listener) - return () => { - workItemDetailsCacheListeners.delete(listener) - } -} -function notifyWorkItemDetailsCache(): void { - for (const listener of workItemDetailsCacheListeners) { - listener() - } -} - -function getWorkItemDetailsCacheKey(args: { - repoPath: string - repoId: string - issueSourcePreference: string | undefined - sourceCacheScope?: string | null - type: 'issue' | 'pr' - number: number -}): string { - // Why: key on every axis that changes which (repo, item) the IPC resolves to; `\0` separator avoids ambiguity with fields containing `:` or `/`. - const keyParts = args.sourceCacheScope - ? [args.repoId, args.sourceCacheScope, args.issueSourcePreference ?? 'auto', args.type] - : [args.repoId, args.issueSourcePreference ?? 'auto', args.type] - return [...keyParts, args.number].join('\0') -} - -function touchWorkItemDetailsCache(key: string, entry: WorkItemDetailsCacheEntry): void { - // Why: re-insert to move to MRU position; Map insertion order keeps the oldest key first for eviction. - workItemDetailsCache.delete(key) - workItemDetailsCache.set(key, entry) - while (workItemDetailsCache.size > WORK_ITEM_DETAILS_CACHE_MAX) { - const oldest = workItemDetailsCache.keys().next().value - if (oldest === undefined) { - break - } - workItemDetailsCache.delete(oldest) - } - notifyWorkItemDetailsCache() -} - -// Why: exposed so mutation handlers can drop a stale entry after a local mutation; cross-window invalidation arrives via the gh:workItemMutated listener below. -export function invalidateWorkItemDetailsCacheForKey(key: string): void { - // Why: bump generation so a fetch launched before this invalidation won't write its stale result back. - workItemDetailsCacheGeneration += 1 - const existed = workItemDetailsCache.delete(key) - if (existed) { - notifyWorkItemDetailsCache() - } -} - -// Why: bumped on every invalidation so an in-flight refetch started before a mutation can detect its result is stale and skip writing it back. -let workItemDetailsCacheGeneration = 0 - -// Why: without the exact key (e.g. a cross-window event carries only repoPath+number+type), drop every entry matching that tuple regardless of source preference. -function invalidateWorkItemDetailsCacheByMatch(args: { - repoPath: string - repoId?: string - type: 'issue' | 'pr' - number: number -}): void { - const suffix = `\0${args.type}\0${args.number}` - const prefix = `${args.repoId ?? args.repoPath}\0` - let removed = false - for (const key of Array.from(workItemDetailsCache.keys())) { - if (key.startsWith(prefix) && key.endsWith(suffix)) { - workItemDetailsCache.delete(key) - removed = true - } - } - if (removed) { - workItemDetailsCacheGeneration += 1 - notifyWorkItemDetailsCache() - } -} - -function patchCachedPRFileViewedState( - cacheKey: string, - path: string, - viewerViewedState: GitHubPRFileViewedState -): GitHubPRFileViewedState | undefined { - const prev = workItemDetailsCache.get(cacheKey) - const files = prev?.details?.files - if (!prev?.details || !files) { - return undefined - } - let previousState: GitHubPRFileViewedState | undefined - const nextFiles = files.map((file) => { - if (file.path !== path) { - return file - } - previousState = file.viewerViewedState ?? 'UNVIEWED' - return { ...file, viewerViewedState } - }) - if (previousState === undefined || previousState === viewerViewedState) { - return previousState - } - touchWorkItemDetailsCache(cacheKey, { - ...prev, - details: { ...prev.details, files: nextFiles }, - error: undefined - }) - return previousState -} - -function patchCachedPRChecks(cacheKey: string, checks: PRCheckDetail[]): void { - const prev = workItemDetailsCache.get(cacheKey) - if (!prev?.details) { - return - } - touchWorkItemDetailsCache(cacheKey, { - ...prev, - details: { ...prev.details, checks }, - fetchedAt: Date.now(), - error: undefined - }) -} - -function patchCachedPRReviewRequests( - cacheKey: string, - reviewRequests: GitHubAssignableUser[] -): void { - const prev = workItemDetailsCache.get(cacheKey) - if (!prev?.details) { - return - } - touchWorkItemDetailsCache(cacheKey, { - ...prev, - details: { - ...prev.details, - item: { ...prev.details.item, reviewRequests } - }, - fetchedAt: Date.now(), - error: undefined - }) -} - -function patchCachedWorkItemBody(cacheKey: string, body: string): void { - const prev = workItemDetailsCache.get(cacheKey) - if (!prev?.details) { - return - } - touchWorkItemDetailsCache(cacheKey, { - ...prev, - details: { ...prev.details, body }, - fetchedAt: Date.now(), - error: undefined - }) -} - -// Why: install once — all dialogs share the cache; track unsubscribe so Vite HMR doesn't accumulate listeners across dev reloads. -let workItemMutatedUnsub: (() => void) | undefined -let workItemDetailsCacheEventUnsub: (() => void) | undefined -if (typeof window !== 'undefined' && window.api?.gh?.onWorkItemMutated) { - workItemMutatedUnsub = window.api.gh.onWorkItemMutated((payload) => { - invalidateWorkItemDetailsCacheByMatch({ - repoPath: payload.repoPath, - repoId: payload.repoId, - type: payload.type, - number: payload.number - }) - }) - workItemDetailsCacheEventUnsub = onGitHubWorkItemDetailsCacheMutation((payload) => { - invalidateWorkItemDetailsCacheByMatch(payload) - }) -} -if (import.meta !== undefined && import.meta.hot) { - import.meta.hot.dispose(() => { - workItemMutatedUnsub?.() - workItemDetailsCacheEventUnsub?.() - }) -} - -// Why: bounded LRU so opening many PRs with many files doesn't grow this module-level map unboundedly until reload. -const PR_FILE_CONTENT_CACHE_MAX = 64 -type PRFileContentCacheEntry = { - value: Promise | GitHubPRFileContents - byteCount: number -} -const prFileContentCache = new Map() -let prFileContentCacheBytes = 0 - -function touchPRFileContentCache( - key: string, - value: Promise | GitHubPRFileContents -): void { - const retainedByteCount = value instanceof Promise ? 0 : getRetainedPRFileContentsByteCount(value) - if (retainedByteCount === null) { - const existing = prFileContentCache.get(key) - prFileContentCacheBytes -= existing?.byteCount ?? 0 - prFileContentCache.delete(key) - return - } - - const existing = prFileContentCache.get(key) - prFileContentCacheBytes -= existing?.byteCount ?? 0 - // Why: re-insert to move to MRU position; Map insertion order keeps the oldest key first for eviction. - prFileContentCache.delete(key) - const byteCount = retainedByteCount - prFileContentCache.set(key, { value, byteCount }) - prFileContentCacheBytes += byteCount - while ( - prFileContentCache.size > PR_FILE_CONTENT_CACHE_MAX || - prFileContentCacheBytes > PR_FILE_CONTENT_CACHE_MAX_BYTES - ) { - const oldest = prFileContentCache.keys().next().value - if (oldest === undefined) { - break - } - const evicted = prFileContentCache.get(oldest) - prFileContentCacheBytes -= evicted?.byteCount ?? 0 - prFileContentCache.delete(oldest) - } -} - -function getPRFileContentCacheKey(args: { - repoPath: string - repoId: string - sourceContext?: TaskSourceContext | null - prNumber: number - prRepo?: GitHubOwnerRepo | null - file: GitHubPRFile - headSha: string - baseSha: string -}): string { - const repositoryKey = args.repoId ? `repo:${args.repoId}` : `path:${args.repoPath}` - const sourceKey = - args.sourceContext?.provider === 'github' - ? `source:${getTaskSourceCacheScope(args.sourceContext)}` - : 'source:local' - return [ - repositoryKey, - sourceKey, - args.prNumber, - args.prRepo ? githubRepoIdentityKey(args.prRepo) : '', - args.file.path, - args.file.oldPath ?? '', - args.file.status, - args.headSha, - args.baseSha - ].join('\0') -} - -function loadPRFileContents(args: { - repoPath: string - repoId: string - sourceContext?: TaskSourceContext | null - prNumber: number - prRepo?: GitHubOwnerRepo | null - file: GitHubPRFile - headSha: string - baseSha: string -}): Promise { - const cacheKey = getPRFileContentCacheKey(args) - const cached = prFileContentCache.get(cacheKey) - if (cached) { - touchPRFileContentCache(cacheKey, cached.value) - return Promise.resolve(cached.value) - } - let request: Promise - const runtimeHost = getGitHubSourceRuntimeHost(args.sourceContext) - request = ( - runtimeHost - ? callRuntimeRpc( - { kind: 'environment', environmentId: runtimeHost.environmentId }, - 'github.prFileContents', - { - repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId), - prNumber: args.prNumber, - prRepo: args.prRepo ?? null, - path: args.file.path, - oldPath: args.file.oldPath, - status: args.file.status, - headSha: args.headSha, - baseSha: args.baseSha - }, - { timeoutMs: 30_000 } - ) - : window.api.gh.prFileContents({ - repoPath: args.repoPath, - repoId: args.repoId, - sourceContext: args.sourceContext, - prNumber: args.prNumber, - prRepo: args.prRepo ?? null, - path: args.file.path, - oldPath: args.file.oldPath, - status: args.file.status, - headSha: args.headSha, - baseSha: args.baseSha - }) - ) - .then((contents) => { - if (prFileContentCache.get(cacheKey)?.value === request) { - touchPRFileContentCache(cacheKey, contents) - } - return contents - }) - .catch((err) => { - const cachedRequest = prFileContentCache.get(cacheKey) - if (cachedRequest?.value === request) { - prFileContentCacheBytes -= cachedRequest.byteCount - prFileContentCache.delete(cacheKey) - } - throw err - }) - touchPRFileContentCache(cacheKey, request) - return request -} - -function PRFilesCombinedDiffViewer({ - files, - comments, - repoPath, - repoId, - sourceContext, - prNumber, - prRepo, - prUrl, - headSha, - baseSha, - pendingViewedPaths, - onCommentAdded, - onViewedChange -}: PRFilesCombinedDiffViewerProps): React.JSX.Element { - const settings = useAppStore((s) => s.settings) - const isDark = - settings?.theme === 'dark' || - (settings?.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches) - const entriesCacheRef = useRef<{ - signature: string - entries: GitBranchChangeEntry[] - } | null>(null) - const diffEntrySignature = useMemo( - () => - JSON.stringify( - files.map((file) => ({ - path: file.path, - oldPath: file.oldPath ?? null, - status: file.status, - additions: file.additions, - deletions: file.deletions, - isBinary: file.isBinary - })) - ), - [files] - ) - const entries = useMemo(() => { - if (entriesCacheRef.current?.signature === diffEntrySignature) { - return entriesCacheRef.current.entries - } - const nextEntries = getCombinedDiffBranchEntriesInTreeOrder( - 'commit', - files.map(gitHubPRFileToBranchEntry) - ) - entriesCacheRef.current = { - signature: diffEntrySignature, - entries: nextEntries - } - return nextEntries - }, [diffEntrySignature, files]) - const fileByPath = useMemo(() => new Map(files.map((file) => [file.path, file])), [files]) - const inlineReviewComments = useMemo( - () => - comments.flatMap((comment): DecoratedDiffComment[] => { - // Why: outdated threads' line number can attach the comment to unrelated current code, so skip them inline. - if (comment.isOutdated || !comment.path || typeof comment.line !== 'number') { - return [] - } - const createdAtMs = new Date(comment.createdAt).getTime() - return [ - { - id: `github-pr-comment:${comment.id}`, - worktreeId: `github-pr:${repoId}:${prNumber}`, - filePath: comment.path, - source: 'diff', - startLine: comment.startLine, - lineNumber: comment.line, - body: comment.body, - createdAt: Number.isFinite(createdAtMs) ? createdAtMs : Date.now(), - side: 'modified', - author: comment.author, - authorAvatarUrl: comment.authorAvatarUrl, - createdAtLabel: formatRelativeTime(comment.createdAt), - url: comment.url, - canDelete: false, - canEdit: false - } - ] - }), - [comments, prNumber, repoId] - ) - const entrySignature = useMemo( - () => - JSON.stringify({ - repoId, - prNumber, - prRepo: prRepo ? githubRepoIdentityKey(prRepo) : null, - headSha: headSha ?? null, - baseSha: baseSha ?? null, - files: diffEntrySignature - }), - [baseSha, diffEntrySignature, headSha, prNumber, prRepo, repoId] - ) - const [sections, setSections] = useState([]) - const [sideBySide, setSideBySide] = useState(false) - const [fileTreeCollapsed, setFileTreeCollapsed] = useState(false) - const [sectionHeights, setSectionHeights] = useState>({}) - const [activeTreeSectionKey, setActiveTreeSectionKey] = useState(null) - const scrollContainerRef = useRef(null) - const loadedIndicesRef = useRef>(new Set()) - const loadingIndicesRef = useRef>(new Set()) - const sectionsRef = useRef([]) - const generationRef = useRef(0) - const modifiedEditorsRef = useRef>(new Map()) - const handleSectionSaveRef = useRef<(index: number) => Promise>(async () => {}) - sectionsRef.current = sections - - useEffect(() => { - generationRef.current += 1 - loadedIndicesRef.current.clear() - loadingIndicesRef.current.clear() - setSectionHeights({}) - setActiveTreeSectionKey(null) - setSections( - entries.map((entry) => ({ - key: getPRFileSectionKey(entry.path), - path: entry.path, - oldPath: entry.oldPath, - status: entry.status, - added: entry.added, - removed: entry.removed, - originalContent: '', - modifiedContent: '', - collapsed: false, - loading: true, - error: undefined, - dirty: false, - diffResult: null, - largeDiffRenderLimit: null - })) - ) - }, [entries, entrySignature]) - - const loadSection = useCallback( - (index: number) => { - const section = sectionsRef.current[index] - if (!section || section.collapsed) { - return - } - if (loadedIndicesRef.current.has(index) || loadingIndicesRef.current.has(index)) { - return - } - const file = fileByPath.get(section.path) - if (!file) { - return - } - const generation = generationRef.current - loadingIndicesRef.current.add(index) - - const load = async (): Promise<{ - result: GitDiffResult - resultContents?: GitHubPRFileContents - error?: string - }> => { - if (file.isBinary) { - return { - result: { - kind: 'binary', - originalContent: '', - modifiedContent: '', - originalIsBinary: true, - modifiedIsBinary: true - } - } - } - if (!headSha || !baseSha) { - return { - result: { - kind: 'text', - originalContent: '', - modifiedContent: '', - originalIsBinary: false, - modifiedIsBinary: false - }, - error: translate( - 'auto.components.GitHubItemDialog.829674460a', - 'Diff unavailable because the PR commit SHAs are missing.' - ) - } - } - const contents = await loadPRFileContents({ - repoPath, - repoId, - sourceContext, - prNumber, - prRepo, - file, - headSha, - baseSha - }) - return { result: getPRFileDiffResult(contents), resultContents: contents } - } - - load() - .catch((error) => ({ - result: { - kind: 'text', - originalContent: '', - modifiedContent: '', - originalIsBinary: false, - modifiedIsBinary: false - } as GitDiffResult, - resultContents: undefined, - error: error instanceof Error ? error.message : 'Failed to load diff.' - })) - .then(({ result, resultContents, error }) => { - loadingIndicesRef.current.delete(index) - if (generationRef.current !== generation) { - return - } - const largeDiffRenderLimit = - !error && result.kind === 'text' && resultContents - ? getPRFileContentsRenderLimit(resultContents) - : null - const storedContent = getStoredTextDiffContent(result, largeDiffRenderLimit) - const storedResult = getStoredTextDiffResult(result, largeDiffRenderLimit) - loadedIndicesRef.current.add(index) - setSections((prev) => - prev.map((current, currentIndex) => - currentIndex === index - ? { - ...current, - diffResult: storedResult, - originalContent: storedContent.originalContent, - modifiedContent: storedContent.modifiedContent, - loading: false, - error, - largeDiffRenderLimit - } - : current - ) - ) - }) - }, - [baseSha, fileByPath, headSha, prNumber, prRepo, repoId, repoPath, sourceContext] - ) - - const retrySection = useCallback( - (index: number) => { - loadedIndicesRef.current.delete(index) - loadingIndicesRef.current.delete(index) - setSectionHeights((prev) => removeDiffSectionMeasuredHeight(prev, index)) - setSections((prev) => - prev.map((section, sectionIndex) => - sectionIndex === index - ? { - ...section, - diffResult: null, - originalContent: '', - modifiedContent: '', - loading: true, - error: undefined, - largeDiffRenderLimit: null - } - : section - ) - ) - loadSection(index) - }, - [loadSection] - ) - - const toggleSection = useCallback( - (index: number) => { - const shouldLoadAfterExpand = sectionsRef.current[index]?.collapsed ?? false - setSections((prev) => - prev.map((section, sectionIndex) => - sectionIndex === index ? { ...section, collapsed: !section.collapsed } : section - ) - ) - if (shouldLoadAfterExpand) { - window.requestAnimationFrame(() => loadSection(index)) - } - }, - [loadSection] - ) - - const setAllSectionsCollapsed = useCallback( - (collapsed: boolean) => { - setSections((prev) => prev.map((section) => ({ ...section, collapsed }))) - if (!collapsed) { - window.requestAnimationFrame(() => { - sectionsRef.current.forEach((_, index) => loadSection(index)) - }) - } - }, - [loadSection] - ) - - const allSectionsCollapsed = sections.length > 0 && sections.every((section) => section.collapsed) - const sectionIndexByKey = useMemo(() => createCombinedDiffSectionIndexMap(sections), [sections]) - const viewedSectionKeys = useMemo( - () => new Set(files.filter(isPRFileViewed).map((file) => getPRFileSectionKey(file.path))), - [files] - ) - - const virtualizer = useVirtualizer({ - count: sections.length, - getScrollElement: () => scrollContainerRef.current, - estimateSize: (index) => { - const section = sections[index] - if (!section) { - return 88 - } - return getDiffSectionEstimatedHeight({ - collapsed: section.collapsed, - measuredContentHeight: sectionHeights[index], - originalContent: section.originalContent, - modifiedContent: section.modifiedContent, - changedLineCount: - section.added === undefined && section.removed === undefined - ? undefined - : (section.added ?? 0) + (section.removed ?? 0), - useIntrinsicImageHeight: isIntrinsicHeightImageDiff(section.diffResult), - isLargeDiffLimited: section.largeDiffRenderLimit?.limited === true, - lineCounts: section.largeDiffRenderLimit?.lineCounts ?? undefined - }) - }, - overscan: PR_DIFF_OVERSCAN, - getItemKey: (index) => { - const section = sections[index] - return section - ? `${section.key}:${section.collapsed ? 'collapsed' : 'expanded'}:${entrySignature}` - : `${index}:${entrySignature}` - } - }) - - useLayoutEffect(() => { - virtualizer.measure() - }, [sideBySide, virtualizer]) - - const handleTreeNavigate = useCallback( - (entry: CombinedDiffFileTreeEntry) => { - const navigatedIndex = handleCombinedDiffFileTreeNavigation({ - mode: 'commit', - entry, - sections: sectionsRef.current, - sectionIndexByKey, - toggleSection, - scrollToIndex: (index) => virtualizer.scrollToIndex(index, { align: 'start' }) - }) - if (navigatedIndex !== null) { - setActiveTreeSectionKey(sectionsRef.current[navigatedIndex]?.key ?? null) - } - }, - [sectionIndexByKey, toggleSection, virtualizer] - ) - - const openFilesOnGitHub = useCallback(() => { - void window.api.shell.openUrl(`${prUrl.replace(/\/$/, '')}/files`) - }, [prUrl]) - - const handleAddLineComment = useCallback( - async ( - section: DiffSection, - { - lineNumber, - startLine, - body - }: { - lineNumber: number - startLine?: number - body: string - } - ) => { - if (!headSha) { - toast.error( - translate( - 'auto.components.GitHubItemDialog.d1fa2cf888', - 'Unable to comment without the PR head SHA.' - ) - ) - return false - } - const result = await addPRReviewCommentForRepo({ - repoPath, - repoId, - sourceContext, - prNumber, - prRepo, - commitId: headSha, - path: section.path, - line: lineNumber, - startLine, - body - }) - if (!result.ok) { - toast.error( - result.error || - translate( - 'auto.components.GitHubItemDialog.b0b09778c8', - 'Failed to add review comment.' - ) - ) - return false - } - onCommentAdded(result.comment) - toast.success( - translate('auto.components.GitHubItemDialog.a341343303', 'Review comment added.') - ) - return true - }, - [headSha, onCommentAdded, prNumber, prRepo, repoId, repoPath, sourceContext] - ) - - const renderViewedCheckbox = useCallback( - (section: DiffSection) => { - const file = fileByPath.get(section.path) - if (!file) { - return null - } - const viewed = isPRFileViewed(file) - const pending = pendingViewedPaths.has(file.path) - return ( - { - if (!pending) { - void onViewedChange(file.path, !viewed) - } - }} - /> - ) - }, - [fileByPath, onViewedChange, pendingViewedPaths] - ) - - return ( -
-
-
- {fileTreeCollapsed && ( - - - - - - {translate('auto.components.GitHubItemDialog.1257d1435d', 'Show file tree')} - - - )} - - {files.filter(isPRFileViewed).length} / {files.length}{' '} - {translate('auto.components.GitHubItemDialog.f2d02cdf8c', 'files viewed')} - -
-
- - -
-
-
- -
-
- {virtualizer.getVirtualItems().map((virtualItem) => { - const section = sections[virtualItem.index] - if (!section) { - return null - } - return ( -
- - fileByPath.get(section.path)?.reviewCommentLineNumbers - } - setSectionHeights={setSectionHeights} - setSections={setSections} - modifiedEditorsRef={modifiedEditorsRef} - handleSectionSaveRef={handleSectionSaveRef} - /> -
- ) - })} -
-
-
-
- ) -} - -type IssueConversationEntry = - | { kind: 'comment'; id: string; createdAt: string; comment: PRComment; index: number } - | { - kind: 'activity' - id: string - createdAt: string - activity: GitHubIssueTimelineItem - index: number - } - -const EMPTY_GITHUB_ISSUE_TIMELINE_ITEMS: GitHubIssueTimelineItem[] = [] - -function getTimelineSortValue(createdAt: string): number { - const value = new Date(createdAt).getTime() - return Number.isFinite(value) ? value : 0 -} - -function getIssueConversationEntries( - comments: PRComment[], - timelineItems: GitHubIssueTimelineItem[] -): IssueConversationEntry[] { - return [ - ...comments.map( - (comment, index): IssueConversationEntry => ({ - kind: 'comment', - id: `comment:${comment.id}`, - createdAt: comment.createdAt, - comment, - index - }) - ), - ...timelineItems.map( - (activity, index): IssueConversationEntry => ({ - kind: 'activity', - id: `activity:${activity.id}`, - createdAt: activity.createdAt, - activity, - index: comments.length + index - }) - ) - ].sort((a, b) => { - const diff = getTimelineSortValue(a.createdAt) - getTimelineSortValue(b.createdAt) - return diff === 0 ? a.index - b.index : diff - }) -} - -function getTimelineTargetLabel(target: GitHubIssueTimelineTarget): string { - const prefix = target.type === 'pr' ? 'PR' : 'issue' - const title = target.title ? ` ${target.title}` : '' - return `${prefix} #${target.number}${title}` -} - -function getTimelineStateReasonLabel(reason: string | null | undefined): string | null { - if (reason === 'completed') { - return translate('auto.components.GitHubItemDialog.timeline.completed', 'as completed') - } - if (reason === 'not_planned') { - return translate('auto.components.GitHubItemDialog.timeline.notPlanned', 'as not planned') - } - return null -} - -function ConversationTab({ - item, - repoPath, - sourceContext, - body, - comments, - timelineItems, - files, - headSha, - baseSha, - loading, - detailsLoaded, - checks, - localState, - onStateChange, - projectOrigin, - onMutated, - onChecksUpdated, - onBodyUpdated, - onCommentAdded, - onReviewersRequested -}: { - item: GitHubWorkItem - repoPath: string | null - repoId: string | null - sourceContext?: TaskSourceContext | null - body: string - comments: PRComment[] - timelineItems?: GitHubIssueTimelineItem[] - files: GitHubPRFile[] - headSha: string | undefined - baseSha: string | undefined - loading: boolean - detailsLoaded: boolean - checks: GitHubWorkItemDetails['checks'] - localState: GitHubWorkItem['state'] - onStateChange: (state: GitHubWorkItem['state']) => void - projectOrigin: GitHubItemDialogProjectOrigin | undefined - onMutated: () => void - onChecksUpdated: (checks: PRCheckDetail[]) => void - onBodyUpdated: (body: string) => void - onCommentAdded: (comment: PRComment) => void - onReviewersRequested: (reviewRequests: GitHubAssignableUser[]) => void -}): React.JSX.Element { - const authorLabel = item.author ?? 'unknown' - const [replyingTo, setReplyingTo] = useState(null) - const [commentFilter, setCommentFilter] = useState('all') - const [bodyDraft, setBodyDraft] = useState(body) - const [bodyEditing, setBodyEditing] = useState(false) - const [bodySaving, setBodySaving] = useState(false) - const canUseRepoMutationContext = canUseGitHubRepoContext(repoPath, sourceContext) - const botAuthorOverrides = usePRBotAuthorOverrides() - const commentCounts = useMemo( - () => getPRCommentAudienceCounts(comments, botAuthorOverrides), - [botAuthorOverrides, comments] - ) - const visibleComments = useMemo( - () => filterPRCommentsByAudience(comments, commentFilter, botAuthorOverrides), - [botAuthorOverrides, commentFilter, comments] - ) - const visibleCommentGroups = useMemo(() => groupPRComments(visibleComments), [visibleComments]) - const resolvedTimelineItems = timelineItems ?? EMPTY_GITHUB_ISSUE_TIMELINE_ITEMS - const issueConversationEntries = useMemo( - () => getIssueConversationEntries(comments, resolvedTimelineItems), - [comments, resolvedTimelineItems] - ) - const replyTargetComments = getCommentReplyTargetCandidates(item.type, comments, visibleComments) - const resolvedReplyingTo = resolveCommentReplyTarget(replyingTo, replyTargetComments) - - if (resolvedReplyingTo !== replyingTo) { - // Why: filters/refetches can hide the active reply target; clear before paint so a stale composer doesn't flash for the wrong comment set. - setReplyingTo(resolvedReplyingTo) - } - - const resolvedBodyDraft = resolveGitHubBodyDraft(bodyDraft, body, bodyEditing) - if (shouldSyncGitHubBodyDraft(bodyDraft, body, bodyEditing)) { - // Why: a background refresh can change the body while the editor is closed; reconcile before paint so reopening never sees a stale draft. - setBodyDraft(resolvedBodyDraft) - } - - const bodySlug = useMemo(() => parseOwnerRepoFromItemUrl(item.url), [item.url]) - const prRepo = useMemo(() => resolvePullRequestRepo(item, projectOrigin), [item, projectOrigin]) - const markdownGitHubRepo = useMemo( - () => (projectOrigin ? { owner: projectOrigin.owner, repo: projectOrigin.repo } : bodySlug), - [bodySlug, projectOrigin] - ) - const canEditBody = - item.type === 'pr' - ? Boolean(projectOrigin || bodySlug) - : Boolean(projectOrigin || canUseRepoMutationContext) - const bodyChanged = resolvedBodyDraft !== body - - const handleSaveBody = useCallback(async (): Promise => { - if (bodySaving || !bodyChanged) { - setBodyEditing(false) - return - } - setBodySaving(true) - try { - await runWorkItemBodyUpdate({ - item, - repoPath, - sourceContext, - projectOrigin, - body: resolvedBodyDraft, - parsedSlug: bodySlug - }) - onBodyUpdated(resolvedBodyDraft) - setBodyEditing(false) - useAppStore.getState().recordFeatureInteraction('github-tasks') - toast.success( - translate('auto.components.GitHubItemDialog.5221548274', 'Description updated.') - ) - } catch (err) { - toast.error( - err instanceof Error - ? err.message - : translate( - 'auto.components.GitHubItemDialog.58c73cb0d8', - 'Failed to update description.' - ) - ) - } finally { - setBodySaving(false) - } - }, [ - bodyChanged, - resolvedBodyDraft, - bodySaving, - bodySlug, - item, - onBodyUpdated, - projectOrigin, - repoPath, - sourceContext - ]) - - const handleReply = useCallback( - async (comment: PRComment, replyBody: string): Promise => { - if (!canUseRepoMutationContext) { - toast.error( - translate( - 'auto.components.GitHubItemDialog.745c9089ec', - 'Unable to reply without a repository path.' - ) - ) - return false - } - // Why: nest under review threads (path/threadId/discussion_r); never post a - // separate top-level conversation comment for those. - const isReviewThreadReply = item.type === 'pr' && canPostPRReviewThreadReply(comment) - const result = isReviewThreadReply - ? await addPRReviewCommentReplyForRepo({ - repoPath: repoPath ?? '', - repoId: item.repoId, - sourceContext, - prNumber: item.number, - prRepo, - commentId: comment.id, - body: replyBody, - threadId: comment.threadId, - path: comment.path, - line: comment.line - }) - : await addIssueCommentForRepo({ - repoPath: repoPath ?? '', - repoId: item.repoId, - sourceContext, - number: item.number, - // Why: a GitHub App login carries a [bot] suffix that never resolves as a mention. - body: buildPRCommentConversationReplyBody(comment.author, replyBody), - type: item.type, - prRepo - }) - - if (!result.ok) { - toast.error( - result.error || - translate('auto.components.GitHubItemDialog.283699bc82', 'Failed to post reply.') - ) - return false - } - onCommentAdded( - isReviewThreadReply ? attachPRReviewReplyParent(result.comment, comment) : result.comment - ) - setReplyingTo(null) - toast.success(translate('auto.components.GitHubItemDialog.10f4ff5be8', 'Reply posted.')) - return true - }, - [ - canUseRepoMutationContext, - item.number, - item.repoId, - item.type, - onCommentAdded, - prRepo, - repoPath, - sourceContext - ] - ) - - const rightPanel = - item.type === 'pr' ? ( -
- - - - -
- ) : null - - const renderCommentCard = (comment: PRComment, isReply = false): React.JSX.Element => ( -
-
- {comment.authorAvatarUrl ? ( - {comment.author} - ) : ( -
- )} - - {comment.author} - - - · {formatRelativeTime(comment.createdAt)} - - {comment.path && ( - - {comment.path.split('/').pop()} - {comment.line - ? translate('auto.components.GitHubItemDialog.136542c9ba', ':L{{value0}}', { - value0: comment.line - }) - : ''} - - )} - {comment.isResolved && ( - - {translate('auto.components.GitHubItemDialog.68cb993d61', 'resolved')} - - )} -
- - - - - - {translate('auto.components.GitHubItemDialog.bca8eb39ac', 'Reply to comment')} - - - {comment.url && ( - - - - - - {translate('auto.components.GitHubItemDialog.a154ec5224', 'Open comment on GitHub')} - - - )} -
-
-
- - - - {resolvedReplyingTo === comment.id && ( - setReplyingTo(null)} - onSubmit={(replyBody) => handleReply(comment, replyBody)} - /> - )} -
-
- ) - - const renderCommentGroup = (group: PRCommentGroup): React.JSX.Element => { - const cards = - group.kind === 'thread' - ? [ - renderCommentCard(group.root), - ...group.replies.map((reply) => renderCommentCard(reply, true)) - ] - : [renderCommentCard(group.comment)] - - if (!isResolvedPRCommentGroup(group)) { - return ( -
- {cards} -
- ) - } - - const root = getPRCommentGroupRoot(group) - const count = getPRCommentGroupCount(group) - return ( - - - - - {translate('auto.components.GitHubItemDialog.228e2f59d3', 'Resolved')}{' '} - {group.kind === 'thread' - ? translate('auto.components.GitHubItemDialog.28d0d3374f', 'thread') - : translate('auto.components.GitHubItemDialog.e2bf3e41a9', 'comment')}{' '} - {translate('auto.components.GitHubItemDialog.0ae387d8ca', 'by')} {root.author} - {count > 1 ? ` (${count})` : ''} - - - - {cards} - - - - ) - } - - const renderTimelineTarget = (target: GitHubIssueTimelineTarget | undefined): React.ReactNode => { - if (!target) { - return null - } - return ( - - ) - } - - const renderTimelineActivityMessage = (activity: GitHubIssueTimelineItem): React.ReactNode => { - const assignee = - activity.assignee ?? translate('auto.components.GitHubItemDialog.timeline.someone', 'someone') - if (activity.event === 'assigned') { - return ( - <> - {translate('auto.components.GitHubItemDialog.timeline.assigned', 'assigned')}{' '} - {assignee} - - ) - } - if (activity.event === 'unassigned') { - return ( - <> - {translate('auto.components.GitHubItemDialog.timeline.unassigned', 'unassigned')}{' '} - {assignee} - - ) - } - if (activity.event === 'mentioned' || activity.event === 'cross-referenced') { - return ( - <> - {translate('auto.components.GitHubItemDialog.timeline.mentioned', 'mentioned this')} - {activity.source ? ( - <> - {' '} - {translate('auto.components.GitHubItemDialog.timeline.in', 'in')}{' '} - {renderTimelineTarget(activity.source)} - - ) : null} - - ) - } - if (activity.event === 'closed') { - const stateReason = getTimelineStateReasonLabel(activity.stateReason) - return ( - <> - {translate('auto.components.GitHubItemDialog.timeline.closed', 'closed this')} - {stateReason ? ` ${stateReason}` : ''} - {activity.closer ? ( - <> - {' '} - {translate('auto.components.GitHubItemDialog.timeline.in', 'in')}{' '} - {renderTimelineTarget(activity.closer)} - - ) : null} - - ) - } - if (activity.event === 'reopened') { - return translate('auto.components.GitHubItemDialog.timeline.reopened', 'reopened this') - } - const hasFrom = Boolean(activity.previousColumnName) - const hasTo = Boolean(activity.columnName) - return ( - <> - {translate('auto.components.GitHubItemDialog.timeline.moved', 'moved this')} - {hasFrom ? ( - <> - {' '} - {translate('auto.components.GitHubItemDialog.timeline.from', 'from')}{' '} - {activity.previousColumnName} - - ) : null} - {hasTo ? ( - <> - {' '} - {translate('auto.components.GitHubItemDialog.timeline.to', 'to')}{' '} - {activity.columnName} - - ) : null} - {activity.projectName ? ( - <> - {' '} - {translate('auto.components.GitHubItemDialog.timeline.in', 'in')}{' '} - {activity.projectName} - - ) : null} - - ) - } - - const renderTimelineActivity = (activity: GitHubIssueTimelineItem): React.JSX.Element => { - const Icon = - activity.event === 'assigned' - ? UserPlus - : activity.event === 'unassigned' - ? UserMinus - : activity.event === 'closed' - ? CheckCircle2 - : activity.event === 'reopened' - ? CircleDot - : activity.event === 'moved_columns_in_project' - ? MoveRight - : Link2 - return ( -
- - - - {activity.actorAvatarUrl ? ( - - ) : null} -
-
- {activity.actor} - {renderTimelineActivityMessage(activity)} - - {formatRelativeTime(activity.createdAt)} - -
-
-
- ) - } - - const renderIssueConversationEntry = (entry: IssueConversationEntry): React.JSX.Element => - entry.kind === 'comment' - ? renderCommentCard(entry.comment) - : renderTimelineActivity(entry.activity) - - return ( -
-
-
-
- {authorLabel} - - {translate('auto.components.GitHubItemDialog.8223320f8d', 'updated')}{' '} - {formatRelativeTime(item.updatedAt)} - - {canEditBody && !loading && detailsLoaded ? ( - bodyEditing ? ( -
- - -
- ) : ( - - - - - - {translate('auto.components.GitHubItemDialog.4d555d3796', 'Edit description')} - - - ) - ) : null} -
-
- {loading && !detailsLoaded ? ( -
- -
- ) : bodyEditing ? ( - void handleSaveBody()} - /> - ) : body.trim() ? ( - - ) : ( - - {translate( - 'auto.components.GitHubItemDialog.9b9cb55994', - 'No description provided.' - )} - - )} -
-
- - {detailsLoaded ? ( - <> -
- {item.type === 'issue' ? ( - - ) : ( - - )} - - {item.type === 'issue' - ? translate('auto.components.GitHubItemDialog.timeline.activity', 'Activity') - : translate('auto.components.GitHubItemDialog.1506916c09', 'Comments')} - - {comments.length + (item.type === 'issue' ? resolvedTimelineItems.length : 0) > 0 && ( - - {comments.length + (item.type === 'issue' ? resolvedTimelineItems.length : 0)} - - )} -
- - {item.type === 'pr' && comments.length > 0 && ( -
- {getPrCommentAudienceFilters().map((filter) => { - const isActive = commentFilter === filter.value - return ( - - ) - })} -
- )} - - {item.type === 'issue' ? ( - issueConversationEntries.length === 0 ? ( -
- {translate( - 'auto.components.GitHubItemDialog.timeline.noActivity', - 'No activity yet.' - )} -
- ) : ( -
- {issueConversationEntries.map(renderIssueConversationEntry)} -
- ) - ) : comments.length === 0 ? ( -
- {translate('auto.components.GitHubItemDialog.5a94f3d0e9', 'No comments yet.')} -
- ) : visibleComments.length === 0 ? ( -
- {getPRCommentAudienceEmptyLabel(commentFilter)} -
- ) : ( -
- {visibleCommentGroups.map(renderCommentGroup)} -
- )} - - ) : null} - - {detailsLoaded && canUseRepoMutationContext && ( - - )} -
- - {rightPanel} -
- ) -} - -function PRActionsPanel({ - item, - repoPath, - repoId, - sourceContext, - projectOrigin, - localState, - onStateChange, - onMutated -}: { - item: GitHubWorkItem - repoPath: string | null - repoId: string | null - sourceContext?: TaskSourceContext | null - projectOrigin: GitHubItemDialogProjectOrigin | undefined - localState: GitHubWorkItem['state'] - onStateChange: (state: GitHubWorkItem['state']) => void - onMutated: () => void -}): React.JSX.Element { - const [statePending, setStatePending] = useState(false) - const [mergePending, setMergePending] = useState(false) - const patchWorkItem = useAppStore((s) => s.patchWorkItem) - const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent) - const confirm = useConfirmationDialog() - const actionItem = { ...item, state: localState } - const mergePresentation = presentGitHubPRMergeState(actionItem) - const mergeMethods = resolveGitHubPRMergeMethods(actionItem.mergeMethodSettings) - const sourceSettings = useAppStore( - useShallow((s) => - getGitHubMutationRoutingSettings(s, item.repoId ?? repoId ?? null, sourceContext) - ) - ) - const mergeTarget = getActiveRuntimeTarget(sourceSettings) - const prRepo = resolvePullRequestRepo(item, projectOrigin) - const canMutateWithRepoContext = - !!repoPath || !!projectOrigin || mergeTarget.kind === 'environment' - const canMutateState = localState !== 'merged' && canMutateWithRepoContext - const nextState: 'open' | 'closed' = localState === 'closed' ? 'open' : 'closed' - const canMergeWithRepoContext = !!repoPath || mergeTarget.kind === 'environment' - const mergeDisabled = - !canMergeWithRepoContext || mergePending || !mergePresentation.directMergeAvailable - - const patchProjectRowIfNeeded = useCallback( - (state: GitHubWorkItem['state']) => { - if (!projectOrigin) { - return - } - patchProjectRowContent(projectOrigin.cacheKey, projectOrigin.projectItemId, { state }) - }, - [patchProjectRowContent, projectOrigin] - ) - - const applyStatePatch = useCallback( - (state: GitHubWorkItem['state']) => { - onStateChange(state) - patchWorkItem(item.id, { state }, item.repoId, { sourceContext }) - patchProjectRowIfNeeded(state) - }, - [item.id, item.repoId, onStateChange, patchProjectRowIfNeeded, patchWorkItem, sourceContext] - ) - - const handleStateChange = async (): Promise => { - if (!canMutateState || statePending) { - return - } - const label = nextState === 'closed' ? 'Close' : 'Reopen' - const confirmed = await confirm({ - title: translate( - 'auto.components.GitHubItemDialog.03d7216d62', - '{{value0}} PR #{{value1}}?', - { value0: label, value1: item.number } - ), - description: - nextState === 'closed' - ? translate( - 'auto.components.GitHubItemDialog.de45fedf7b', - 'This will close the pull request on GitHub.' - ) - : translate( - 'auto.components.GitHubItemDialog.b6f1b7adbd', - 'This will reopen the pull request on GitHub.' - ), - confirmLabel: label, - confirmVariant: nextState === 'closed' ? 'destructive' : 'default' - }) - if (!confirmed) { - return - } - const previousState = localState - setStatePending(true) - // Why: without registry authority a search-lagged Tasks refetch silently - // reverts this row to its pre-mutation state (STA-3343). - const authority = assertTaskPageGitHubDialogStateAuthority({ - repoId: item.repoId, - itemId: item.id, - state: nextState, - sourceContext - }) - applyStatePatch(nextState) - try { - await runPullRequestStateUpdate({ - repoPath, - repoId, - sourceContext, - projectOrigin, - number: item.number, - prRepo, - updates: { state: nextState } - }) - useAppStore.getState().recordFeatureInteraction('github-tasks') - toast.success( - nextState === 'closed' - ? translate('auto.components.GitHubItemDialog.9f88657c4e', 'Pull request closed') - : translate('auto.components.GitHubItemDialog.bd3b4492a0', 'Pull request reopened') - ) - onMutated() - } catch (err) { - if (authority.revert()) { - applyStatePatch(previousState) - } - toast.error( - err instanceof Error - ? err.message - : translate('auto.components.GitHubItemDialog.e9b7cb7d17', 'Failed to {{value0}} PR', { - value0: label.toLowerCase() - }) - ) - } finally { - setStatePending(false) - } - } - - const handleMerge = async (method: GitHubPRMergeMethod): Promise => { - if (mergeDisabled) { - return - } - const label = GITHUB_PR_MERGE_METHOD_LABELS[method] - const confirmed = await confirm({ - title: translate( - 'auto.components.GitHubItemDialog.03d7216d62', - '{{value0}} PR #{{value1}}?', - { value0: label, value1: item.number } - ), - description: translate( - 'auto.components.GitHubItemDialog.a27ee5ca1a', - 'This will update the pull request on GitHub.' - ), - confirmLabel: label - }) - if (!confirmed) { - return - } - setMergePending(true) - try { - const result = - mergeTarget.kind === 'environment' - ? await callRuntimeRpc>>( - mergeTarget, - 'github.mergePR', - { - repo: getGitHubRuntimeRepoId(sourceContext, repoId ?? item.repoId), - prNumber: item.number, - method, - prRepo - }, - { timeoutMs: 30_000 } - ) - : await window.api.gh.mergePR({ - repoPath: repoPath ?? '', - repoId: repoId ?? undefined, - sourceContext, - prNumber: item.number, - method, - prRepo - }) - if (!result.ok) { - toast.error(result.error) - return - } - // Why: merge is confirmed here; hold 'merged' against search-lagged refetches. - assertTaskPageGitHubDialogStateAuthority({ - repoId: item.repoId, - itemId: item.id, - state: 'merged', - sourceContext - }) - applyStatePatch('merged') - if (mergeTarget.kind === 'environment') { - notifyWorkItemDetailsMutation( - { - repoPath: repoPath ?? '', - repoId: item.repoId, - sourceContext, - type: 'pr', - number: item.number - }, - { local: false } - ) - } - useAppStore.getState().recordFeatureInteraction('github-tasks') - toast.success(translate('auto.components.GitHubItemDialog.dbe5e2448e', 'Pull request merged')) - onMutated() - } catch { - toast.error( - translate('auto.components.GitHubItemDialog.aba792c8b3', 'Failed to merge pull request') - ) - } finally { - setMergePending(false) - } - } - - const handleAutoMerge = async (): Promise => { - if (!canMergeWithRepoContext || !mergePresentation.autoMergeAction) { - return - } - const enabled = mergePresentation.autoMergeAction.kind === 'enable' - setMergePending(true) - try { - const result = - mergeTarget.kind === 'environment' - ? await callRuntimeRpc>>( - mergeTarget, - 'github.setPRAutoMerge', - { - repo: getGitHubRuntimeRepoId(sourceContext, repoId ?? item.repoId), - prNumber: item.number, - enabled, - method: enabled ? mergeMethods.defaultMethod : undefined, - prRepo - }, - { timeoutMs: 30_000 } - ) - : await window.api.gh.setPRAutoMerge({ - repoPath: repoPath ?? '', - repoId: repoId ?? undefined, - sourceContext, - prNumber: item.number, - enabled, - method: enabled ? mergeMethods.defaultMethod : undefined, - prRepo - }) - if (!result.ok) { - toast.error(result.error) - return - } - if (mergeTarget.kind === 'environment') { - notifyWorkItemDetailsMutation( - { - repoPath: repoPath ?? '', - repoId: item.repoId, - sourceContext, - type: 'pr', - number: item.number - }, - { local: false } - ) - } - useAppStore.getState().recordFeatureInteraction('github-tasks') - toast.success( - enabled - ? translate('auto.components.GitHubItemDialog.a35ea5a0f6', 'Auto-merge enabled') - : translate('auto.components.GitHubItemDialog.4b390bd50d', 'Auto-merge disabled') - ) - onMutated() - } catch { - toast.error( - enabled - ? translate('auto.components.GitHubItemDialog.825a8fb8cd', 'Failed to enable auto-merge') - : translate('auto.components.GitHubItemDialog.ce360fc318', 'Failed to disable auto-merge') - ) - } finally { - setMergePending(false) - } - } - - return ( - - ) -} - -function CommentReplyForm({ - className, - placeholder, - onCancel, - onSubmit -}: { - className?: string - placeholder: string - onCancel: () => void - onSubmit: (body: string) => Promise -}): React.JSX.Element { - const [body, setBody] = useState('') - const [submitting, setSubmitting] = useState(false) - const mountedRef = useMountedRef() - - const submit = useCallback(async () => { - const bodyState = getCommentBodySubmitState(body) - if (bodyState.status === 'empty' || submitting) { - return - } - if (bodyState.status === 'too-large-leading-whitespace') { - toast.error( - translate( - 'auto.components.GitHubItemDialog.commentTooLarge', - 'Comment is too large to submit safely.' - ) - ) - return - } - setSubmitting(true) - try { - const ok = await onSubmit(bodyState.body) - if (!mountedRef.current) { - return - } - if (ok) { - setBody('') - } - } finally { - if (mountedRef.current) { - setSubmitting(false) - } - } - }, [body, mountedRef, onSubmit, submitting]) - const canSubmitReply = hasBoundedCommentBodyText(body) - - return ( -
- void submit()} - /> -
- - -
-
- ) -} - -function ChecksTab({ - item, - repoPath, - repoId, - sourceContext, - headSha, - checks, - loading, - variant = 'compact', - onChecksUpdated -}: { - item: GitHubWorkItem - repoPath: string | null - repoId: string | null - sourceContext?: TaskSourceContext | null - headSha: string | undefined - checks: GitHubWorkItemDetails['checks'] - loading: boolean - variant?: 'compact' | 'page' - onChecksUpdated: (checks: PRCheckDetail[]) => void -}): React.JSX.Element { - const [fixingChecks, setFixingChecks] = useState(false) - const mountedRef = useMountedRef() - const prRepo = useMemo(() => resolvePullRequestRepo(item), [item]) - const nextCheckDetailsRequestIdRef = useRef(0) - const checkDetailsContextKey = [ - sourceContext ? getTaskSourceCacheScope(sourceContext) : 'local', - repoId ?? item.repoId ?? '', - repoPath ?? '', - prRepo ? githubRepoIdentityKey(prRepo) : '', - item.id, - item.number, - headSha ?? '' - ].join('\0') - const [checksState, setChecksState] = useState(() => - createGitHubChecksTabState(checks, checkDetailsContextKey) - ) - const resolvedChecksState = resolveGitHubChecksTabState( - checksState, - checks, - checkDetailsContextKey - ) - const committedChecksContextOwnerRef = useRef(resolvedChecksState.contextOwner) - const nextChecksRefreshRequestIdRef = useRef(0) - const activeChecksRefreshRequestIdRef = useRef(null) - const [refreshingOwner, setRefreshingOwner] = useState<{ - contextOwner: object - requestId: number - } | null>(null) - const refreshing = refreshingOwner?.contextOwner === resolvedChecksState.contextOwner - const [rerunningOwner, setRerunningOwner] = useState(null) - const rerunning = rerunningOwner === resolvedChecksState.contextOwner - useLayoutEffect(() => { - committedChecksContextOwnerRef.current = resolvedChecksState.contextOwner - }, [resolvedChecksState.contextOwner]) - if (resolvedChecksState !== checksState) { - // Why: a parent check refresh replaces the source list; reset local state before stale rows/details can paint. - setChecksState(resolvedChecksState) - } - const { localChecks, expandedCheckKey, detailsByCheckKey } = resolvedChecksState - const list = useMemo(() => localChecks ?? checks ?? [], [checks, localChecks]) - const runtimeHost = getGitHubSourceRuntimeHost(sourceContext) - const canUseChecksRepoContext = canUseGitHubRepoContext(repoPath, sourceContext) - const sorted = sortChecksBySeverity(list) - const failedChecks = getBrokenChecks(list) - const counts = getCheckCounts(list) - const summaryLabel = getChecksSummaryLabel(list) - // Why: keying the green tick off `list.length` painted an all-neutral PR green above the words - // "0 of N checks passing"; nothing passed, so it reads unresolved like the checks pill does. - const SummaryIcon = - counts.failing > 0 - ? CHECK_ICON.failure - : counts.needsAction > 0 - ? CHECK_ICON.action_required - : counts.pending > 0 - ? CHECK_ICON.pending - : counts.passing > 0 - ? CHECK_ICON.success - : CircleDashed - const summaryColor = - counts.failing > 0 - ? CHECK_COLOR.failure - : counts.needsAction > 0 - ? CHECK_COLOR.action_required - : counts.pending > 0 - ? CHECK_COLOR.pending - : counts.passing > 0 - ? CHECK_COLOR.success - : 'text-muted-foreground' - const canFixBrokenChecks = Boolean((repoId ?? item.repoId) && failedChecks.length > 0) - - const handleRefresh = useCallback( - async (expectedContextOwner?: object): Promise => { - if (!canUseChecksRepoContext) { - toast.error( - translate( - 'auto.components.GitHubItemDialog.e7007aa1d8', - 'Unable to refresh checks without a repository path.' - ) - ) - return null - } - const refreshContextOwner = expectedContextOwner ?? committedChecksContextOwnerRef.current - if (committedChecksContextOwnerRef.current !== refreshContextOwner) { - return null - } - const refreshRequestId = ++nextChecksRefreshRequestIdRef.current - activeChecksRefreshRequestIdRef.current = refreshRequestId - setRefreshingOwner({ contextOwner: refreshContextOwner, requestId: refreshRequestId }) - try { - const nextChecks = (await (runtimeHost - ? callRuntimeRpc( - { kind: 'environment', environmentId: runtimeHost.environmentId }, - 'github.prChecks', - { - repo: getGitHubRuntimeRepoId(sourceContext, repoId ?? item.repoId), - prNumber: item.number, - headSha, - prRepo, - noCache: true - }, - { timeoutMs: 30_000 } - ) - : window.api.gh.prChecks({ - repoPath: repoPath ?? '', - repoId: repoId ?? undefined, - sourceContext, - prNumber: item.number, - headSha, - prRepo, - noCache: true - }))) as PRCheckDetail[] - if ( - !mountedRef.current || - committedChecksContextOwnerRef.current !== refreshContextOwner || - activeChecksRefreshRequestIdRef.current !== refreshRequestId - ) { - return null - } - setChecksState((current) => - current.contextOwner === refreshContextOwner - ? updateGitHubChecksTabLocalChecks(resetGitHubChecksTabForSource(current), nextChecks) - : current - ) - onChecksUpdated(nextChecks) - return nextChecks - } catch (err) { - if ( - mountedRef.current && - committedChecksContextOwnerRef.current === refreshContextOwner && - activeChecksRefreshRequestIdRef.current === refreshRequestId - ) { - toast.error( - err instanceof Error - ? err.message - : translate('auto.components.GitHubItemDialog.0bbdc673c1', 'Failed to refresh checks') - ) - } - return null - } finally { - if (activeChecksRefreshRequestIdRef.current === refreshRequestId) { - activeChecksRefreshRequestIdRef.current = null - } - if (mountedRef.current) { - setRefreshingOwner((current) => - current?.requestId === refreshRequestId ? null : current - ) - } - } - }, - [ - canUseChecksRepoContext, - headSha, - item.number, - item.repoId, - mountedRef, - onChecksUpdated, - runtimeHost, - prRepo, - repoId, - repoPath, - sourceContext - ] - ) - - const handleRerun = useCallback( - async (failedOnly: boolean): Promise => { - if (!canUseChecksRepoContext || rerunning) { - return - } - const rerunContextOwner = committedChecksContextOwnerRef.current - setRerunningOwner(rerunContextOwner) - try { - const result = runtimeHost - ? await callRuntimeRpc>>( - { kind: 'environment', environmentId: runtimeHost.environmentId }, - 'github.rerunPRChecks', - { - repo: getGitHubRuntimeRepoId(sourceContext, repoId ?? item.repoId), - prNumber: item.number, - headSha, - failedOnly, - prRepo - }, - { timeoutMs: 30_000 } - ) - : await window.api.gh.rerunPRChecks({ - repoPath: repoPath ?? '', - repoId: repoId ?? undefined, - sourceContext, - prNumber: item.number, - headSha, - failedOnly, - prRepo - }) - if (!mountedRef.current || committedChecksContextOwnerRef.current !== rerunContextOwner) { - return - } - if (!result.ok) { - toast.error(result.error) - return - } - toast.success( - result.count === 1 - ? translate('auto.components.GitHubItemDialog.ddafe851e1', 'Check rerun requested') - : translate('auto.components.GitHubItemDialog.e463ec935f', 'Check reruns requested') - ) - await handleRefresh(rerunContextOwner) - } catch (err) { - if (mountedRef.current && committedChecksContextOwnerRef.current === rerunContextOwner) { - toast.error( - err instanceof Error - ? err.message - : translate('auto.components.GitHubItemDialog.9e7c221b8d', 'Failed to rerun checks') - ) - } - } finally { - if (mountedRef.current) { - setRerunningOwner((current) => (current === rerunContextOwner ? null : current)) - } - } - }, - [ - canUseChecksRepoContext, - handleRefresh, - headSha, - item.number, - item.repoId, - mountedRef, - prRepo, - runtimeHost, - rerunning, - repoId, - repoPath, - sourceContext - ] - ) - - const handleFixBrokenChecks = useCallback(async (): Promise => { - const targetRepoId = repoId ?? item.repoId - if (!targetRepoId || fixingChecks) { - return - } - if (failedChecks.length === 0) { - toast.message( - translate('auto.components.GitHubItemDialog.1690fd7f4a', 'No broken checks to fix.') - ) - return - } - - const basePrompt = buildFixBrokenChecksPrompt({ - reviewKind: 'PR', - reviewNumber: item.number, - reviewTitle: item.title, - reviewUrl: item.url, - checks: list - }) - setFixingChecks(true) - try { - const started = await startFixChecksAgent({ - item, - repoId: targetRepoId, - basePrompt, - launchSource: 'task_page', - telemetrySource: 'sidebar', - openModalFallback: () => { - toast.error( - translate( - 'auto.components.GitHubItemDialog.06482d6190', - 'Unable to create a fix workspace automatically.' - ) - ) - } - }) - if (started) { - toast.success( - translate( - 'auto.components.GitHubItemDialog.28986b3747', - 'Started an AI agent for the broken checks.' - ) - ) - } - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - console.error('Failed to start fix checks agent', err) - toast.error( - translate( - 'auto.components.GitHubItemDialog.03e542fcfe', - 'Failed to start an AI agent for the broken checks: {{value0}}', - { value0: message } - ) - ) - } finally { - setFixingChecks(false) - } - }, [failedChecks.length, fixingChecks, item, list, repoId]) - - const requestCheckDetails = useCallback( - (check: PRCheckDetail, key: string): void => { - if (!canUseChecksRepoContext || (!check.checkRunId && !check.workflowRunId && !check.url)) { - return - } - const requestId = ++nextCheckDetailsRequestIdRef.current - const commit = (next: Omit): void => { - if (!mountedRef.current) { - return - } - setChecksState((current) => settleGitHubChecksTabDetails(current, key, requestId, next)) - } - setChecksState((current) => beginGitHubChecksTabDetails(current, key, requestId)) - const detailsRequest = withGitHubCheckDetailsTimeout((signal) => - runtimeHost - ? callRuntimeRpc>>( - { kind: 'environment', environmentId: runtimeHost.environmentId }, - 'github.prCheckDetails', - { - repo: getGitHubRuntimeRepoId(sourceContext, repoId ?? item.repoId), - checkRunId: check.checkRunId, - workflowRunId: check.workflowRunId, - checkName: check.name, - url: check.url, - prRepo - }, - { timeoutMs: 30_000, signal } - ) - : window.api.gh.prCheckDetails({ - repoPath: repoPath ?? '', - repoId: repoId ?? undefined, - sourceContext, - checkRunId: check.checkRunId, - workflowRunId: check.workflowRunId, - checkName: check.name, - url: check.url, - prRepo - }) - ) - void detailsRequest - .then((details) => { - commit({ - loading: false, - details, - error: details - ? null - : translate( - 'auto.components.GitHubItemDialog.e15a8b77ef', - 'No inline details are available for this check.' - ) - }) - }) - .catch((err) => { - commit({ - loading: false, - details: null, - error: - err instanceof Error - ? err.message - : translate( - 'auto.components.GitHubItemDialog.e45324fbed', - 'Failed to load check details.' - ) - }) - }) - }, - [ - canUseChecksRepoContext, - item.repoId, - mountedRef, - runtimeHost, - prRepo, - repoId, - repoPath, - sourceContext - ] - ) - - const handleToggleCheckDetails = useCallback( - (check: PRCheckDetail): void => { - const key = getCheckDetailsKey(check) - setChecksState((current) => toggleGitHubChecksTabExpandedKey(current, key)) - if (detailsByCheckKey[key]) { - return - } - requestCheckDetails(check, key) - }, - [detailsByCheckKey, requestCheckDetails] - ) - - const refreshAction = ( - - - - - - {translate('auto.components.GitHubItemDialog.9a1004fc76', 'Refresh checks')} - - - ) - const fixBrokenChecksAction = - failedChecks.length > 0 || fixingChecks ? ( - - - - - - {translate( - 'auto.components.GitHubItemDialog.f4b1292569', - 'Start the default AI agent on these checks' - )} - - - ) : null - const rerunAction = - list.length > 0 || rerunning ? ( - - - - - - void handleRerun(true)} - > - - {translate('auto.components.GitHubItemDialog.e31651a224', 'Rerun failed checks')} - - void handleRerun(false)}> - - {translate('auto.components.GitHubItemDialog.71c11aff84', 'Rerun all checks')} - - - - ) : null - const secondaryActions = - variant === 'compact' && !fixBrokenChecksAction ? null : fixBrokenChecksAction || - rerunAction ? ( -
- {fixBrokenChecksAction} - {variant === 'page' ? rerunAction : null} -
- ) : null - const actions = ( -
- {refreshAction} - {fixBrokenChecksAction} - {rerunAction} -
- ) - const compactHeader = ( -
-
-
- 0 && counts.failing === 0 && 'animate-spin' - )} - /> -
-
- {translate('auto.components.GitHubItemDialog.4bd1f5b055', 'Checks')} -
- {list.length > 0 && ( -
- {summaryLabel} -
- )} -
-
-
- {refreshAction} - {list.length > 0 && ( -
- {rerunAction} -
- )} -
-
- {secondaryActions ? ( -
{secondaryActions}
- ) : null} -
- ) - - const renderCheckRow = (check: PRCheckDetail): React.JSX.Element => { - const conclusion = getCheckConclusion(check) - const Icon = CHECK_ICON[conclusion] ?? CircleDashed - const color = CHECK_COLOR[conclusion] ?? 'text-muted-foreground' - const statusLabel = getCheckStatusLabel(check) - const key = getCheckDetailsKey(check) - const expanded = expandedCheckKey === key - const detailsState = detailsByCheckKey[key] - return ( -
- - {expanded && renderCheckDetails(check, detailsState)} -
- ) - } - - const renderCheckDetails = ( - check: PRCheckDetail, - state: CheckDetailsLoadState | undefined - ): React.JSX.Element => { - const details = state?.details - const openUrl = details?.detailsUrl ?? details?.url ?? check.url - const startedAt = formatCheckTimestamp(details?.startedAt) - const completedAt = formatCheckTimestamp(details?.completedAt) - const detailsStatusCheck: PRCheckDetail = { - ...check, - status: (details?.status as PRCheckDetail['status'] | undefined) ?? check.status, - conclusion: - (details?.conclusion as PRCheckDetail['conclusion'] | undefined) ?? check.conclusion - } - const hasOutput = Boolean(details?.title || details?.summary || details?.text) - const hasAnnotations = (details?.annotations.length ?? 0) > 0 - const hasJobs = (details?.jobs.length ?? 0) > 0 - - return ( -
- {state?.loading && !state.error ? ( -
- - {translate('auto.components.GitHubItemDialog.934d87ab96', 'Loading check details…')} -
- ) : ( -
-
- - {translate('auto.components.GitHubItemDialog.9c3ba11a05', 'Status:')}{' '} - {details ? getCheckStatusLabel(detailsStatusCheck) : getCheckStatusLabel(check)} - - {startedAt && ( - - {translate('auto.components.GitHubItemDialog.4812814bc8', 'Started')} {startedAt} - - )} - {completedAt && ( - - {translate('auto.components.GitHubItemDialog.0f478f5efa', 'Completed')}{' '} - {completedAt} - - )} - {check.checkRunId && ( - - {translate('auto.components.GitHubItemDialog.485609c4f2', 'check #')} - {check.checkRunId} - - )} -
- - {state?.error && ( -
- - {state.error} - - -
- )} - - {hasOutput && ( -
- {details?.title && ( -
- {details.title} -
- )} - {details?.summary && ( - - )} - {details?.text && ( - - )} -
- )} - - {hasAnnotations && ( -
-
- {translate('auto.components.GitHubItemDialog.96d8f36798', 'Annotations')} -
-
- {details!.annotations.map((annotation, index) => ( -
0 && 'border-t border-border/30' - )} - > -
- - {annotation.path ?? - translate('auto.components.GitHubItemDialog.7d42606f66', 'Annotation')} - {annotation.startLine ? `:${annotation.startLine}` : ''} - - {annotation.annotationLevel && ( - - {annotation.annotationLevel} - - )} -
- {annotation.title && ( -
- {annotation.title} -
- )} -
- {annotation.message} -
- {annotation.rawDetails && ( -
-                          {annotation.rawDetails}
-                        
- )} -
- ))} -
-
- )} - - {hasJobs && ( -
-
- {translate('auto.components.GitHubItemDialog.08d072664d', 'Jobs')} -
-
- {details!.jobs.map((job, index) => ( -
0 && 'border-t border-border/30' - )} - > -
- - {job.name} - - - {job.conclusion ?? - job.status ?? - translate('auto.components.GitHubItemDialog.773ff70035', 'unknown')} - -
- {job.steps.length > 0 && ( -
- {job.steps.map((step) => ( -
- {step.name} - {step.conclusion ?? step.status} -
- ))} -
- )} -
- ))} -
-
- )} - - {!state?.error && !hasOutput && !hasAnnotations && !hasJobs && ( -
- {getCheckConclusion(check) === 'action_required' - ? translate( - 'auto.components.GitHubItemDialog.checkActionRequiredHint', - 'Needs a manual action on GitHub (e.g. approving the run) to unblock merging.' - ) - : translate( - 'auto.components.GitHubItemDialog.744197c84d', - 'No inline output is available for this check.' - )} -
- )} - - {openUrl && ( -
- -
- )} -
- )} -
- ) - } - - if (loading && list.length === 0) { - return ( - <> - {variant === 'compact' ? compactHeader : null} -
- -
- - ) - } - if (list.length === 0) { - if (variant === 'page') { - return ( -
-
- -
- - {translate('auto.components.GitHubItemDialog.ecffebc251', 'No checks found')} - - - {translate( - 'auto.components.GitHubItemDialog.90020cc1f3', - 'This pull request has no reported checks yet.' - )} - -
- {actions} -
-
- ) - } - return ( - <> - {compactHeader} -
- -
- {translate('auto.components.GitHubItemDialog.e52bed9264', 'No checks reported yet')} -
-
- - ) - } - if (variant === 'page') { - const countChips = getCheckCountChips(counts) - return ( -
-
- 0 && counts.failing === 0 && 'animate-spin' - )} - /> -
- {summaryLabel} - {countChips.length > 1 && ( - - {countChips.map((chip, i) => ( - - {i > 0 && ·} - {chip.label} - - ))} - - )} -
- {actions} -
-
- {sorted.map((check, index) => ( -
0 && 'border-t border-border/40')} - > - {renderCheckRow(check)} -
- ))} -
-
- ) - } - return ( - <> - {compactHeader} -
- {sorted.map(renderCheckRow)} -
- - ) -} - -function GitHubLabelsSettingsLink({ - url, - separated, - onOpen -}: { - url: string | null - separated?: boolean - onOpen?: () => void -}): React.JSX.Element | null { - if (!url) { - return null - } - - return ( -
- -
- ) -} - -function GHEditSection({ - item, - repoPath, - repoId, - sourceContext, - projectOrigin, - localState, - localLabels, - onStateChange, - onLabelsChange, - onMutated, - assignees, - onUse, - onOpenOrUse, - attachedWorkspaceLabel, - layout = 'horizontal' -}: { - item: GitHubWorkItem - repoPath: string | null - repoId: string | null - sourceContext?: TaskSourceContext | null - projectOrigin: GitHubItemDialogProjectOrigin | undefined - localState: GitHubWorkItem['state'] - localLabels: string[] - onStateChange: (state: GitHubWorkItem['state']) => void - onLabelsChange: (labels: string[]) => void - /** Why: lets the parent invalidate its details cache after a mutation, else a reopen within FRESH_MS paints pre-mutation data. */ - onMutated: () => void - assignees: string[] - onUse: (item: GitHubWorkItem) => void - onOpenOrUse?: (item: GitHubWorkItem) => void - attachedWorkspaceLabel?: string | null - /** `horizontal`: compact pill strip for the non-issue drawer/header; `top-columns`: labeled columns above the issue page body. */ - layout?: 'horizontal' | 'top-columns' -}): React.JSX.Element | null { - const [labelPopoverOpen, setLabelPopoverOpen] = useState(false) - const [assigneePopoverOpen, setAssigneePopoverOpen] = useState(false) - const [statusPopoverOpen, setStatusPopoverOpen] = useState(false) - const [duplicatePickerOpen, setDuplicatePickerOpen] = useState(false) - const [duplicateSearch, setDuplicateSearch] = useState('') - const [duplicateError, setDuplicateError] = useState(null) - const [localAssignees, setLocalAssignees] = useState(assignees) - const editedAssigneesItemKeyRef = useRef(null) - const assigneesItemKey = `${item.repoId}\0${item.id}` - const patchWorkItem = useAppStore((s) => s.patchWorkItem) - const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent) - const duplicateIssueCandidates = useAppStore( - useShallow((s) => { - if (!duplicatePickerOpen) { - return [] - } - const deduped = new Map() - for (const entry of Object.values(s.workItemsCache)) { - for (const candidate of entry.data ?? []) { - if ( - candidate.type === 'issue' && - candidate.repoId === item.repoId && - candidate.number !== item.number && - !deduped.has(candidate.number) - ) { - deduped.set(candidate.number, candidate) - } - } - } - return Array.from(deduped.values()).sort((a, b) => b.number - a.number) - }) - ) - const repoOwnerSettings = useAppStore( - useShallow((s) => getSettingsForRepoRuntimeOwner(s, item.repoId ?? null)) - ) - const sourceSettings = useMemo( - () => - sourceContext?.provider === 'github' - ? ({ - ...repoOwnerSettings, - ...getTaskSourceRuntimeSettings(sourceContext) - } as typeof repoOwnerSettings) - : repoOwnerSettings, - [repoOwnerSettings, sourceContext] - ) - const { isPending, run } = useImmediateMutation() - // Why: from a Project view, keep projectViewCache in sync too — patchWorkItem only walks workItemsCache, so the table would render stale without this. See docs/design/github-project-view-tasks.md §Dialog editing from Project rows. - const patchProjectRowIfNeeded = useCallback( - (patch: Parameters[2]) => { - if (!projectOrigin) { - return - } - patchProjectRowContent(projectOrigin.cacheKey, projectOrigin.projectItemId, patch) - }, - [projectOrigin, patchProjectRowContent] - ) - - // Why: with projectOrigin set, read labels/assignees from the row's repo, not the workspace path, or popovers list a different repo than writes target. - const slugOwner = projectOrigin?.owner ?? null - const slugRepo = projectOrigin?.repo ?? null - const repoLabelsByPath = useRepoLabels( - projectOrigin ? null : repoPath, - projectOrigin ? null : repoId, - sourceSettings - ) - const repoLabelsBySlug = useRepoLabelsBySlug( - slugOwner, - slugRepo, - sourceSettings, - projectOrigin?.host - ) - const repoLabels = projectOrigin ? repoLabelsBySlug : repoLabelsByPath - const repositoryLabelsUrl = useMemo(() => getGitHubRepositoryLabelsUrl(item.url), [item.url]) - const repoAssigneesByPath = useRepoAssignees( - projectOrigin ? null : repoPath, - projectOrigin ? null : repoId, - sourceSettings - ) - const repoAssigneesBySlug = useRepoAssigneesBySlug( - slugOwner, - slugRepo, - assignees, - sourceSettings, - projectOrigin?.host - ) - const repoAssignees = projectOrigin ? repoAssigneesBySlug : repoAssigneesByPath - const hasAttachedWorkspace = - attachedWorkspaceLabel !== null && attachedWorkspaceLabel !== undefined - const filteredDuplicateCandidates = useMemo( - () => - getTaskPageGitHubDuplicateCandidates(duplicateIssueCandidates, item.number, duplicateSearch), - [duplicateIssueCandidates, duplicateSearch, item.number] - ) - const directDuplicateTarget = useMemo(() => { - const trimmed = duplicateSearch.trim() - const validation = validateTaskPageGitHubDuplicateTarget(trimmed, item.number) - if (!trimmed || !validation.ok) { - return null - } - if ( - filteredDuplicateCandidates.some((candidate) => candidate.number === validation.duplicateOf) - ) { - return null - } - return validation.duplicateOf - }, [duplicateSearch, filteredDuplicateCandidates, item.number]) - const duplicatePickerTitle = useMemo(() => { - if (projectOrigin) { - return `${projectOrigin.owner}/${projectOrigin.repo}` - } - const parsed = parseOwnerRepoFromItemUrl(item.url) - return parsed - ? `${parsed.owner}/${parsed.repo}` - : translate('auto.components.TaskPage.repository', 'Repository') - }, [item.url, projectOrigin]) - const handleOpenOrUseWorkspace = useCallback((): void => { - if (onOpenOrUse) { - onOpenOrUse(item) - return - } - onUse(item) - }, [item, onOpenOrUse, onUse]) - - // Why: sync local assignees on item change / detail resolve, but skip if the user made an optimistic edit so we don't clobber in-flight changes. - useEffect(() => { - if (editedAssigneesItemKeyRef.current === assigneesItemKey) { - return - } - setLocalAssignees(assignees) - }, [assigneesItemKey, assignees]) - - const handleStateChange = useCallback( - (newState: 'open' | 'closed', closeAction?: TaskPageGitHubCloseAction) => { - if (newState === localState) { - return - } - const prevState = localState - // Why: without registry authority a search-lagged Tasks refetch silently - // reverts this row to its pre-mutation state (STA-3343). - let authority: { revert: () => boolean } | null = null - run('state', { - mutate: () => - runIssueUpdate({ - repoId: item.repoId, - repoPath, - sourceContext, - projectOrigin, - number: item.number, - updates: - newState === 'closed' && closeAction - ? buildTaskPageGitHubCloseUpdate(closeAction) - : { state: newState } - }), - onOptimistic: () => { - authority = assertTaskPageGitHubDialogStateAuthority({ - repoId: item.repoId, - itemId: item.id, - state: newState, - sourceContext - }) - onStateChange(newState) - patchWorkItem(item.id, { state: newState }, item.repoId, { sourceContext }) - patchProjectRowIfNeeded({ state: newState }) - }, - onRevert: () => { - if (authority?.revert()) { - onStateChange(prevState) - patchWorkItem(item.id, { state: prevState }, item.repoId, { sourceContext }) - patchProjectRowIfNeeded({ state: prevState }) - } - }, - onSuccess: () => { - useAppStore.getState().recordFeatureInteraction('github-tasks') - patchWorkItem(item.id, { state: newState }, item.repoId, { sourceContext }) - patchProjectRowIfNeeded({ state: newState }) - onMutated() - }, - onError: (err) => toast.error(err) - }) - }, - [ - item.id, - item.number, - item.repoId, - localState, - repoPath, - sourceContext, - projectOrigin, - patchWorkItem, - patchProjectRowIfNeeded, - run, - onStateChange, - onMutated - ] - ) - - const closeAsDuplicate = useCallback( - (targetIssueNumber: number | string) => { - const validation = validateTaskPageGitHubDuplicateTarget( - String(targetIssueNumber), - item.number - ) - if (!validation.ok) { - setDuplicateError(getTaskPageGitHubDuplicateTargetErrorMessage(validation, translate)) - return - } - setDuplicateError(null) - handleStateChange('closed', { stateReason: 'duplicate', duplicateOf: validation.duplicateOf }) - setStatusPopoverOpen(false) - setDuplicatePickerOpen(false) - }, - [handleStateChange, item.number] - ) - - const handleDuplicateSearchSubmit = useCallback(() => { - const validation = validateTaskPageGitHubDuplicateTarget(duplicateSearch, item.number) - if (!validation.ok) { - setDuplicateError(getTaskPageGitHubDuplicateTargetErrorMessage(validation, translate)) - return - } - closeAsDuplicate(validation.duplicateOf) - }, [closeAsDuplicate, duplicateSearch, item.number]) - - const handleStatusPopoverOpenChange = useCallback((nextOpen: boolean) => { - setStatusPopoverOpen(nextOpen) - if (!nextOpen) { - setDuplicatePickerOpen(false) - setDuplicateSearch('') - setDuplicateError(null) - } - }, []) - - const handleLabelToggle = useCallback( - (label: string) => { - const isAdding = !localLabels.includes(label) - const prevLabels = localLabels - const newLabels = isAdding ? [...prevLabels, label] : prevLabels.filter((l) => l !== label) - - if (isAdding) { - run('labels', { - mutate: () => - runIssueUpdate({ - repoId: item.repoId, - repoPath, - sourceContext, - projectOrigin, - number: item.number, - updates: { addLabels: [label] } - }), - onOptimistic: () => { - onLabelsChange(newLabels) - patchWorkItem(item.id, { labels: newLabels }, item.repoId, { sourceContext }) - patchProjectRowIfNeeded({ labels: newLabels }) - }, - onSuccess: () => { - useAppStore.getState().recordFeatureInteraction('github-tasks') - onMutated() - }, - onRevert: () => { - onLabelsChange(prevLabels) - patchWorkItem(item.id, { labels: prevLabels }, item.repoId, { sourceContext }) - patchProjectRowIfNeeded({ labels: prevLabels }) - }, - onError: (err) => toast.error(err) - }) - } else { - run('labels', { - mutate: () => - runIssueUpdate({ - repoId: item.repoId, - repoPath, - sourceContext, - projectOrigin, - number: item.number, - updates: { removeLabels: [label] } - }), - onOptimistic: () => { - onLabelsChange(newLabels) - patchWorkItem(item.id, { labels: newLabels }, item.repoId, { sourceContext }) - patchProjectRowIfNeeded({ labels: newLabels }) - }, - onRevert: () => { - onLabelsChange(prevLabels) - patchWorkItem(item.id, { labels: prevLabels }, item.repoId, { sourceContext }) - patchProjectRowIfNeeded({ labels: prevLabels }) - }, - onSuccess: () => { - useAppStore.getState().recordFeatureInteraction('github-tasks') - onMutated() - }, - onError: (err) => toast.error(err) - }) - } - }, - [ - item.id, - item.number, - item.repoId, - localLabels, - repoPath, - sourceContext, - projectOrigin, - patchWorkItem, - patchProjectRowIfNeeded, - run, - onLabelsChange, - onMutated - ] - ) - - const handleAssigneeToggle = useCallback( - (login: string) => { - const isAssigned = localAssignees.includes(login) - const prevAssignees = localAssignees - const newAssignees = isAssigned - ? prevAssignees.filter((l) => l !== login) - : [...prevAssignees, login] - - // Why: scope the optimistic guard to this repo item so switching items doesn't suppress the next item's assignee sync. - editedAssigneesItemKeyRef.current = assigneesItemKey - if (isAssigned) { - run('assignees', { - mutate: () => - runIssueUpdate({ - repoId: item.repoId, - repoPath, - sourceContext, - projectOrigin, - number: item.number, - updates: { removeAssignees: [login] } - }), - onOptimistic: () => { - setLocalAssignees(newAssignees) - patchProjectRowIfNeeded({ assignees: newAssignees }) - }, - onRevert: () => { - setLocalAssignees(prevAssignees) - patchProjectRowIfNeeded({ assignees: prevAssignees }) - }, - onSuccess: () => { - useAppStore.getState().recordFeatureInteraction('github-tasks') - onMutated() - }, - onError: (err) => toast.error(err) - }) - } else { - run('assignees', { - mutate: () => - runIssueUpdate({ - repoId: item.repoId, - repoPath, - sourceContext, - projectOrigin, - number: item.number, - updates: { addAssignees: [login] } - }), - onOptimistic: () => { - setLocalAssignees(newAssignees) - patchProjectRowIfNeeded({ assignees: newAssignees }) - }, - onSuccess: () => { - useAppStore.getState().recordFeatureInteraction('github-tasks') - onMutated() - }, - onRevert: () => { - setLocalAssignees(prevAssignees) - patchProjectRowIfNeeded({ assignees: prevAssignees }) - }, - onError: (err) => toast.error(err) - }) - } - }, - [ - item.number, - item.repoId, - assigneesItemKey, - repoPath, - sourceContext, - projectOrigin, - localAssignees, - patchProjectRowIfNeeded, - run, - onMutated - ] - ) - - const renderIssueStatusPopover = (variant: 'sidebar' | 'pill'): React.JSX.Element => { - const isSidebar = variant === 'sidebar' - return ( - - - - - - {duplicatePickerOpen ? ( -
-
- - - {duplicatePickerTitle} - -
-
- - { - setDuplicateSearch(event.target.value) - setDuplicateError(null) - }} - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault() - handleDuplicateSearchSubmit() - } - }} - placeholder={translate('auto.components.TaskPage.searchIssues', 'Search issues')} - className="h-9 pl-8 text-[12px]" - aria-invalid={duplicateError ? true : undefined} - /> -
- {duplicateError ? ( -

{duplicateError}

- ) : null} -
- {directDuplicateTarget ? ( - - ) : null} - {filteredDuplicateCandidates.map((candidate) => ( - - ))} - {!directDuplicateTarget && filteredDuplicateCandidates.length === 0 ? ( -

- {translate( - 'auto.components.TaskPage.noMatchingIssuesLoaded', - 'No matching issues loaded.' - )} -

- ) : null} -
-
- ) : ( - <> - - - - - - )} -
-
- ) - } - - if (item.type === 'pr') { - return null - } - - const checkIcon = ( - - - - ) - - if (layout === 'top-columns') { - // Why: lay property fields as top columns so the description isn't squeezed by a right rail. - return ( - - ) - } - - return ( -
- {/* State */} - {renderIssueStatusPopover('pill')} - - {/* Labels */} - - - - - - {repoLabels.error ? ( -
- {repoLabels.error} -
- ) : null} - {!repoLabels.error ? ( -
- {repoLabels.data.map((label) => ( - - ))} -
- ) : null} - 0} - onOpen={() => setLabelPopoverOpen(false)} - /> -
-
- - {/* Assignees */} - - - - - - {repoAssignees.error ? ( -
- {repoAssignees.error} -
- ) : ( -
- {repoAssignees.data.map((user) => ( - - ))} -
- )} -
-
- -
- {attachedWorkspaceLabel ? ( - - - {attachedWorkspaceLabel} - - ) : null} - {hasAttachedWorkspace ? ( - - - - - - - - - onUse(item)}> - - {translate('auto.components.GitHubItemDialog.36182aa57f', 'Start new workspace')} - - - - ) : ( - - )} -
-
- ) -} - -function GHCommentComposer({ - className, - repoPath, - repoId, - sourceContext, - issueNumber, - itemType, - prRepo, - onCommentAdded -}: { - className?: string - repoPath: string - repoId?: string | null - sourceContext?: TaskSourceContext | null - issueNumber: number - itemType: 'issue' | 'pr' - prRepo?: GitHubOwnerRepo | null - onCommentAdded: (comment: PRComment) => void -}): React.JSX.Element { - const [body, setBody] = useState('') - const [submitting, setSubmitting] = useState(false) - const mountedRef = useMountedRef() - - const handleSubmit = useCallback(async () => { - const bodyState = getCommentBodySubmitState(body) - if (bodyState.status === 'empty') { - return - } - if (bodyState.status === 'too-large-leading-whitespace') { - toast.error( - translate( - 'auto.components.GitHubItemDialog.commentTooLarge', - 'Comment is too large to submit safely.' - ) - ) - return - } - setSubmitting(true) - try { - const result = await addIssueCommentForRepo({ - repoPath, - repoId: repoId ?? undefined, - sourceContext, - number: issueNumber, - body: bodyState.body, - type: itemType, - prRepo - }) - if (!mountedRef.current) { - return - } - if (result.ok) { - setBody('') - // Why: use GitHub's returned comment so the optimistic row shows the real login/avatar immediately. - onCommentAdded(result.comment) - } else { - toast.error( - result.error ?? - translate('auto.components.GitHubItemDialog.082515176a', 'Failed to add comment') - ) - } - } catch (err) { - if (mountedRef.current) { - toast.error( - err instanceof Error - ? err.message - : translate('auto.components.GitHubItemDialog.082515176a', 'Failed to add comment') - ) - } - } finally { - if (mountedRef.current) { - setSubmitting(false) - } - } - }, [ - body, - mountedRef, - repoPath, - repoId, - sourceContext, - issueNumber, - itemType, - prRepo, - onCommentAdded - ]) - const canSubmitComment = hasBoundedCommentBodyText(body) - - return ( -
- void handleSubmit()} - /> - - - - - - {translate('auto.components.GitHubItemDialog.0a73f59e85', 'Send comment')} - - -
- ) -} - -// Why: recover the PR-source slug the dialog lacks from workItemsCache, scoped to this repoPath so a sibling repo sharing the issue-source (e.g. two forks) can't mislabel the chip; hide when unknown rather than guess (design doc §1). -function WorkItemIssueSourceIndicator({ - url, - repoId, - repoPath -}: { - url: string - repoId: string | null - repoPath?: string | null -}): React.JSX.Element | null { - // Why: resolve repo sources via the primary cache entry, or any sibling entry if the Tasks view only populated a query-keyed slot (sources are repo-level, so any is safe). - const sources = useAppStore((s) => - s.getWorkItemsAnySourcesForRepo(repoId ?? '', PER_REPO_FETCH_LIMIT, repoPath ?? undefined) - ) - const issues = useMemo(() => { - const fromUrl = parseOwnerRepoFromItemUrl(url) - if (!fromUrl) { - return null - } - // Prefer the cache's resolved issue-source (canonicalized by main) over the best-effort URL parse when they match. - const cachedIssues = sources?.issues - if (cachedIssues && sameGitHubOwnerRepo(cachedIssues, fromUrl)) { - return cachedIssues - } - return fromUrl - }, [url, sources]) - const prs = sources?.prs ?? null - - if (!issues || !prs || sameGitHubOwnerRepo(issues, prs)) { - return null - } - return ( -
- -
- ) -} - -export default function GitHubItemDialog({ - workItem, - repoPath, - repoId, - sourceContext, - initialTab, - backLabel = 'Back', - projectOrigin, - onUse, - onReviewRequestsChange, - onClose -}: GitHubItemDialogProps): React.JSX.Element { - const workItemId = workItem?.id - const [tab, setTab] = useState(() => normalizeItemDialogTab(workItem, initialTab)) - const [localState, setLocalState] = useState(workItem?.state ?? 'open') - const [localLabels, setLocalLabels] = useState(workItem?.labels ?? []) - const [linkCopyState, setLinkCopyState] = useState(() => createGitHubLinkCopyState(workItemId)) - const resolvedLinkCopyState = resolveGitHubLinkCopyState(linkCopyState, workItemId) - if (resolvedLinkCopyState !== linkCopyState) { - // Why: switching items must not paint a stale "copied" indicator from the previous item. - setLinkCopyState(resolvedLinkCopyState) - } - const linkCopied = resolvedLinkCopyState.copied - const workItemState = workItem?.state - const workItemLabels = workItem?.labels - const effectiveRepoId = repoId ?? workItem?.repoId ?? null - const allWorktrees = useAllWorktrees() - const issueAttachedWorkspace = useMemo( - () => - workItem?.type === 'issue' - ? findGithubIssueWorkspaceAttachment(allWorktrees, effectiveRepoId, workItem.number) - : null, - [allWorktrees, effectiveRepoId, workItem] - ) - const issueAttachedWorkspaceLabel = issueAttachedWorkspace - ? getGithubWorkItemWorkspaceAttachmentLabel(issueAttachedWorkspace) - : null - - const handleOpenOrUseIssueWorkspace = useCallback( - (item: GitHubWorkItem): void => { - const currentAttached = findGithubIssueWorkspaceAttachment( - useAppStore.getState().allWorktrees(), - effectiveRepoId, - item.number - ) - if (!currentAttached) { - onUse(item) - return - } - - const result = activateAndRevealWorktree(currentAttached.id) - if (result === false) { - toast.error( - translate( - 'auto.components.GitHubItemDialog.2ef631437e', - 'Unable to open the workspace attached to this issue.' - ) - ) - } - }, - [effectiveRepoId, onUse] - ) - - // Why: the cache key must include issue source preference so toggling origin/upstream for the same issue number doesn't read the wrong repo's details. - const issueSourcePreference = useAppStore((s) => { - if (!repoPath && !effectiveRepoId) { - return undefined - } - return s.repos.find((r) => (effectiveRepoId ? r.id === effectiveRepoId : r.path === repoPath)) - ?.issueSourcePreference - }) - const canUseDetailsRepoContext = canUseGitHubRepoContext(repoPath, sourceContext) - const detailsCacheKey = useMemo(() => { - if (!workItem || !effectiveRepoId || !canUseDetailsRepoContext) { - return null - } - return getWorkItemDetailsCacheKey({ - repoPath: repoPath ?? '', - repoId: effectiveRepoId, - issueSourcePreference, - sourceCacheScope: - sourceContext?.provider === 'github' ? getTaskSourceCacheScope(sourceContext) : null, - type: workItem.type, - number: workItem.number - }) - }, [ - canUseDetailsRepoContext, - repoPath, - effectiveRepoId, - sourceContext, - workItem, - issueSourcePreference - ]) - - // Why: hold comments added before the detail fetch resolves so they merge into the result instead of being overwritten. - const optimisticCommentsRef = useRef([]) - // Why: distinguish "reopen same item" from "switch item" — reopen must keep optimistic comments since gh's 60s cache omits the just-posted one. - const prevItemIdRef = useRef(null) - - // Why: a just-closed Radix overlay can leave `pointer-events: none` on , killing header button clicks; poll a few frames to clear it. - useEffect(() => { - if (!workItem) { - return - } - let cancelled = false - let count = 0 - let frameId: number | null = null - const tick = (): void => { - frameId = null - if (cancelled) { - return - } - if (document.body.style.pointerEvents === 'none') { - document.body.style.pointerEvents = '' - } - if (count++ < 5) { - frameId = requestAnimationFrame(tick) - } - } - tick() - return () => { - cancelled = true - if (frameId !== null) { - cancelAnimationFrame(frameId) - } - } - }, [workItem]) - - // Why: subscribe to the module-level cache so reopening a cached item paints synchronously on first render. - const cachedEntry = useSyncExternalStore( - subscribeWorkItemDetailsCache, - useCallback( - () => (detailsCacheKey ? workItemDetailsCache.get(detailsCacheKey) : undefined), - [detailsCacheKey] - ) - ) - - // Why: bumped on cold open (no cached details yet) so the details memo re-runs and surfaces the optimistic comment before the fetch lands. - const [optimisticTick, setOptimisticTick] = useState(0) - - // Why: key off cachedEntry identity (stable), not the optimistic ref array (fresh each render), to avoid needless recompute. - const details = useMemo(() => { - const cachedDetails = cachedEntry?.details ?? null - const opt = optimisticCommentsRef.current - if (!cachedDetails) { - // Why: on cold open, details may still be loading — surface optimistic comments via a minimal shell so a pre-fetch comment isn't invisible. - if (opt.length > 0 && workItem) { - return { item: workItem, body: '', comments: [...opt] } - } - return null - } - if (opt.length === 0) { - return cachedDetails - } - const ids = new Set(cachedDetails.comments.map((c) => c.id)) - const missing = opt.filter((c) => !ids.has(c.id)) - if (missing.length === 0) { - return cachedDetails - } - return { - ...cachedDetails, - comments: [...cachedDetails.comments, ...missing] - } - // Why: optimisticTick forces this ref-reading memo to re-run on cold-open writes; lint can't see the dependency. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [cachedEntry, workItem, optimisticTick]) - - const resolvedWorkItemState = details?.item.state ?? workItemState - - // Why: the opening list row can be stale; the detail payload has authoritative state, so refresh the local edit UI from it. - useEffect(() => { - if (resolvedWorkItemState) { - setLocalState(resolvedWorkItemState) - } - if (workItemLabels) { - setLocalLabels(workItemLabels) - } - }, [workItemId, resolvedWorkItemState, workItemLabels]) - - const loading = !!cachedEntry?.pending && !cachedEntry?.details - const error = cachedEntry?.error && !cachedEntry?.details ? cachedEntry.error : null - const detailsLoaded = Boolean(cachedEntry?.details) - - // Why: if a cross-window mutation invalidates the open drawer's entry (cachedEntry undefined, fetch deps unchanged), bump a tick to force the refetch. - const [refetchTick, setRefetchTick] = useState(0) - useEffect(() => { - if (workItem && detailsCacheKey && !cachedEntry) { - setRefetchTick((n) => n + 1) - } - }, [workItem, detailsCacheKey, cachedEntry]) - - useEffect(() => { - if (!workItem || !effectiveRepoId || !detailsCacheKey || !canUseDetailsRepoContext) { - return - } - // Why: clear optimistic comments only on item switch — on reopen, gh's 60s cache omits the just-posted comment, so keep the ref to re-merge. - if (workItem.id !== prevItemIdRef.current) { - optimisticCommentsRef.current = [] - } - prevItemIdRef.current = workItem.id - setTab(normalizeItemDialogTab(workItem, initialTab)) - - const cached = workItemDetailsCache.get(detailsCacheKey) - const now = Date.now() - const hasFreshData = cached?.details && now - cached.fetchedAt <= WORK_ITEM_DETAILS_FRESH_MS - - if (hasFreshData) { - return - } - - // Why: dedupe concurrent opens for the same key — share one in-flight promise instead of racing two `gh` subprocesses. - const inflight: Promise = - cached?.pending ?? - lookupGitHubWorkItemDetailsForSource({ - repoPath: repoPath ?? '', - repoId: effectiveRepoId, - sourceContext, - number: workItem.number, - type: workItem.type - }) - - // Why: snapshot the invalidation generation; if it advances before resolve, a mid-flight mutation invalidated the entry — don't write back. - const launchedAtGeneration = workItemDetailsCacheGeneration - - if (!cached?.pending) { - touchWorkItemDetailsCache(detailsCacheKey, { - details: cached?.details ?? null, - fetchedAt: cached?.fetchedAt ?? 0, - pending: inflight, - error: cached?.error - }) - } - - inflight - .then((result) => { - const invalidatedMidFlight = workItemDetailsCacheGeneration !== launchedAtGeneration - const prev = workItemDetailsCache.get(detailsCacheKey) - if (invalidatedMidFlight && prev?.pending !== inflight) { - // Why: entry was deliberately dropped (or later repopulated) — don't recreate or touch it. - return - } - // Why: null means unavailable/not found, not loaded empty content. - if (result === null && prev?.details) { - touchWorkItemDetailsCache(detailsCacheKey, { - details: prev.details, - fetchedAt: prev.fetchedAt, - error: undefined - }) - } else if (result === null) { - touchWorkItemDetailsCache(detailsCacheKey, { - details: null, - fetchedAt: 0, - error: WORK_ITEM_DETAILS_UNAVAILABLE_MESSAGE - }) - } else { - touchWorkItemDetailsCache(detailsCacheKey, { - details: result, - fetchedAt: Date.now(), - error: undefined - }) - } - }) - .catch((err) => { - const message = err instanceof Error ? err.message : 'Failed to load details' - const invalidatedMidFlight = workItemDetailsCacheGeneration !== launchedAtGeneration - const prev = workItemDetailsCache.get(detailsCacheKey) - if (invalidatedMidFlight && prev?.pending !== inflight) { - return - } - // Why: stale-on-error — keep cached data, drop the pending promise so next open retries; show the error only when nothing is cached. - touchWorkItemDetailsCache(detailsCacheKey, { - details: prev?.details ?? null, - fetchedAt: prev?.fetchedAt ?? 0, - error: message - }) - }) - }, [ - canUseDetailsRepoContext, - repoPath, - effectiveRepoId, - sourceContext, - workItem, - detailsCacheKey, - initialTab, - refetchTick - ]) - - const Icon = workItem?.type === 'pr' ? GitPullRequest : CircleDot - const displayWorkItem = useMemo(() => { - if (!workItem) { - return null - } - if (!details?.item) { - return workItem - } - return { ...workItem, ...details.item, repoId: workItem.repoId } - }, [details?.item, workItem]) - - useEffect(() => { - if (!workItem || details?.item.reviewRequests === undefined) { - return - } - // Why: PR details can carry fresher reviewer metadata than the list row; push it back so the Tasks review chip isn't stale. - onReviewRequestsChange?.( - { id: workItem.id, repoId: workItem.repoId }, - details.item.reviewRequests - ) - }, [details?.item.reviewRequests, onReviewRequestsChange, workItem]) - - const body = details?.body ?? '' - const comments = details?.comments ?? [] - const timelineItems = details?.timelineItems ?? [] - const files = details?.files ?? [] - const filesUnavailable = details?.filesUnavailable ?? false - const checks = details?.checks ?? [] - const [pendingViewedPaths, setPendingViewedPaths] = useState>(() => new Set()) - // Why: clipboard IPC can resolve after unmount; skip copied-state feedback rather than start a reset timer on a stale surface. - const linkCopyMountedRef = useRef(false) - const linkCopiedResetTimerRef = useRef(null) - const clearLinkCopiedResetTimer = useCallback((): void => { - if (linkCopiedResetTimerRef.current === null) { - return - } - window.clearTimeout(linkCopiedResetTimerRef.current) - linkCopiedResetTimerRef.current = null - }, []) - const setLinkCopyButtonRef = useCallback( - (node: HTMLButtonElement | null) => { - linkCopyMountedRef.current = node !== null - if (node === null) { - // Why: the copied-state timer belongs to this control; clear it on detach without a passive cleanup Effect. - clearLinkCopiedResetTimer() - } - }, - [clearLinkCopiedResetTimer] - ) - - const handleCopyWorkItemLink = useCallback(async (): Promise => { - if (!workItem) { - return - } - try { - // Why: Electron clipboard IPC works even when browser clipboard APIs lose focus/activation in nested overlays. - await window.api.ui.writeClipboardText(workItem.url) - if (!linkCopyMountedRef.current) { - return - } - clearLinkCopiedResetTimer() - const copiedWorkItemId = workItem.id - setLinkCopyState(markGitHubLinkCopied(copiedWorkItemId)) - linkCopiedResetTimerRef.current = window.setTimeout(() => { - linkCopiedResetTimerRef.current = null - setLinkCopyState((current) => clearGitHubLinkCopied(current, copiedWorkItemId)) - }, 1500) - toast.success(translate('auto.components.GitHubItemDialog.2e77dc2053', 'GitHub link copied')) - } catch { - toast.error( - translate('auto.components.GitHubItemDialog.5fea151559', 'Failed to copy GitHub link') - ) - } - }, [clearLinkCopiedResetTimer, workItem]) - - const appendOptimisticComment = useCallback( - (comment: PRComment) => { - useAppStore.getState().recordFeatureInteraction('github-tasks') - // Why: skip refreshDetails() — gh's 60s cache would overwrite the optimistic comment; next open picks up the server version. - optimisticCommentsRef.current.push(comment) - // Why: write through the module cache so concurrent drawers re-render; mark fetchedAt stale (0) so next open refetches server fields. - if (detailsCacheKey) { - const prev = workItemDetailsCache.get(detailsCacheKey) - if (prev?.details) { - const ids = new Set(prev.details.comments.map((c) => c.id)) - if (!ids.has(comment.id)) { - touchWorkItemDetailsCache(detailsCacheKey, { - details: { - ...prev.details, - comments: [...prev.details.comments, comment] - }, - fetchedAt: 0, - error: undefined - }) - return - } - } - } - // Why: no cache write fires while details are still loading; bump local state so the memo re-runs and shows the optimistic comment. - setOptimisticTick((n) => n + 1) - }, - [detailsCacheKey] - ) - - const invalidateCurrentDetailsCache = useCallback((): void => { - if (!workItem) { - return - } - // Why: local repos invalidate all source-pref variants; runtime-only entries need their exact source-scoped key (no local path). - if (repoPath) { - invalidateWorkItemDetailsCacheByMatch({ - repoPath, - repoId: effectiveRepoId ?? undefined, - type: workItem.type, - number: workItem.number - }) - return - } - if (detailsCacheKey) { - invalidateWorkItemDetailsCacheForKey(detailsCacheKey) - } - }, [detailsCacheKey, effectiveRepoId, repoPath, workItem]) - - const handlePRFileViewedChange = useCallback( - async (path: string, viewed: boolean): Promise => { - if ( - !canUseDetailsRepoContext || - !details?.pullRequestId || - !workItem || - workItem.type !== 'pr' - ) { - toast.error( - translate( - 'auto.components.GitHubItemDialog.c0253318d6', - 'Unable to sync viewed state for this pull request.' - ) - ) - return false - } - setPendingViewedPaths((prev) => new Set(prev).add(path)) - const nextState: GitHubPRFileViewedState = viewed ? 'VIEWED' : 'UNVIEWED' - const previousState = detailsCacheKey - ? patchCachedPRFileViewedState(detailsCacheKey, path, nextState) - : undefined - try { - const ok = await setPRFileViewedForRepo({ - repoId: workItem.repoId, - repoPath: repoPath ?? '', - sourceContext, - prNumber: workItem.number, - prRepo: resolvePullRequestRepo(workItem, projectOrigin), - pullRequestId: details.pullRequestId, - path, - viewed - }) - if (!ok) { - if (detailsCacheKey && previousState) { - patchCachedPRFileViewedState(detailsCacheKey, path, previousState) - } - toast.error( - translate( - 'auto.components.GitHubItemDialog.b7bf31b8de', - 'Failed to sync viewed state with GitHub.' - ) - ) - return false - } - return true - } finally { - setPendingViewedPaths((prev) => { - const next = new Set(prev) - next.delete(path) - return next - }) - } - }, - [ - canUseDetailsRepoContext, - details?.pullRequestId, - detailsCacheKey, - projectOrigin, - repoPath, - sourceContext, - workItem - ] - ) - - const isIssuePage = workItem?.type === 'issue' - const ownerRepo = workItem ? parseOwnerRepoFromItemUrl(workItem.url) : null - const issueStateBadgeTone = - localState === 'closed' ? 'bg-rose-600 text-white' : 'bg-emerald-600 text-white' - - const content = workItem ? ( -
- {isIssuePage ? ( - <> - {/* Row 1: breadcrumb-style strip mirroring GitHub's canvas-subtle header */} -
-
- - · - {ownerRepo ? ( - <> - - {ownerRepo.owner} - / - {ownerRepo.repo} - - · - - ) : null} - #{workItem.number} -
- - - - - - {linkCopied - ? translate('auto.components.GitHubItemDialog.038b3d39b1', 'Copied') - : translate( - 'auto.components.GitHubItemDialog.c43fe79ee0', - 'Copy GitHub link' - )} - - - - - - - - {translate('auto.components.GitHubItemDialog.3fdf777817', 'Open on GitHub')} - - -
-
-
- - {/* Row 2: large title block */} -
-
-

- {workItem.title} - #{workItem.number} -

-
- {/* Why: Orca's signature affordance — keep primary so it stands out against GitHub's familiar surface. */} - {issueAttachedWorkspace ? ( - - - - - - - - - onUse(workItem)}> - - {translate( - 'auto.components.GitHubItemDialog.36182aa57f', - 'Start new workspace' - )} - - window.api.shell.openUrl(workItem.url)}> - - {translate('auto.components.GitHubItemDialog.3fdf777817', 'Open on GitHub')} - - - - ) : ( - - )} -
-
-
- - {localState === 'closed' ? ( - - ) : ( - - )} - {localState === 'closed' - ? translate('auto.components.GitHubItemDialog.ab050dffec', 'Closed') - : translate('auto.components.GitHubItemDialog.dc1ca081a8', 'Open')} - - - - {workItem.author ?? - translate('auto.components.GitHubItemDialog.773ff70035', 'unknown')} - - - {translate('auto.components.GitHubItemDialog.55962099bc', 'opened this issue')} - - - {translate('auto.components.GitHubItemDialog.10ef1afb8e', '· updated')} - {formatRelativeTime(workItem.updatedAt)} - - - - {issueAttachedWorkspaceLabel ? ( - - - {issueAttachedWorkspaceLabel} - - ) : null} -
-
- - ) : ( -
-
- -
- -
-
-
- - #{workItem.number} - - {workItem.type === 'pr' - ? translate('auto.components.GitHubItemDialog.a2495e4784', 'Pull request') - : translate('auto.components.GitHubItemDialog.3e544d966d', 'Issue')} - -
-

- {workItem.title} -

-
- - {workItem.author ?? - translate('auto.components.GitHubItemDialog.773ff70035', 'unknown')} - - - {translate('auto.components.GitHubItemDialog.8223320f8d', 'updated')} - {formatRelativeTime(workItem.updatedAt)} - - {workItem.branchName && ( - - {workItem.branchName} - - )} - {issueAttachedWorkspaceLabel ? ( - - - {issueAttachedWorkspaceLabel} - - ) : null} -
- {workItem.type === 'issue' && ( - - )} -
-
- {workItem.type === 'pr' && ( - - )} - - - - - - {linkCopied - ? translate('auto.components.GitHubItemDialog.038b3d39b1', 'Copied') - : translate('auto.components.GitHubItemDialog.c43fe79ee0', 'Copy GitHub link')} - - - - - - - - {translate('auto.components.GitHubItemDialog.3fdf777817', 'Open on GitHub')} - - -
-
-
- )} - - {!isIssuePage && (canUseDetailsRepoContext || projectOrigin) && ( - - )} - -
- {error ? ( -
{error}
- ) : isIssuePage ? ( -
- {/* Why: full content width so the description isn't squeezed by a right rail; px-2 + ConversationTab px-4 = header px-6. */} -
- {(canUseDetailsRepoContext || projectOrigin) && ( -
- -
- )} -
- { - if (detailsCacheKey) { - patchCachedPRChecks(detailsCacheKey, nextChecks) - } - }} - onBodyUpdated={(nextBody) => { - if (detailsCacheKey) { - patchCachedWorkItemBody(detailsCacheKey, nextBody) - } - }} - onCommentAdded={appendOptimisticComment} - onReviewersRequested={(nextReviewRequests) => { - if (detailsCacheKey) { - patchCachedPRReviewRequests(detailsCacheKey, nextReviewRequests) - } - onReviewRequestsChange?.( - { id: workItem.id, repoId: workItem.repoId }, - nextReviewRequests - ) - }} - /> -
-
-
- ) : ( - setTab(value as ItemDialogTab)} - className="flex h-full min-h-0 flex-col gap-0" - > - - - - {translate('auto.components.GitHubItemDialog.e30a5470c9', 'Conversation')} - - {workItem.type === 'pr' && ( - <> - - - {translate('auto.components.GitHubItemDialog.4bd1f5b055', 'Checks')} - {checks.length > 0 && ( - - {checks.length} - - )} - - - - {translate('auto.components.GitHubItemDialog.999b5ad7d9', 'Files')} - {files.length > 0 && ( - {files.length} - )} - - - )} - - -
- - { - if (detailsCacheKey) { - patchCachedPRChecks(detailsCacheKey, nextChecks) - } - }} - onBodyUpdated={(nextBody) => { - if (detailsCacheKey) { - patchCachedWorkItemBody(detailsCacheKey, nextBody) - } - }} - onCommentAdded={appendOptimisticComment} - onReviewersRequested={(nextReviewRequests) => { - if (detailsCacheKey) { - patchCachedPRReviewRequests(detailsCacheKey, nextReviewRequests) - } - onReviewRequestsChange?.( - { id: workItem.id, repoId: workItem.repoId }, - nextReviewRequests - ) - }} - /> - - - {workItem.type === 'pr' && ( - <> - - { - if (detailsCacheKey) { - patchCachedPRChecks(detailsCacheKey, nextChecks) - } - }} - /> - - - - {loading && files.length === 0 ? ( -
- -
- ) : filesUnavailable && files.length === 0 ? ( - // Why: file fetch failed (rate limit, auth, unresolved remote); offer a retry instead of implying the PR is empty. -
-
- {translate( - 'auto.components.GitHubItemDialog.filesUnavailable', - "Couldn't load changed files." - )} -
- -
- ) : files.length === 0 ? ( -
- {translate( - 'auto.components.GitHubItemDialog.3cd5ae5b7b', - 'No files changed.' - )} -
- ) : ( - - )} -
- - )} -
-
- )} -
-
- ) : null - - return ( - // Why: rendered inline (not a Radix dialog), so e2e needs a stable hook to scope assertions to this detail surface. -
- {content} -
- ) -} +export default GitHubItemDialog diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.set-location.test.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.set-location.test.tsx new file mode 100644 index 00000000000..92c7e889075 --- /dev/null +++ b/src/renderer/src/components/NewWorkspaceComposerCard.set-location.test.tsx @@ -0,0 +1,238 @@ +// @vitest-environment happy-dom + +import React, { act } from 'react' +import { createRoot } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import NewWorkspaceComposerCard from './NewWorkspaceComposerCard' +import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options' +import type { ProjectHostSetupOption } from '@/lib/project-host-setup-options' + +const storeMocks = vi.hoisted(() => ({ + closeModal: vi.fn(), + openModal: vi.fn(), + openSettingsPage: vi.fn(), + openSettingsTarget: vi.fn() +})) + +vi.mock('@/store', () => ({ + useAppStore: Object.assign( + (selector: (state: unknown) => unknown) => + selector({ + closeModal: storeMocks.closeModal, + openModal: storeMocks.openModal, + openSettingsPage: storeMocks.openSettingsPage, + openSettingsTarget: storeMocks.openSettingsTarget, + setRuntimeEnvironmentStatus: vi.fn(), + setupProjectExistingFolder: vi.fn(), + setupProjectClone: vi.fn(), + activeModal: 'new-workspace-composer', + settings: { defaultTuiAgent: null, disabledTuiAgents: [] }, + updateSettings: vi.fn(), + projects: [], + repos: [] + }), + { getState: () => ({}) } + ) +})) + +vi.mock('@/components/contextual-tours/use-contextual-tour', () => ({ + useContextualTour: vi.fn() +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children} +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children} +})) + +vi.mock('@/components/agent/AgentCombobox', () => ({ + default: () => +})) + +vi.mock('@/components/sidebar/AddRemoteHostDialog', () => ({ + AddRemoteHostDialog: () => null +})) + +vi.mock('@/components/sparse/SparseCheckoutPresetSelect', () => ({ + default: () => null +})) + +vi.mock('@/components/new-workspace/SmartWorkspaceNameField', () => ({ + default: () => +})) + +vi.mock('@/components/new-workspace/ProjectCombobox', () => ({ + default: () =>
+})) + +vi.mock('@/components/new-workspace/SetProjectLocationDialog', () => ({ + SetProjectLocationDialog: ({ + option, + projectName + }: { + option: { label: string } | null + projectName: string + }) => + option ? ( +
+ ) : null +})) + +const projectOptions: NewWorkspaceProjectOption[] = [ + { + kind: 'project-group', + id: 'project-group:platform', + projectGroupId: 'platform', + displayName: 'Platform', + badgeColor: 'var(--muted-foreground)', + detail: '/workspace/platform', + parentPath: '/workspace/platform', + connectionId: null + } +] + +const hostOptions: ProjectHostSetupOption[] = [ + { + kind: 'ready', + id: 'setup-local', + projectId: 'project-group:platform', + hostId: 'local', + repoId: 'repo-a', + label: 'Local Mac', + detail: 'Orca', + path: '/Users/alice/orca' + }, + { + kind: 'needs-setup', + id: 'needs-setup:ssh:devbox', + projectId: 'project-group:platform', + hostId: 'ssh:devbox', + label: 'Devbox', + detail: 'Project location not set', + isAvailable: true, + attention: false, + canSetLocation: true + } +] + +function renderCard( + overrides: Partial> = {} +): HTMLDivElement { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + act(() => { + root.render( + {}} + eligibleRepos={[]} + repoId="repo-a" + projectOptions={projectOptions} + selectedProjectId="project-group:platform" + selectedRepoIsGit + onRepoChange={() => {}} + onProjectChange={() => {}} + primaryActionLabel="Create workspace" + name="" + onNameValueChange={() => {}} + onSmartGitHubItemSelect={() => {}} + onSmartGitLabItemSelect={() => {}} + onSmartBranchSelect={() => {}} + onSmartLinearIssueSelect={() => {}} + smartNameSelection={null} + onClearSmartNameSelection={() => {}} + canReuseSelectedBranch={false} + reuseSelectedBranch={false} + onReuseSelectedBranchChange={() => {}} + forkPushWarning={null} + detectedAgentIds={null} + onOpenAgentSettings={() => {}} + advancedOpen={false} + onToggleAdvanced={() => {}} + createDisabled={false} + projectError={null} + creating={false} + onCreate={() => {}} + note="" + onNoteChange={() => {}} + setupConfig={null} + requiresExplicitSetupChoice={false} + setupDecision={null} + onSetupDecisionChange={() => {}} + setupAgentStartupPolicy="start-immediately" + onSetupAgentStartupPolicyChange={() => {}} + shouldWaitForSetupCheck={false} + resolvedSetupDecision={null} + createError={null} + selectedRepoConnectionId={null} + selectedRepoSshStatus={null} + selectedRepoRequiresConnection={false} + selectedRepoConnectInProgress={false} + onConnectSelectedRepo={async () => {}} + canUseSparseCheckout={false} + sparsePresets={[]} + sparseSelectedPresetId={null} + onSparseSelectPreset={() => {}} + branchNameOverride={undefined} + onBranchNameOverrideChange={() => {}} + branchesEnabled={false} + setupControlsEnabled={false} + sparseControlsEnabled={false} + projectHostSetupOptions={hostOptions} + selectedProjectHostSetupId="setup-local" + {...overrides} + /> + ) + }) + return container +} + +describe('NewWorkspaceComposerCard set location', () => { + let container: HTMLDivElement | null = null + + beforeEach(() => { + storeMocks.closeModal.mockReset() + storeMocks.openModal.mockReset() + storeMocks.openSettingsPage.mockReset() + }) + + afterEach(() => { + container?.remove() + container = null + }) + + it('opens set-location over the composer without leaving the create dialog', () => { + const nestedOpenChanges: boolean[] = [] + container = renderCard({ + onNestedDialogOpenChange: (open) => nestedOpenChanges.push(open) + }) + + act(() => { + container?.querySelector('div[data-run-target-combobox-root="true"]')?.click() + }) + const setLocation = [...document.body.querySelectorAll('button')].find( + (button) => button.textContent?.includes('Set project location') + ) + expect(setLocation).toBeTruthy() + act(() => setLocation?.click()) + + const dialog = document.body.querySelector('[data-testid="set-project-location-dialog"]') + expect(dialog?.getAttribute('data-host')).toBe('Devbox') + expect(dialog?.getAttribute('data-project')).toBe('Platform') + expect(nestedOpenChanges).toEqual([true]) + expect(storeMocks.closeModal).not.toHaveBeenCalled() + expect(storeMocks.openModal).not.toHaveBeenCalled() + expect(storeMocks.openSettingsPage).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx index 33fc740980b..9775cd06dae 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx @@ -31,7 +31,9 @@ vi.mock('@/store', () => ({ setRuntimeEnvironmentStatus: storeMocks.setRuntimeEnvironmentStatus, activeModal: 'none', settings: { defaultTuiAgent: null, disabledTuiAgents: [] }, - updateSettings: vi.fn() + updateSettings: vi.fn(), + projects: [], + repos: [] }), { getState: () => ({ @@ -62,6 +64,10 @@ vi.mock('@/components/sidebar/AddRemoteHostDialog', () => ({ mode ?
: null })) +vi.mock('@/components/new-workspace/SetProjectLocationDialog', () => ({ + SetProjectLocationDialog: () => null +})) + vi.mock('@/components/sparse/SparseCheckoutPresetSelect', () => ({ default: () =>
})) @@ -155,7 +161,8 @@ const devboxNeedsSetupHostOption: ProjectHostSetupOption = { label: 'Devbox', detail: 'Project location not set', isAvailable: true, - attention: false + attention: false, + canSetLocation: true } const disconnectedDevboxNeedsSetupHostOption: ProjectHostSetupOption = { @@ -167,6 +174,7 @@ const disconnectedDevboxNeedsSetupHostOption: ProjectHostSetupOption = { detail: 'Connect this host to set up projects', isAvailable: false, attention: false, + canSetLocation: false, connectAction: { kind: 'ssh', targetId: 'devbox' } } @@ -179,6 +187,7 @@ const disconnectedBastionNeedsSetupHostOption: ProjectHostSetupOption = { detail: 'Connect this host to set up projects', isAvailable: false, attention: false, + canSetLocation: false, connectAction: { kind: 'ssh', targetId: 'bastion' } } @@ -601,7 +610,8 @@ describe('NewWorkspaceComposerCard folder task source mode', () => { openRunTargetPicker(current.container) const devboxItem = findRunTargetItem('Devbox') - expect(devboxItem?.textContent).toContain('Project location not set') + expect(devboxItem?.textContent).not.toContain('Project location not set') + expect(devboxItem?.textContent).toContain('Set project location') // Not-connected rows stay highlightable (never `disabled`) so they hover like // the other rows; they're quieted visually instead. expect(devboxItem?.hasAttribute('data-disabled')).toBe(false) diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index 4606a625c07..2de214624e1 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -34,6 +34,7 @@ import { } from '@/lib/text-control-paste' import { getScreenSubmitModifierLabel } from '@/lib/screen-submit-shortcut' import { useContextualTour } from '@/components/contextual-tours/use-contextual-tour' +import { resolveProjectCloneUrlPrefill } from '@/lib/project-clone-url-prefill' import type { GitHubWorkItem } from '../../../shared/github/work-item-types' import type { GitLabWorkItem } from '../../../shared/gitlab-types' import type { JiraIssue } from '../../../shared/jira-types' @@ -48,6 +49,7 @@ import SmartWorkspaceNameField, { import type { SmartNameMode } from '@/components/new-workspace/smart-workspace-source-results' import ProjectCombobox from '@/components/new-workspace/ProjectCombobox' import RunTargetCombobox from '@/components/new-workspace/RunTargetCombobox' +import { SetProjectLocationDialog } from '@/components/new-workspace/SetProjectLocationDialog' import { AddRemoteHostDialog, type AddRemoteHostMode @@ -162,6 +164,8 @@ type NewWorkspaceComposerCardProps = { sparseControlsEnabled?: boolean /** When set, "Add project" opens a host-provided flow instead of swapping the store's active modal. */ onAddProjectOverride?: () => void + /** Fires as the nested Set-project-location dialog opens and closes, so the host can stand down its Escape/submit handling. */ + onNestedDialogOpenChange?: (open: boolean) => void } const SSH_STATUS_LABELS: Partial> = { @@ -371,7 +375,8 @@ export default function NewWorkspaceComposerCard({ sparseSelectedPresetId, onSparseSelectPreset, sparseControlsEnabled = true, - onAddProjectOverride + onAddProjectOverride, + onNestedDialogOpenChange }: NewWorkspaceComposerCardProps): React.JSX.Element { // Why: subscribe (form uses translate() directly) so an open create dialog repaints when the UI language changes. useTranslation() @@ -499,6 +504,32 @@ export default function NewWorkspaceComposerCard({ const handleAddRemoteServer = React.useCallback((): void => { setAddRemoteHostMode('server') }, []) + const [setLocationOption, setSetLocationOption] = + React.useState(null) + const handleSetLocation = React.useCallback( + (option: NeedsSetupProjectHostOption): void => { + setSetLocationOption(option) + onNestedDialogOpenChange?.(true) + }, + [onNestedDialogOpenChange] + ) + const handleSetLocationClose = React.useCallback((): void => { + setSetLocationOption(null) + onNestedDialogOpenChange?.(false) + }, [onNestedDialogOpenChange]) + const handleSetLocationReady = React.useCallback( + (setupId: string): void => { + handleSetLocationClose() + onProjectHostSetupChange?.(setupId) + }, + [handleSetLocationClose, onProjectHostSetupChange] + ) + const projects = useAppStore((state) => state.projects) + const repos = useAppStore((state) => state.repos) + const defaultCloneUrl = React.useMemo( + () => resolveProjectCloneUrlPrefill(projects, repos, selectedProjectId), + [projects, repos, selectedProjectId] + ) const handleConnectRunTargetHost = React.useCallback( async (option: NeedsSetupProjectHostOption): Promise => { const action = option.connectAction @@ -704,6 +735,7 @@ export default function NewWorkspaceComposerCard({ onAddSshHost={handleAddSshHost} onAddRemoteServer={handleAddRemoteServer} onConnectHost={handleConnectRunTargetHost} + onSetLocation={handleSetLocation} /> {ephemeralVmRecipeError ? (

@@ -1231,6 +1263,14 @@ export default function NewWorkspaceComposerCard({ the in-progress workspace form is preserved; on success the new host flows back into the run-target picker via the store. */} +

) } diff --git a/src/renderer/src/components/NewWorkspaceComposerModal.tsx b/src/renderer/src/components/NewWorkspaceComposerModal.tsx index 77b9cf64e52..2309ef54b50 100644 --- a/src/renderer/src/components/NewWorkspaceComposerModal.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerModal.tsx @@ -197,6 +197,7 @@ function QuickTabBody({ // outcomes still navigate away and tear the whole modal down.) const [addProjectOpen, setAddProjectOpen] = useState(false) const [addProjectMounted, setAddProjectMounted] = useState(false) + const [setLocationOpen, setSetLocationOpen] = useState(false) const handleOpenAddProject = useCallback((): void => { setAddProjectMounted(true) setAddProjectOpen(true) @@ -238,10 +239,10 @@ function QuickTabBody({ : translate('auto.components.NewWorkspaceComposerModal.createWorkspace', 'Create workspace') // Cmd/Ctrl+Enter submits, Esc first blurs the focused input (like the full page). - const nestedDialogOpen = agentSettingsOpen || addProjectOpen + const nestedDialogOpen = agentSettingsOpen || addProjectOpen || setLocationOpen useEffect(() => { if (!active || nestedDialogOpen) { - // Why: while a nested dialog (Add Project / Agents) is layered on top, + // Why: while a nested dialog (Add Project / Agents / Set location) is layered on top, // this capture-phase handler must not steal its Escape (which should // close only the nested dialog) or fire composer submit underneath it. return @@ -324,6 +325,7 @@ function QuickTabBody({ onOpenAgentSettings={() => setAgentSettingsOpen(true)} onCreate={() => void handleCreate()} onAddProjectOverride={handleOpenAddProject} + onNestedDialogOpenChange={setSetLocationOpen} /> {addProjectMounted ? ( diff --git a/src/renderer/src/components/PullRequestPage.tsx b/src/renderer/src/components/PullRequestPage.tsx index 72f5d151eb2..12db9c71fd9 100644 --- a/src/renderer/src/components/PullRequestPage.tsx +++ b/src/renderer/src/components/PullRequestPage.tsx @@ -1,5888 +1,4 @@ -/* eslint-disable max-lines -- Why: duplicated from GitHubItemDialog so the dedicated PR full-page surface can evolve its Primer-styled header without destabilizing the issue dialog; planned to refactor shared parts out later. */ -import React, { - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, - useSyncExternalStore -} from 'react' -import { useVirtualizer } from '@tanstack/react-virtual' -import { useShallow } from 'zustand/react/shallow' -import type { editor as monacoEditor } from 'monaco-editor' -import { - ArrowLeft, - ArrowRight, - Check, - ChevronDown, - ChevronLeft, - CircleDashed, - CircleDot, - Copy, - ExternalLink, - FileText, - FolderKanban, - GitMerge, - GitPullRequest, - GitPullRequestClosed, - GitPullRequestDraft, - ListChecks, - LoaderCircle, - MessageSquare, - MessageSquarePlus, - PanelLeftOpen, - Pencil, - Plus, - RefreshCw, - Send, - Wrench, - X -} from 'lucide-react' -import { toast } from 'sonner' -import { Button } from '@/components/ui/button' -import { ButtonGroup } from '@/components/ui/button-group' -import { Input } from '@/components/ui/input' -import { useMountedRef } from '@/hooks/useMountedRef' -import { useConfirmationDialog } from '@/components/confirmation-dialog-context' -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger -} from '@/components/ui/accordion' -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' -import CommentMarkdown from '@/components/sidebar/CommentMarkdown' -import { cn } from '@/lib/utils' -import { setWithLRU } from '@/lib/scroll-cache' -import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' -import { DiffSectionItem } from '@/components/editor/DiffSectionItem' -import type { DecoratedDiffComment } from '@/components/diff-comments/decorated-diff-comment' -import { - CombinedDiffFileTree, - createCombinedDiffSectionIndexMap, - handleCombinedDiffFileTreeNavigation -} from '@/components/editor/CombinedDiffFileTree' -import { - getDiffSectionEstimatedHeight, - isIntrinsicHeightImageDiff -} from '@/components/editor/diff-section-layout' -import type { DiffSection } from '@/components/editor/diff-section-types' -import { removeDiffSectionMeasuredHeight } from '@/components/editor/diff-section-height-cache' -import { - getCombinedDiffBranchEntriesInTreeOrder, - type CombinedDiffFileTreeEntry -} from '@/components/editor/combined-diff-file-tree-model' -import { - getStoredTextDiffContent, - getStoredTextDiffResult -} from '@/components/editor/large-diff-section-content' -import { CHECK_COLOR, CHECK_ICON } from '@/components/right-sidebar/checks-panel-content' -import { SourceControlAgentActionDialog } from '@/components/right-sidebar/SourceControlAgentActionDialog' -import { - beginGitHubChecksTabDetails, - createGitHubChecksTabState, - resetGitHubChecksTabForSource, - resolveGitHubChecksTabState, - settleGitHubChecksTabDetails, - toggleGitHubChecksTabExpandedKey, - updateGitHubChecksTabLocalChecks, - type CheckDetailsLoadState -} from '@/components/github-checks-tab-state' -import { - clearGitHubLinkCopied, - createGitHubLinkCopyState, - markGitHubLinkCopied, - resolveGitHubLinkCopyState -} from '@/components/github-link-copy-state' -import { - resolveGitHubBodyDraft, - shouldSyncGitHubBodyDraft -} from '@/components/github-body-draft-state' -import { - filterPRCommentsByAudience, - getPRCommentAudienceCounts, - type PRCommentAudienceFilter -} from '../../../shared/pr-comment-audience' -import { - getPRCommentAudienceEmptyLabel, - getPrCommentAudienceFilters -} from '@/lib/pr-comment-audience-labels' -import { usePRBotAuthorOverrides } from '@/lib/pr-bot-author-overrides' -import { - getPRCommentGroupCount, - getPRCommentGroupId, - getPRCommentGroupRoot, - groupPRComments, - isResolvedPRCommentGroup, - type PRCommentGroup -} from '../../../shared/pr-comment-groups' -import { - PR_COMMENT_OPEN_AUTHOR_CLASS, - PR_COMMENT_RESOLVED_AUTHOR_CLASS, - PR_COMMENT_RESOLVED_CONTAINER_CLASS -} from '@/lib/pr-comment-resolution-classes' -import { resolveCommentReplyTarget } from '@/components/comment-reply-target-state' -import { - attachPRReviewReplyParent, - canPostPRReviewThreadReply -} from '@/components/right-sidebar/pr-comments-ai-launch-ack' -import { buildPRCommentConversationReplyBody } from '@/components/right-sidebar/pr-comment-fixing-reply-body' -import { useAppStore } from '@/store' -import { useAllWorktrees } from '@/store/selectors' -import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' -import { withGitHubCheckDetailsTimeout } from '@/runtime/github-check-details-timeout' -import { useRepoLabels, useRepoAssignees, useImmediateMutation } from '@/hooks/useIssueMetadata' -import { useRepoLabelsBySlug, useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata' -import { - getGitHubPRReviewerRows, - normalizeGitHubReviewerLogins, - parseGitHubReviewerInputLogins -} from '@/components/github-pr-reviewer-display' -import { - filterGitHubPRReviewerCandidates, - getGitHubPRReviewerQueryState -} from '@/components/github/github-pr-reviewer-candidate-filter' -import { GitHubUserAvatar } from '@/components/github/github-user-avatar' -import { filterGitHubMentionOptions } from '@/components/github/github-mention-option-filter' -import { - getCommentBodySubmitState, - hasBoundedCommentBodyText -} from '@/lib/comment-body-submit-state' -import { onGitHubWorkItemDetailsCacheMutation } from '@/lib/github-work-item-details-cache-events' -import { lookupGitHubWorkItemDetailsForSource } from '@/lib/github-work-item-source-lookup' -import { - canUseGitHubRepoContext, - getGitHubMutationRoutingSettings, - getGitHubRuntimeRepoId, - getGitHubSourceRuntimeHost -} from '@/lib/github-source-runtime-context' -import { presentGitHubPRMergeState } from '@/components/github-pr-merge-state' -import { - GITHUB_PR_MERGE_METHOD_LABELS, - resolveGitHubPRMergeMethods -} from '../../../shared/github/pull-request-merge-methods' -import { githubRepoIdentityKey } from '../../../shared/github/repository-identity-key' -import { - findGithubPrWorkspaceAttachment, - getGithubPrWorkspaceAttachmentLabel -} from '@/lib/github-work-item-workspace-attachment' -import { startFixChecksAgent } from '@/lib/fix-checks-agent-launch' -import { launchWorkItemDirect } from '@/lib/launch-work-item-direct' -import { getLocalRepoProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' -import { CLIENT_PLATFORM } from '@/lib/new-workspace' -import { readSourceControlLaunchRecipeAgentId } from '@/lib/source-control-launch-agent-selection' -import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform' -import { activateAndRevealWorktree } from '@/lib/worktree-activation' -import { buildFixBrokenChecksPrompt, getBrokenChecks } from '@/components/pr-checks-fix-prompt' -import { resolveSourceControlActionRecipe } from '../../../shared/source-control-ai' -import type { - SourceControlActionRecipe, - SourceControlLaunchActionId -} from '../../../shared/source-control-ai-actions' -import { - saveSourceControlActionRecipe, - type SourceControlAiWriteTarget -} from '../../../shared/source-control-ai-recipe-save' -import type { GitBranchChangeEntry, GitDiffResult } from '../../../shared/git-diff-compare-types' -import type { PRCheckDetail } from '../../../shared/github/check-types' -import type { PRComment } from '../../../shared/github/comment-types' -import type { - GitHubAssignableUser, - GitHubOwnerRepo, - GitHubPRFile, - GitHubPRFileContents, - GitHubPRFileViewedState, - GitHubPRMergeMethod -} from '../../../shared/github/pull-request-types' -import type { GitHubWorkItem, GitHubWorkItemDetails } from '../../../shared/github/work-item-types' -import { - getTaskSourceCacheScope, - getTaskSourceRuntimeSettings, - type TaskSourceContext -} from '../../../shared/task-source-context' -import { translate } from '@/i18n/i18n' -import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' -import { sortChecksBySeverity } from '../../../shared/pr-check-severity-order' -import { - getCheckConclusion, - getCheckCountChips, - getCheckCounts, - getChecksSummaryLabel -} from '@/components/pr-check-counts' -import { - normalizeItemDialogTab, - parseOwnerRepoFromItemUrl, - resolvePullRequestRepo, - type GitHubWorkItemProjectOrigin, - type ItemDialogTab -} from '@/components/github/github-work-item-identity' -import { - addIssueCommentForRepo, - addPRReviewCommentForRepo, - addPRReviewCommentReplyForRepo, - notifyWorkItemDetailsMutation, - setPRFileViewedForRepo -} from '@/components/github/github-work-item-comment-mutations' -import { - runIssueUpdate, - runPullRequestStateUpdate, - runWorkItemBodyUpdate -} from '@/components/github/github-work-item-edit-mutations' -import { - PR_FILE_CONTENT_CACHE_MAX_BYTES, - getRetainedPRFileContentsByteCount, - isPRFileViewed -} from '@/components/github/pr-file-content-size' -import { - PR_DIFF_OVERSCAN, - getPRFileContentsRenderLimit, - getPRFileDiffResult, - getPRFileSectionKey, - gitHubPRFileToBranchEntry, - type PRFilesCombinedDiffViewerProps -} from '@/components/github/pr-file-diff-mapping' -import { - buildRequestedReviewUsers, - formatRelativeTime, - getStateLabel, - mergeReviewerSuggestions, - ReviewerAvatar -} from '@/components/github/work-item-state-presentation' -import { - formatCheckTimestamp, - getCheckDetailsKey, - getCheckStatusLabel -} from '@/components/github/pr-check-presentation' -import { CommentCodeContext } from '@/components/github/CommentCodeContext' -import { CommentReactions } from '@/components/github/CommentReactions' -import { PRAssigneesPanel } from '@/components/github/PRAssigneesPanel' -import { PRViewedCheckbox } from '@/components/github/PRViewedCheckbox' - -export type { ItemDialogTab } - -export type PullRequestPageProjectOrigin = GitHubWorkItemProjectOrigin - -type MentionOption = { - login: string - name?: string | null - avatarUrl?: string - source: string -} - -type MentionQuery = { - atIndex: number - query: string -} - -type PullRequestPageProps = { - workItem: GitHubWorkItem | null - repoPath: string | null - repoId?: string | null - sourceContext?: TaskSourceContext | null - initialTab?: ItemDialogTab - backLabel?: string - /** Called when the user clicks the primary CTA to start work from this item. */ - onUse: (item: GitHubWorkItem) => void - onReviewRequestsChange?: ( - itemKey: { id: string; repoId: string }, - reviewRequests: GitHubAssignableUser[] - ) => void - onClose: () => void - /** Optional Project-origin context; when set, slug-addressed IPCs route writes to the row's repo instead of `repoPath` (both may be set — slug wins for writes). */ - projectOrigin?: PullRequestPageProjectOrigin -} - -function findMentionQuery(value: string, caret: number): MentionQuery | null { - const beforeCaret = value.slice(0, caret) - const match = /(^|[\s([{,])@([A-Za-z0-9-]*)$/.exec(beforeCaret) - if (!match) { - return null - } - const query = match[2] ?? '' - return { - atIndex: beforeCaret.length - query.length - 1, - query - } -} - -function buildMentionOptions({ - item, - comments, - participants, - assignableUsers -}: { - item: GitHubWorkItem - comments: PRComment[] - participants: GitHubAssignableUser[] - assignableUsers: GitHubAssignableUser[] -}): MentionOption[] { - const byLogin = new Map() - const add = ( - login: string | null | undefined, - source: string, - avatarUrl?: string, - name?: string | null - ): void => { - if (!login || login === 'ghost') { - return - } - const key = login.toLowerCase() - const existing = byLogin.get(key) - if (existing) { - if (!existing.avatarUrl && avatarUrl) { - existing.avatarUrl = avatarUrl - } - if (!existing.name && name) { - existing.name = name - } - return - } - byLogin.set(key, { login, source, avatarUrl, name }) - } - - add(item.author, item.type === 'pr' ? 'PR author' : 'Issue author') - for (const comment of comments) { - add(comment.author, 'Commenter', comment.authorAvatarUrl) - } - for (const user of participants) { - add(user.login, 'Participant', user.avatarUrl, user.name) - } - for (const user of assignableUsers) { - add(user.login, 'Team member', user.avatarUrl, user.name) - } - - return Array.from(byLogin.values()) -} - -function getStateTone(item: GitHubWorkItem): string { - if (item.type === 'pr') { - if (item.state === 'merged') { - return 'border-purple-500/30 bg-purple-500/10 text-purple-600 dark:text-purple-300' - } - if (item.state === 'draft') { - return 'border-slate-500/30 bg-slate-500/10 text-slate-600 dark:text-slate-300' - } - if (item.state === 'closed') { - return 'border-rose-500/30 bg-rose-500/10 text-rose-600 dark:text-rose-300' - } - return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300' - } - if (item.state === 'closed') { - return 'border-rose-500/30 bg-rose-500/10 text-rose-600 dark:text-rose-300' - } - return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300' -} - -function WorkItemStateBadge({ - item, - className -}: { - item: GitHubWorkItem - className?: string -}): React.JSX.Element { - return ( - - {getStateLabel(item)} - - ) -} - -function PRReviewersPanel({ - item, - loading, - repoPath, - sourceContext, - projectOrigin, - onReviewersRequested -}: { - item: GitHubWorkItem - loading: boolean - repoPath: string | null - sourceContext?: TaskSourceContext | null - projectOrigin?: PullRequestPageProjectOrigin - onReviewersRequested: (reviewRequests: GitHubAssignableUser[]) => void -}): React.JSX.Element { - const [open, setOpen] = useState(false) - const [reviewerInput, setReviewerInput] = useState('') - const [activeReviewerCursor, setActiveReviewerCursor] = useState({ - resetKey: '', - index: 0 - }) - const [submitting, setSubmitting] = useState(false) - const [localReviewRequests, setLocalReviewRequests] = useState( - () => item.reviewRequests ?? [] - ) - const [reviewRequestsSource, setReviewRequestsSource] = useState(() => ({ - itemId: item.id, - repoId: item.repoId, - reviewRequests: item.reviewRequests - })) - const patchWorkItem = useAppStore((s) => s.patchWorkItem) - const repoOwnerSettings = useAppStore( - useShallow((s) => getSettingsForRepoRuntimeOwner(s, item.repoId ?? null)) - ) - const sourceSettings = useMemo( - () => - sourceContext?.provider === 'github' - ? ({ - ...repoOwnerSettings, - ...getTaskSourceRuntimeSettings(sourceContext) - } as typeof repoOwnerSettings) - : repoOwnerSettings, - [repoOwnerSettings, sourceContext] - ) - const reviewerInputRef = useRef(null) - const reviewerInputFocusFrameRef = useRef(null) - const reviewerPanelMountedRef = useRef(true) - - const cancelReviewerInputFocusFrame = useCallback((): void => { - if (reviewerInputFocusFrameRef.current !== null) { - cancelAnimationFrame(reviewerInputFocusFrameRef.current) - reviewerInputFocusFrameRef.current = null - } - }, []) - - const scheduleReviewerInputFocus = useCallback((): void => { - if (!reviewerPanelMountedRef.current) { - return - } - cancelReviewerInputFocusFrame() - reviewerInputFocusFrameRef.current = requestAnimationFrame(() => { - reviewerInputFocusFrameRef.current = null - reviewerInputRef.current?.focus() - }) - }, [cancelReviewerInputFocusFrame]) - - useEffect(() => { - reviewerPanelMountedRef.current = true - return () => { - reviewerPanelMountedRef.current = false - cancelReviewerInputFocusFrame() - } - }, [cancelReviewerInputFocusFrame]) - - // Why: clear stale optimistic review requests before paint on item switch/refetch (a passive Effect leaves one stale render). - if ( - reviewRequestsSource.itemId !== item.id || - reviewRequestsSource.repoId !== item.repoId || - reviewRequestsSource.reviewRequests !== item.reviewRequests - ) { - setReviewRequestsSource({ - itemId: item.id, - repoId: item.repoId, - reviewRequests: item.reviewRequests - }) - setLocalReviewRequests(item.reviewRequests ?? []) - } - - const reviewerSeedUsers = useMemo(() => { - const byLogin = new Map() - const add = (user: GitHubAssignableUser): void => { - if (!user.login) { - return - } - byLogin.set(user.login.toLowerCase(), user) - } - for (const user of localReviewRequests) { - add(user) - } - for (const review of item.latestReviews ?? []) { - add({ - login: review.login, - name: null, - avatarUrl: review.avatarUrl ?? '' - }) - } - if (item.author) { - add({ login: item.author, name: null, avatarUrl: '' }) - } - return Array.from(byLogin.values()) - }, [item.author, item.latestReviews, localReviewRequests]) - - const reviewRepo = useMemo( - () => resolvePullRequestRepo(item, projectOrigin), - [item, projectOrigin] - ) - const reviewerMetadataBySlug = useRepoAssigneesBySlug( - open && reviewRepo ? reviewRepo.owner : null, - open && reviewRepo ? reviewRepo.repo : null, - reviewerSeedUsers.map((user) => user.login), - sourceSettings, - reviewRepo?.host - ) - const reviewerMetadataByPath = useRepoAssignees( - open && !reviewRepo ? repoPath : null, - open && !reviewRepo ? item.repoId : null, - sourceSettings - ) - const reviewerMetadata = reviewRepo ? reviewerMetadataBySlug : reviewerMetadataByPath - const displayItem = { ...item, reviewRequests: localReviewRequests } - const reviewers = getGitHubPRReviewerRows(displayItem) - const authorLogin = item.author?.toLowerCase() ?? null - const reviewerCandidates = useMemo( - () => - mergeReviewerSuggestions(reviewerMetadata.data, reviewerSeedUsers).filter( - (user) => user.login.toLowerCase() !== authorLogin - ), - [authorLogin, reviewerMetadata.data, reviewerSeedUsers] - ) - const reviewerCandidatesByLogin = useMemo( - () => new Map(reviewerCandidates.map((user) => [user.login.toLowerCase(), user])), - [reviewerCandidates] - ) - const selectedReviewerLogins = useMemo( - () => - new Set( - localReviewRequests.map((reviewer) => reviewer.login.trim().toLowerCase()).filter(Boolean) - ), - [localReviewRequests] - ) - const reviewerQueryState = useMemo( - () => getGitHubPRReviewerQueryState(reviewerInput), - [reviewerInput] - ) - const reviewerQuery = reviewerQueryState.query - const filteredReviewerCandidates = useMemo( - () => - filterGitHubPRReviewerCandidates({ - candidates: reviewerCandidates, - queryState: reviewerQueryState - }), - [reviewerCandidates, reviewerQueryState] - ) - const suggestedReviewerRows = useMemo( - () => - reviewerQuery.length === 0 && !reviewerQueryState.isTooLarge - ? reviewerSeedUsers - .filter((user) => !selectedReviewerLogins.has(user.login.toLowerCase())) - .filter((user) => user.login.toLowerCase() !== authorLogin) - .map((user) => reviewerCandidatesByLogin.get(user.login.toLowerCase()) ?? user) - .slice(0, 1) - : [], - [ - authorLogin, - reviewerCandidatesByLogin, - reviewerQuery.length, - reviewerQueryState.isTooLarge, - reviewerSeedUsers, - selectedReviewerLogins - ] - ) - const everyoneElseReviewerRows = useMemo(() => { - const suggestedLogins = new Set(suggestedReviewerRows.map((user) => user.login.toLowerCase())) - return filteredReviewerCandidates.filter( - (user) => !suggestedLogins.has(user.login.toLowerCase()) - ) - }, [filteredReviewerCandidates, suggestedReviewerRows]) - const actionableReviewerRows = useMemo( - () => [...suggestedReviewerRows, ...everyoneElseReviewerRows], - [everyoneElseReviewerRows, suggestedReviewerRows] - ) - - const reviewerCursorResetKey = `${reviewerQuery}\u0000${actionableReviewerRows.length}` - if (activeReviewerCursor.resetKey !== reviewerCursorResetKey) { - setActiveReviewerCursor({ resetKey: reviewerCursorResetKey, index: 0 }) - } - const activeReviewerIndex = - activeReviewerCursor.resetKey === reviewerCursorResetKey ? activeReviewerCursor.index : 0 - const setActiveReviewerIndex = useCallback( - (nextIndex: number | ((current: number) => number)): void => { - setActiveReviewerCursor((current) => { - const currentIndex = current.resetKey === reviewerCursorResetKey ? current.index : 0 - return { - resetKey: reviewerCursorResetKey, - index: typeof nextIndex === 'function' ? nextIndex(currentIndex) : nextIndex - } - }) - }, - [reviewerCursorResetKey] - ) - - const hasReviewerMetadata = - item.reviewDecision !== undefined || - localReviewRequests.length > 0 || - item.reviewRequests !== undefined || - item.latestReviews !== undefined - const canRequestReview = - !!repoPath || getActiveRuntimeTarget(sourceSettings).kind === 'environment' - - const handleRequestReview = async (requestedLogins?: string[]): Promise => { - if (submitting) { - return - } - const logins = normalizeGitHubReviewerLogins( - requestedLogins ?? parseGitHubReviewerInputLogins(reviewerInput), - selectedReviewerLogins - ) - if (logins.length === 0) { - toast.error(translate('auto.components.PullRequestPage.dace0d1a9f', 'Enter a reviewer')) - return - } - if (localReviewRequests.length + logins.length > 15) { - toast.error( - translate( - 'auto.components.PullRequestPage.8f369a6b6b', - 'You can request up to 15 reviewers' - ) - ) - return - } - const target = getActiveRuntimeTarget(sourceSettings) - if (target.kind !== 'environment' && !repoPath) { - toast.error( - translate( - 'auto.components.PullRequestPage.1ae11c905c', - 'No repo context available for this pull request.' - ) - ) - return - } - setSubmitting(true) - try { - const runtimeRepo = getGitHubRuntimeRepoId(sourceContext, item.repoId) - const result = - target.kind === 'environment' - ? await callRuntimeRpc<{ ok: boolean; error?: string }>( - target, - 'github.requestPRReviewers', - { - repo: runtimeRepo, - prNumber: item.number, - reviewers: logins, - prRepo: reviewRepo - }, - { timeoutMs: 30_000 } - ) - : await window.api.gh.requestPRReviewers({ - repoPath: repoPath ?? '', - repoId: item.repoId, - sourceContext, - prNumber: item.number, - reviewers: logins, - prRepo: reviewRepo - }) - if (!reviewerPanelMountedRef.current) { - return - } - if (!result.ok) { - toast.error( - result.error ?? - translate('auto.components.PullRequestPage.2560588245', 'Failed to request reviewer') - ) - return - } - const nextReviewRequests = buildRequestedReviewUsers( - logins, - reviewerCandidates, - localReviewRequests - ) - setLocalReviewRequests(nextReviewRequests) - patchWorkItem(item.id, { reviewRequests: nextReviewRequests }, item.repoId, { - sourceContext - }) - onReviewersRequested(nextReviewRequests) - if (target.kind === 'environment') { - notifyWorkItemDetailsMutation( - { - repoPath: repoPath ?? '', - repoId: item.repoId, - sourceContext, - type: 'pr', - number: item.number - }, - { local: false } - ) - } - setReviewerInput('') - toast.success( - logins.length === 1 - ? translate('auto.components.PullRequestPage.03282ff3b9', 'Reviewer requested') - : translate('auto.components.PullRequestPage.102d3d177f', 'Reviewers requested') - ) - } catch { - if (reviewerPanelMountedRef.current) { - toast.error( - translate('auto.components.PullRequestPage.2560588245', 'Failed to request reviewer') - ) - } - } finally { - if (reviewerPanelMountedRef.current) { - setSubmitting(false) - } - } - } - - const handleRemoveReviewers = async (reviewersToRemove: string[]): Promise => { - if (submitting) { - return - } - const selected = new Set(localReviewRequests.map((reviewer) => reviewer.login.toLowerCase())) - const logins = reviewersToRemove - .map((reviewer) => reviewer.trim().replace(/^@/, '')) - .filter((reviewer) => reviewer.length > 0 && selected.has(reviewer.toLowerCase())) - if (logins.length === 0) { - return - } - const target = getActiveRuntimeTarget(sourceSettings) - if (target.kind !== 'environment' && !repoPath) { - toast.error( - translate( - 'auto.components.PullRequestPage.1ae11c905c', - 'No repo context available for this pull request.' - ) - ) - return - } - setSubmitting(true) - try { - const runtimeRepo = getGitHubRuntimeRepoId(sourceContext, item.repoId) - const result = - target.kind === 'environment' - ? await callRuntimeRpc<{ ok: boolean; error?: string }>( - target, - 'github.removePRReviewers', - { - repo: runtimeRepo, - prNumber: item.number, - reviewers: logins, - prRepo: reviewRepo - }, - { timeoutMs: 30_000 } - ) - : await window.api.gh.removePRReviewers({ - repoPath: repoPath ?? '', - repoId: item.repoId, - sourceContext, - prNumber: item.number, - reviewers: logins, - prRepo: reviewRepo - }) - if (!reviewerPanelMountedRef.current) { - return - } - if (!result.ok) { - toast.error( - result.error ?? - translate('auto.components.PullRequestPage.c798fa0ec7', 'Failed to remove reviewer') - ) - return - } - const removed = new Set(logins.map((login) => login.toLowerCase())) - const nextReviewRequests = localReviewRequests.filter( - (reviewer) => !removed.has(reviewer.login.toLowerCase()) - ) - setLocalReviewRequests(nextReviewRequests) - patchWorkItem(item.id, { reviewRequests: nextReviewRequests }, item.repoId, { - sourceContext - }) - onReviewersRequested(nextReviewRequests) - if (target.kind === 'environment') { - notifyWorkItemDetailsMutation( - { - repoPath: repoPath ?? '', - repoId: item.repoId, - sourceContext, - type: 'pr', - number: item.number - }, - { local: false } - ) - } - setReviewerInput('') - toast.success( - logins.length === 1 - ? translate('auto.components.PullRequestPage.2c1d93da43', 'Reviewer removed') - : translate('auto.components.PullRequestPage.1e6d089420', 'Reviewers removed') - ) - } catch { - if (reviewerPanelMountedRef.current) { - toast.error( - translate('auto.components.PullRequestPage.c798fa0ec7', 'Failed to remove reviewer') - ) - } - } finally { - if (reviewerPanelMountedRef.current) { - setSubmitting(false) - } - } - } - - const requestReviewer = async (reviewer: GitHubAssignableUser): Promise => { - await (selectedReviewerLogins.has(reviewer.login.toLowerCase()) - ? handleRemoveReviewers([reviewer.login]) - : handleRequestReview([reviewer.login])) - scheduleReviewerInputFocus() - } - - const handleReviewerPickerOpenChange = (nextOpen: boolean): void => { - setOpen(nextOpen) - if (nextOpen) { - scheduleReviewerInputFocus() - return - } - setReviewerInput('') - } - - const renderReviewerPickerRow = ( - reviewer: GitHubAssignableUser, - options: { suggested: boolean; activeIndex: number } - ): React.JSX.Element => { - const selected = selectedReviewerLogins.has(reviewer.login.toLowerCase()) - const active = actionableReviewerRows[activeReviewerIndex]?.login === reviewer.login - return ( - - ) - } - - return ( -
-
- {translate('auto.components.PullRequestPage.00d3be6bcd', 'Reviewers')} - - - - - { - event.preventDefault() - }} - > -
- setReviewerInput(event.target.value)} - disabled={submitting || !canRequestReview} - placeholder={translate( - 'auto.components.PullRequestPage.3bde131f49', - 'Type or choose a user' - )} - aria-label={translate('auto.components.PullRequestPage.a04c137bb7', 'Reviewer')} - aria-expanded={open} - aria-haspopup="listbox" - className="h-8 min-w-0 cursor-text rounded-md border-border/50 bg-background text-xs" - onKeyDown={(event) => { - if (event.key === 'ArrowDown' && actionableReviewerRows.length > 0) { - event.preventDefault() - setActiveReviewerIndex( - (current) => (current + 1) % actionableReviewerRows.length - ) - return - } - if (event.key === 'ArrowUp' && actionableReviewerRows.length > 0) { - event.preventDefault() - setActiveReviewerIndex( - (current) => - (current - 1 + actionableReviewerRows.length) % - actionableReviewerRows.length - ) - return - } - if (event.key === 'Enter') { - event.preventDefault() - const activeReviewer = actionableReviewerRows[activeReviewerIndex] - if (activeReviewer) { - void requestReviewer(activeReviewer) - return - } - void handleRequestReview() - return - } - if (event.key === 'Escape') { - event.preventDefault() - handleReviewerPickerOpenChange(false) - } - }} - /> -
-
- {reviewerMetadata.loading ? ( -
- {translate('auto.components.PullRequestPage.57750f4a8c', 'Loading...')} -
- ) : filteredReviewerCandidates.length > 0 ? ( - <> - {suggestedReviewerRows.length > 0 ? ( - <> -
- {translate('auto.components.PullRequestPage.828f045847', 'Suggestions')} -
- {suggestedReviewerRows.map((reviewer, index) => - renderReviewerPickerRow(reviewer, { - suggested: true, - activeIndex: index - }) - )} - - ) : null} -
- {translate('auto.components.PullRequestPage.2760fa29a4', 'Everyone else')} -
- {everyoneElseReviewerRows.length > 0 ? ( - everyoneElseReviewerRows.map((reviewer, index) => - renderReviewerPickerRow(reviewer, { - suggested: false, - activeIndex: suggestedReviewerRows.length + index - }) - ) - ) : ( -
- {translate( - 'auto.components.PullRequestPage.5ad00c7a0e', - 'No matching reviewers.' - )} -
- )} - - ) : ( -
- {reviewerMetadata.error ?? - (hasReviewerMetadata - ? translate( - 'auto.components.PullRequestPage.5ad00c7a0e', - 'No matching reviewers.' - ) - : translate( - 'auto.components.PullRequestPage.56ec6eafb7', - 'Open the PR details to view current reviewers.' - ))} -
- )} -
-
-
-
- {loading && !hasReviewerMetadata ? ( -
- - {translate('auto.components.PullRequestPage.acbd110867', 'Loading reviewers')} -
- ) : reviewers.length > 0 ? ( -
- {reviewers.map((reviewer) => { - const canRemoveReviewer = selectedReviewerLogins.has(reviewer.login.toLowerCase()) - return ( -
- -
-
- {reviewer.login} -
- {reviewer.name ? ( -
- {reviewer.name} -
- ) : null} -
- - {reviewer.stateLabel} - - {canRemoveReviewer ? ( - - - - - - {translate('auto.components.PullRequestPage.7f964a365a', 'Remove reviewer')} - - - ) : null} -
- ) - })} -
- ) : ( -
- {translate('auto.components.PullRequestPage.d10b6d5209', 'No reviewers requested.')} -
- )} -
- ) -} - -// SWR cache: reopening a drawer paints cached data instantly while a background refetch reconciles. See docs/gh-work-item-drawer-cache.md. -const WORK_ITEM_DETAILS_CACHE_MAX = 50 -const WORK_ITEM_DETAILS_FRESH_MS = 30_000 -const WORK_ITEM_DETAILS_UNAVAILABLE_MESSAGE = 'Unable to load details for this GitHub item.' -type WorkItemDetailsCacheEntry = { - details: GitHubWorkItemDetails | null - fetchedAt: number - pending?: Promise - error?: string -} -const workItemDetailsCache = new Map() - -// Why: useSyncExternalStore snapshot stability relies on every cache write replacing the entry object identity (delete+set). -const workItemDetailsCacheListeners = new Set<() => void>() -function subscribeWorkItemDetailsCache(listener: () => void): () => void { - workItemDetailsCacheListeners.add(listener) - return () => { - workItemDetailsCacheListeners.delete(listener) - } -} -function notifyWorkItemDetailsCache(): void { - for (const listener of workItemDetailsCacheListeners) { - listener() - } -} - -function getWorkItemDetailsCacheKey(args: { - repoPath: string - repoId: string - issueSourcePreference: string | undefined - sourceCacheScope?: string | null - type: 'issue' | 'pr' - number: number -}): string { - // Why: `\0` separator avoids collisions between key fields that may contain `:` or `/`. - const keyParts = args.sourceCacheScope - ? [args.repoId, args.sourceCacheScope, args.issueSourcePreference ?? 'auto', args.type] - : [args.repoId, args.issueSourcePreference ?? 'auto', args.type] - return [...keyParts, args.number].join('\0') -} - -function touchWorkItemDetailsCache(key: string, entry: WorkItemDetailsCacheEntry): void { - // Why: re-insert moves the key to MRU; Map insertion order keeps the oldest key first when evicting. - workItemDetailsCache.delete(key) - workItemDetailsCache.set(key, entry) - while (workItemDetailsCache.size > WORK_ITEM_DETAILS_CACHE_MAX) { - const oldest = workItemDetailsCache.keys().next().value - if (oldest === undefined) { - break - } - workItemDetailsCache.delete(oldest) - } - notifyWorkItemDetailsCache() -} - -// Exposed so mutation handlers can drop a stale entry after a local mutation (cross-window invalidation arrives via the `gh:workItemMutated` listener below). -export function invalidateWorkItemDetailsCacheForKey(key: string): void { - // Why: bump generation so an in-flight fetch launched before this invalidation won't write its stale result back. - workItemDetailsCacheGeneration += 1 - const existed = workItemDetailsCache.delete(key) - if (existed) { - notifyWorkItemDetailsCache() - } -} - -// Why: monotonic counter so an in-flight refetch that started before a mutation detects its result is stale and skips the write-back. -let workItemDetailsCacheGeneration = 0 - -// Why: without the exact key (cross-window events carry only repoPath+number+type), drop every entry matching that tuple regardless of source preference. -function invalidateWorkItemDetailsCacheByMatch(args: { - repoPath: string - repoId?: string - type: 'issue' | 'pr' - number: number -}): void { - const suffix = `\0${args.type}\0${args.number}` - const prefix = `${args.repoId ?? args.repoPath}\0` - let removed = false - for (const key of Array.from(workItemDetailsCache.keys())) { - if (key.startsWith(prefix) && key.endsWith(suffix)) { - workItemDetailsCache.delete(key) - removed = true - } - } - if (removed) { - workItemDetailsCacheGeneration += 1 - notifyWorkItemDetailsCache() - } -} - -function patchCachedPRFileViewedState( - cacheKey: string, - path: string, - viewerViewedState: GitHubPRFileViewedState -): GitHubPRFileViewedState | undefined { - const prev = workItemDetailsCache.get(cacheKey) - const files = prev?.details?.files - if (!prev?.details || !files) { - return undefined - } - let previousState: GitHubPRFileViewedState | undefined - const nextFiles = files.map((file) => { - if (file.path !== path) { - return file - } - previousState = file.viewerViewedState ?? 'UNVIEWED' - return { ...file, viewerViewedState } - }) - if (previousState === undefined || previousState === viewerViewedState) { - return previousState - } - touchWorkItemDetailsCache(cacheKey, { - ...prev, - details: { ...prev.details, files: nextFiles }, - error: undefined - }) - return previousState -} - -function patchCachedPRChecks(cacheKey: string, checks: PRCheckDetail[]): void { - const prev = workItemDetailsCache.get(cacheKey) - if (!prev?.details) { - return - } - touchWorkItemDetailsCache(cacheKey, { - ...prev, - details: { ...prev.details, checks }, - fetchedAt: Date.now(), - error: undefined - }) -} - -function patchCachedPRReviewRequests( - cacheKey: string, - reviewRequests: GitHubAssignableUser[] -): void { - const prev = workItemDetailsCache.get(cacheKey) - if (!prev?.details) { - return - } - touchWorkItemDetailsCache(cacheKey, { - ...prev, - details: { - ...prev.details, - item: { ...prev.details.item, reviewRequests } - }, - fetchedAt: Date.now(), - error: undefined - }) -} - -function patchCachedWorkItemBody(cacheKey: string, body: string): void { - const prev = workItemDetailsCache.get(cacheKey) - if (!prev?.details) { - return - } - touchWorkItemDetailsCache(cacheKey, { - ...prev, - details: { ...prev.details, body }, - fetchedAt: Date.now(), - error: undefined - }) -} - -// Install once at module load (all dialogs share the cache); track the unsubscribe so Vite HMR doesn't accumulate listeners across reloads. -let workItemMutatedUnsub: (() => void) | undefined -let workItemDetailsCacheEventUnsub: (() => void) | undefined -if (typeof window !== 'undefined' && window.api?.gh?.onWorkItemMutated) { - workItemMutatedUnsub = window.api.gh.onWorkItemMutated((payload) => { - invalidateWorkItemDetailsCacheByMatch({ - repoPath: payload.repoPath, - repoId: payload.repoId, - type: payload.type, - number: payload.number - }) - }) - workItemDetailsCacheEventUnsub = onGitHubWorkItemDetailsCacheMutation((payload) => { - invalidateWorkItemDetailsCacheByMatch(payload) - }) -} -if (import.meta !== undefined && import.meta.hot) { - import.meta.hot.dispose(() => { - workItemMutatedUnsub?.() - workItemDetailsCacheEventUnsub?.() - }) -} - -// Why: bounded LRU so a session of opening many PR files can't grow this module map without bound. -const PR_FILE_CONTENT_CACHE_MAX = 64 -type PRFileContentCacheEntry = { - value: Promise | GitHubPRFileContents - byteCount: number -} -const prFileContentCache = new Map() -let prFileContentCacheBytes = 0 - -function touchPRFileContentCache( - key: string, - value: Promise | GitHubPRFileContents -): void { - const retainedByteCount = value instanceof Promise ? 0 : getRetainedPRFileContentsByteCount(value) - if (retainedByteCount === null) { - const existing = prFileContentCache.get(key) - prFileContentCacheBytes -= existing?.byteCount ?? 0 - prFileContentCache.delete(key) - return - } - - const existing = prFileContentCache.get(key) - prFileContentCacheBytes -= existing?.byteCount ?? 0 - // Why: re-insert moves the key to MRU; Map insertion order makes the oldest key first when evicting. - prFileContentCache.delete(key) - const byteCount = retainedByteCount - prFileContentCache.set(key, { value, byteCount }) - prFileContentCacheBytes += byteCount - while ( - prFileContentCache.size > PR_FILE_CONTENT_CACHE_MAX || - prFileContentCacheBytes > PR_FILE_CONTENT_CACHE_MAX_BYTES - ) { - const oldest = prFileContentCache.keys().next().value - if (oldest === undefined) { - break - } - const evicted = prFileContentCache.get(oldest) - prFileContentCacheBytes -= evicted?.byteCount ?? 0 - prFileContentCache.delete(oldest) - } -} - -function getPRFileContentCacheKey(args: { - repoPath: string - repoId: string - sourceContext?: TaskSourceContext | null - prNumber: number - prRepo?: GitHubOwnerRepo | null - file: GitHubPRFile - headSha: string - baseSha: string -}): string { - const repositoryKey = args.repoId ? `repo:${args.repoId}` : `path:${args.repoPath}` - const sourceKey = - args.sourceContext?.provider === 'github' - ? `source:${getTaskSourceCacheScope(args.sourceContext)}` - : 'source:local' - return [ - repositoryKey, - sourceKey, - args.prNumber, - args.prRepo ? githubRepoIdentityKey(args.prRepo) : '', - args.file.path, - args.file.oldPath ?? '', - args.file.status, - args.headSha, - args.baseSha - ].join('\0') -} - -function loadPRFileContents(args: { - repoPath: string - repoId: string - sourceContext?: TaskSourceContext | null - prNumber: number - prRepo?: GitHubOwnerRepo | null - file: GitHubPRFile - headSha: string - baseSha: string -}): Promise { - const cacheKey = getPRFileContentCacheKey(args) - const cached = prFileContentCache.get(cacheKey) - if (cached) { - touchPRFileContentCache(cacheKey, cached.value) - return Promise.resolve(cached.value) - } - let request: Promise - const runtimeHost = getGitHubSourceRuntimeHost(args.sourceContext) - request = ( - runtimeHost - ? callRuntimeRpc( - { kind: 'environment', environmentId: runtimeHost.environmentId }, - 'github.prFileContents', - { - repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId), - prNumber: args.prNumber, - prRepo: args.prRepo ?? null, - path: args.file.path, - oldPath: args.file.oldPath, - status: args.file.status, - headSha: args.headSha, - baseSha: args.baseSha - }, - { timeoutMs: 30_000 } - ) - : window.api.gh.prFileContents({ - repoPath: args.repoPath, - repoId: args.repoId, - sourceContext: args.sourceContext, - prNumber: args.prNumber, - prRepo: args.prRepo ?? null, - path: args.file.path, - oldPath: args.file.oldPath, - status: args.file.status, - headSha: args.headSha, - baseSha: args.baseSha - }) - ) - .then((contents) => { - if (prFileContentCache.get(cacheKey)?.value === request) { - touchPRFileContentCache(cacheKey, contents) - } - return contents - }) - .catch((err) => { - const cachedRequest = prFileContentCache.get(cacheKey) - if (cachedRequest?.value === request) { - prFileContentCacheBytes -= cachedRequest.byteCount - prFileContentCache.delete(cacheKey) - } - throw err - }) - touchPRFileContentCache(cacheKey, request) - return request -} - -type CachedPRFilesDiffViewState = { - entrySignature: string - sections: DiffSection[] - sectionHeights: Record - loadedIndices: number[] - scrollTop: number - sideBySide: boolean - fileTreeCollapsed: boolean - activeTreeSectionKey: string | null -} - -const prFilesDiffViewStateCache = new Map() -const prFilesDiffScrollTopCache = new Map() - -function PRFilesCombinedDiffViewer({ - files, - comments, - repoPath, - repoId, - sourceContext, - prNumber, - prRepo, - prUrl, - headSha, - baseSha, - pendingViewedPaths, - onCommentAdded, - onViewedChange -}: PRFilesCombinedDiffViewerProps): React.JSX.Element { - const settings = useAppStore((s) => s.settings) - const isDark = - settings?.theme === 'dark' || - (settings?.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches) - const entriesCacheRef = useRef<{ - signature: string - entries: GitBranchChangeEntry[] - } | null>(null) - const diffEntrySignature = useMemo( - () => - JSON.stringify( - files.map((file) => ({ - path: file.path, - oldPath: file.oldPath ?? null, - status: file.status, - additions: file.additions, - deletions: file.deletions, - isBinary: file.isBinary - })) - ), - [files] - ) - const entries = useMemo(() => { - if (entriesCacheRef.current?.signature === diffEntrySignature) { - return entriesCacheRef.current.entries - } - const nextEntries = getCombinedDiffBranchEntriesInTreeOrder( - 'commit', - files.map(gitHubPRFileToBranchEntry) - ) - entriesCacheRef.current = { - signature: diffEntrySignature, - entries: nextEntries - } - return nextEntries - }, [diffEntrySignature, files]) - const fileByPath = useMemo(() => new Map(files.map((file) => [file.path, file])), [files]) - const inlineReviewComments = useMemo( - () => - comments.flatMap((comment): DecoratedDiffComment[] => { - // Why: outdated threads keep originalLine for the sidebar, but rendering it inline can attach the comment to unrelated current code. - if (comment.isOutdated || !comment.path || typeof comment.line !== 'number') { - return [] - } - const createdAtMs = new Date(comment.createdAt).getTime() - return [ - { - id: `github-pr-comment:${comment.id}`, - worktreeId: `github-pr:${repoId}:${prNumber}`, - filePath: comment.path, - source: 'diff', - startLine: comment.startLine, - lineNumber: comment.line, - body: comment.body, - createdAt: Number.isFinite(createdAtMs) ? createdAtMs : Date.now(), - side: 'modified', - author: comment.author, - authorAvatarUrl: comment.authorAvatarUrl, - createdAtLabel: formatRelativeTime(comment.createdAt), - url: comment.url, - canDelete: false, - canEdit: false - } - ] - }), - [comments, prNumber, repoId] - ) - const entrySignature = useMemo( - () => - JSON.stringify({ - repoId, - prNumber, - prRepo: prRepo ? githubRepoIdentityKey(prRepo) : null, - headSha: headSha ?? null, - baseSha: baseSha ?? null, - files: diffEntrySignature - }), - [baseSha, diffEntrySignature, headSha, prNumber, prRepo, repoId] - ) - const viewStateKey = useMemo( - () => [repoId || repoPath, prNumber, prRepo ? githubRepoIdentityKey(prRepo) : ''].join('\0'), - [prNumber, prRepo, repoId, repoPath] - ) - const [sections, setSections] = useState([]) - const [sideBySide, setSideBySide] = useState(false) - const [fileTreeCollapsed, setFileTreeCollapsed] = useState(false) - const [sectionHeights, setSectionHeights] = useState>({}) - const [activeTreeSectionKey, setActiveTreeSectionKey] = useState(null) - const scrollContainerRef = useRef(null) - const pendingRestoreScrollTopRef = useRef(null) - const loadedIndicesRef = useRef>(new Set()) - const loadingIndicesRef = useRef>(new Set()) - const sectionsRef = useRef([]) - const generationRef = useRef(0) - const modifiedEditorsRef = useRef>(new Map()) - const handleSectionSaveRef = useRef<(index: number) => Promise>(async () => {}) - sectionsRef.current = sections - - useEffect(() => { - // Why: bump generation so stale async diff loads from the previous view can't patch the restored sections. - generationRef.current += 1 - const cached = prFilesDiffViewStateCache.get(viewStateKey) - if (cached && cached.entrySignature === entrySignature) { - const restoredSections = cached.sections - loadedIndicesRef.current = new Set( - cached.loadedIndices.filter((index) => !restoredSections[index]?.loading) - ) - loadingIndicesRef.current.clear() - setSections(restoredSections) - setSectionHeights(cached.sectionHeights) - setSideBySide(cached.sideBySide) - setFileTreeCollapsed(cached.fileTreeCollapsed) - setActiveTreeSectionKey(cached.activeTreeSectionKey) - pendingRestoreScrollTopRef.current = - prFilesDiffScrollTopCache.get(viewStateKey) ?? cached.scrollTop - return - } - - loadedIndicesRef.current.clear() - loadingIndicesRef.current.clear() - pendingRestoreScrollTopRef.current = prFilesDiffScrollTopCache.get(viewStateKey) ?? null - setSectionHeights({}) - setActiveTreeSectionKey(null) - setSections( - entries.map((entry) => ({ - key: getPRFileSectionKey(entry.path), - path: entry.path, - oldPath: entry.oldPath, - status: entry.status, - added: entry.added, - removed: entry.removed, - originalContent: '', - modifiedContent: '', - collapsed: false, - loading: true, - error: undefined, - dirty: false, - diffResult: null, - largeDiffRenderLimit: null - })) - ) - }, [entries, entrySignature, viewStateKey]) - - const loadSection = useCallback( - (index: number) => { - const section = sectionsRef.current[index] - if (!section || section.collapsed) { - return - } - if (loadedIndicesRef.current.has(index) || loadingIndicesRef.current.has(index)) { - return - } - const file = fileByPath.get(section.path) - if (!file) { - return - } - const generation = generationRef.current - loadingIndicesRef.current.add(index) - - const load = async (): Promise<{ - result: GitDiffResult - resultContents?: GitHubPRFileContents - error?: string - }> => { - if (file.isBinary) { - return { - result: { - kind: 'binary', - originalContent: '', - modifiedContent: '', - originalIsBinary: true, - modifiedIsBinary: true - } - } - } - if (!headSha || !baseSha) { - return { - result: { - kind: 'text', - originalContent: '', - modifiedContent: '', - originalIsBinary: false, - modifiedIsBinary: false - }, - error: translate( - 'auto.components.PullRequestPage.74660bd80b', - 'Diff unavailable because the PR commit SHAs are missing.' - ) - } - } - const contents = await loadPRFileContents({ - repoPath, - repoId, - sourceContext, - prNumber, - prRepo, - file, - headSha, - baseSha - }) - return { result: getPRFileDiffResult(contents), resultContents: contents } - } - - load() - .catch((error) => ({ - result: { - kind: 'text', - originalContent: '', - modifiedContent: '', - originalIsBinary: false, - modifiedIsBinary: false - } as GitDiffResult, - resultContents: undefined, - error: error instanceof Error ? error.message : 'Failed to load diff.' - })) - .then(({ result, resultContents, error }) => { - loadingIndicesRef.current.delete(index) - if (generationRef.current !== generation) { - return - } - const largeDiffRenderLimit = - !error && result.kind === 'text' && resultContents - ? getPRFileContentsRenderLimit(resultContents) - : null - const storedContent = getStoredTextDiffContent(result, largeDiffRenderLimit) - const storedResult = getStoredTextDiffResult(result, largeDiffRenderLimit) - loadedIndicesRef.current.add(index) - setSections((prev) => - prev.map((current, currentIndex) => - currentIndex === index - ? { - ...current, - diffResult: storedResult, - originalContent: storedContent.originalContent, - modifiedContent: storedContent.modifiedContent, - loading: false, - error, - largeDiffRenderLimit - } - : current - ) - ) - }) - }, - [baseSha, fileByPath, headSha, prNumber, prRepo, repoId, repoPath, sourceContext] - ) - - const retrySection = useCallback( - (index: number) => { - loadedIndicesRef.current.delete(index) - loadingIndicesRef.current.delete(index) - setSectionHeights((prev) => removeDiffSectionMeasuredHeight(prev, index)) - setSections((prev) => - prev.map((section, sectionIndex) => - sectionIndex === index - ? { - ...section, - diffResult: null, - originalContent: '', - modifiedContent: '', - loading: true, - error: undefined, - largeDiffRenderLimit: null - } - : section - ) - ) - loadSection(index) - }, - [loadSection] - ) - - const toggleSection = useCallback( - (index: number) => { - const shouldLoadAfterExpand = sectionsRef.current[index]?.collapsed ?? false - setSections((prev) => - prev.map((section, sectionIndex) => - sectionIndex === index ? { ...section, collapsed: !section.collapsed } : section - ) - ) - if (shouldLoadAfterExpand) { - window.requestAnimationFrame(() => loadSection(index)) - } - }, - [loadSection] - ) - - const setAllSectionsCollapsed = useCallback( - (collapsed: boolean) => { - setSections((prev) => prev.map((section) => ({ ...section, collapsed }))) - if (!collapsed) { - window.requestAnimationFrame(() => { - sectionsRef.current.forEach((_, index) => loadSection(index)) - }) - } - }, - [loadSection] - ) - - const allSectionsCollapsed = sections.length > 0 && sections.every((section) => section.collapsed) - const sectionIndexByKey = useMemo(() => createCombinedDiffSectionIndexMap(sections), [sections]) - const viewedSectionKeys = useMemo( - () => new Set(files.filter(isPRFileViewed).map((file) => getPRFileSectionKey(file.path))), - [files] - ) - - const virtualizer = useVirtualizer({ - count: sections.length, - getScrollElement: () => scrollContainerRef.current, - estimateSize: (index) => { - const section = sections[index] - if (!section) { - return 88 - } - return getDiffSectionEstimatedHeight({ - collapsed: section.collapsed, - measuredContentHeight: sectionHeights[index], - originalContent: section.originalContent, - modifiedContent: section.modifiedContent, - changedLineCount: - section.added === undefined && section.removed === undefined - ? undefined - : (section.added ?? 0) + (section.removed ?? 0), - useIntrinsicImageHeight: isIntrinsicHeightImageDiff(section.diffResult), - isLargeDiffLimited: section.largeDiffRenderLimit?.limited === true, - lineCounts: section.largeDiffRenderLimit?.lineCounts ?? undefined - }) - }, - overscan: PR_DIFF_OVERSCAN, - getItemKey: (index) => { - const section = sections[index] - return section - ? `${section.key}:${section.collapsed ? 'collapsed' : 'expanded'}:${entrySignature}` - : `${index}:${entrySignature}` - } - }) - - useLayoutEffect(() => { - virtualizer.measure() - }, [sideBySide, virtualizer]) - - useEffect(() => { - if (sections.length === 0 && entries.length > 0) { - return - } - const preservedScrollTop = - prFilesDiffScrollTopCache.get(viewStateKey) ?? scrollContainerRef.current?.scrollTop ?? 0 - setWithLRU(prFilesDiffViewStateCache, viewStateKey, { - entrySignature, - sections, - sectionHeights, - loadedIndices: Array.from(loadedIndicesRef.current).filter( - (index) => !sections[index]?.loading - ), - scrollTop: preservedScrollTop, - sideBySide, - fileTreeCollapsed, - activeTreeSectionKey - }) - }, [ - activeTreeSectionKey, - entries.length, - entrySignature, - fileTreeCollapsed, - sectionHeights, - sections, - sideBySide, - viewStateKey - ]) - - useLayoutEffect(() => { - const container = scrollContainerRef.current - if (!container) { - return - } - - const updateCachedScrollPosition = (): void => { - const existing = prFilesDiffViewStateCache.get(viewStateKey) - setWithLRU(prFilesDiffScrollTopCache, viewStateKey, container.scrollTop) - if (!existing || existing.entrySignature !== entrySignature) { - return - } - setWithLRU(prFilesDiffViewStateCache, viewStateKey, { - ...existing, - scrollTop: container.scrollTop - }) - } - - container.addEventListener('scroll', updateCachedScrollPosition) - return () => { - updateCachedScrollPosition() - container.removeEventListener('scroll', updateCachedScrollPosition) - } - }, [entrySignature, viewStateKey]) - - useLayoutEffect(() => { - const container = scrollContainerRef.current - const targetScrollTop = pendingRestoreScrollTopRef.current - if (!container || targetScrollTop === null) { - return - } - - let frameId = 0 - let attempts = 0 - const restoreScrollPosition = (): void => { - const liveContainer = scrollContainerRef.current - const liveTarget = pendingRestoreScrollTopRef.current - if (!liveContainer || liveTarget === null) { - return - } - - const maxScrollTop = Math.max(0, liveContainer.scrollHeight - liveContainer.clientHeight) - const nextScrollTop = Math.min(liveTarget, maxScrollTop) - liveContainer.scrollTop = nextScrollTop - setWithLRU(prFilesDiffScrollTopCache, viewStateKey, nextScrollTop) - - if (Math.abs(liveContainer.scrollTop - liveTarget) <= 1 || maxScrollTop >= liveTarget) { - pendingRestoreScrollTopRef.current = null - return - } - - attempts += 1 - if (attempts < 30) { - frameId = window.requestAnimationFrame(restoreScrollPosition) - } - } - - restoreScrollPosition() - return () => window.cancelAnimationFrame(frameId) - }, [sectionHeights, sections, viewStateKey]) - - const handleTreeNavigate = useCallback( - (entry: CombinedDiffFileTreeEntry) => { - const navigatedIndex = handleCombinedDiffFileTreeNavigation({ - mode: 'commit', - entry, - sections: sectionsRef.current, - sectionIndexByKey, - toggleSection, - scrollToIndex: (index) => virtualizer.scrollToIndex(index, { align: 'start' }) - }) - if (navigatedIndex !== null) { - setActiveTreeSectionKey(sectionsRef.current[navigatedIndex]?.key ?? null) - } - }, - [sectionIndexByKey, toggleSection, virtualizer] - ) - - const openFilesOnGitHub = useCallback(() => { - void window.api.shell.openUrl(`${prUrl.replace(/\/$/, '')}/files`) - }, [prUrl]) - - const handleAddLineComment = useCallback( - async ( - section: DiffSection, - { - lineNumber, - startLine, - body - }: { - lineNumber: number - startLine?: number - body: string - } - ) => { - if (!headSha) { - toast.error( - translate( - 'auto.components.PullRequestPage.d8c3ba91c4', - 'Unable to comment without the PR head SHA.' - ) - ) - return false - } - const result = await addPRReviewCommentForRepo({ - repoPath, - repoId, - sourceContext, - prNumber, - prRepo, - commitId: headSha, - path: section.path, - line: lineNumber, - startLine, - body - }) - if (!result.ok) { - toast.error( - result.error || - translate('auto.components.PullRequestPage.19628e058d', 'Failed to add review comment.') - ) - return false - } - onCommentAdded(result.comment) - toast.success( - translate('auto.components.PullRequestPage.eff839f438', 'Review comment added.') - ) - return true - }, - [headSha, onCommentAdded, prNumber, prRepo, repoId, repoPath, sourceContext] - ) - - const renderViewedCheckbox = useCallback( - (section: DiffSection) => { - const file = fileByPath.get(section.path) - if (!file) { - return null - } - const viewed = isPRFileViewed(file) - const pending = pendingViewedPaths.has(file.path) - return ( - { - if (!pending) { - void onViewedChange(file.path, !viewed) - } - }} - /> - ) - }, - [fileByPath, onViewedChange, pendingViewedPaths] - ) - - return ( -
-
-
- {fileTreeCollapsed && ( - - - - - - {translate('auto.components.PullRequestPage.319cf2d54b', 'Show file tree')} - - - )} - - {files.filter(isPRFileViewed).length} / {files.length}{' '} - {translate('auto.components.PullRequestPage.89e80af1c7', 'files viewed')} - -
-
- - -
-
-
- -
-
- {virtualizer.getVirtualItems().map((virtualItem) => { - const section = sections[virtualItem.index] - if (!section) { - return null - } - return ( -
- - fileByPath.get(section.path)?.reviewCommentLineNumbers - } - setSectionHeights={setSectionHeights} - setSections={setSections} - modifiedEditorsRef={modifiedEditorsRef} - handleSectionSaveRef={handleSectionSaveRef} - /> -
- ) - })} -
-
-
-
- ) -} - -function ConversationTab({ - item, - repoPath, - repoId, - sourceContext, - body, - comments, - files, - headSha, - baseSha, - loading, - detailsLoaded, - checks, - participants: detailsParticipants, - localState, - onStateChange, - projectOrigin, - onMutated, - onChecksUpdated, - onBodyUpdated, - onCommentAdded, - onReviewersRequested -}: { - item: GitHubWorkItem - repoPath: string | null - repoId: string | null - sourceContext?: TaskSourceContext | null - body: string - comments: PRComment[] - files: GitHubPRFile[] - headSha: string | undefined - baseSha: string | undefined - loading: boolean - detailsLoaded: boolean - checks: GitHubWorkItemDetails['checks'] - participants: GitHubAssignableUser[] - localState: GitHubWorkItem['state'] - onStateChange: (state: GitHubWorkItem['state']) => void - projectOrigin: PullRequestPageProjectOrigin | undefined - onMutated: () => void - onChecksUpdated: (checks: PRCheckDetail[]) => void - onBodyUpdated: (body: string) => void - onCommentAdded: (comment: PRComment) => void - onReviewersRequested: (reviewRequests: GitHubAssignableUser[]) => void -}): React.JSX.Element { - const authorLabel = item.author ?? 'unknown' - const [replyingTo, setReplyingTo] = useState(null) - const [commentFilter, setCommentFilter] = useState('all') - const [bodyDraft, setBodyDraft] = useState(body) - const [bodyEditing, setBodyEditing] = useState(false) - const [bodySaving, setBodySaving] = useState(false) - const bodyTextareaRef = useRef(null) - const bodyTextareaFocusFrameRef = useRef(null) - const canUseRepoMutationContext = canUseGitHubRepoContext(repoPath, sourceContext) - const repoOwnerSettings = useAppStore( - useShallow((s) => getSettingsForRepoRuntimeOwner(s, item.repoId ?? repoId ?? null)) - ) - const sourceSettings = useMemo( - () => - sourceContext?.provider === 'github' - ? ({ - ...repoOwnerSettings, - ...getTaskSourceRuntimeSettings(sourceContext) - } as typeof repoOwnerSettings) - : repoOwnerSettings, - [repoOwnerSettings, sourceContext] - ) - const repoAssignees = useRepoAssignees(repoPath, item.repoId, sourceSettings) - const botAuthorOverrides = usePRBotAuthorOverrides() - const commentCounts = useMemo( - () => getPRCommentAudienceCounts(comments, botAuthorOverrides), - [botAuthorOverrides, comments] - ) - const visibleComments = useMemo( - () => filterPRCommentsByAudience(comments, commentFilter, botAuthorOverrides), - [botAuthorOverrides, commentFilter, comments] - ) - const visibleCommentGroups = useMemo(() => groupPRComments(visibleComments), [visibleComments]) - const resolvedReplyingTo = resolveCommentReplyTarget(replyingTo, visibleComments) - const mentionOptions = useMemo( - () => - buildMentionOptions({ - item, - comments, - participants: detailsParticipants, - assignableUsers: repoAssignees.data - }), - [comments, detailsParticipants, item, repoAssignees.data] - ) - - const cancelBodyTextareaFocusFrame = useCallback((): void => { - if (bodyTextareaFocusFrameRef.current !== null) { - cancelAnimationFrame(bodyTextareaFocusFrameRef.current) - bodyTextareaFocusFrameRef.current = null - } - }, []) - - if (resolvedReplyingTo !== replyingTo) { - // Why: clear before paint when filters/refetches hide the reply target, so a stale composer doesn't flash for the wrong comment set. - setReplyingTo(resolvedReplyingTo) - } - - const resolvedBodyDraft = resolveGitHubBodyDraft(bodyDraft, body, bodyEditing) - if (shouldSyncGitHubBodyDraft(bodyDraft, body, bodyEditing)) { - // Why: reconcile before paint so a background body refresh while the editor is closed doesn't show a stale draft on reopen. - setBodyDraft(resolvedBodyDraft) - } - - useEffect(() => { - if (!bodyEditing) { - cancelBodyTextareaFocusFrame() - return cancelBodyTextareaFocusFrame - } - cancelBodyTextareaFocusFrame() - bodyTextareaFocusFrameRef.current = requestAnimationFrame(() => { - bodyTextareaFocusFrameRef.current = null - bodyTextareaRef.current?.focus() - }) - return cancelBodyTextareaFocusFrame - }, [bodyEditing, cancelBodyTextareaFocusFrame]) - - const bodySlug = useMemo(() => parseOwnerRepoFromItemUrl(item.url), [item.url]) - const prRepo = useMemo(() => resolvePullRequestRepo(item, projectOrigin), [item, projectOrigin]) - const markdownGitHubRepo = useMemo( - () => - projectOrigin - ? { owner: projectOrigin.owner, repo: projectOrigin.repo, host: projectOrigin.host } - : bodySlug, - [bodySlug, projectOrigin] - ) - const canEditBody = - item.type === 'pr' - ? Boolean(projectOrigin || bodySlug) - : Boolean(projectOrigin || canUseRepoMutationContext) - const bodyChanged = resolvedBodyDraft !== body - - const handleSaveBody = useCallback(async (): Promise => { - if (bodySaving || !bodyChanged) { - setBodyEditing(false) - return - } - setBodySaving(true) - try { - await runWorkItemBodyUpdate({ - item, - repoPath, - sourceContext, - projectOrigin, - body: resolvedBodyDraft, - parsedSlug: bodySlug - }) - onBodyUpdated(resolvedBodyDraft) - setBodyEditing(false) - toast.success(translate('auto.components.PullRequestPage.9b4190dc98', 'Description updated.')) - } catch (err) { - toast.error( - err instanceof Error - ? err.message - : translate('auto.components.PullRequestPage.d94810f652', 'Failed to update description.') - ) - } finally { - setBodySaving(false) - } - }, [ - bodyChanged, - resolvedBodyDraft, - bodySaving, - bodySlug, - item, - onBodyUpdated, - projectOrigin, - repoPath, - sourceContext - ]) - - const handleReply = useCallback( - async (comment: PRComment, replyBody: string): Promise => { - if (!canUseRepoMutationContext) { - toast.error( - translate( - 'auto.components.PullRequestPage.6885c619e7', - 'Unable to reply without a repository path.' - ) - ) - return false - } - // Why: nest under review threads (path/threadId/discussion_r); never post a - // separate top-level conversation comment for those. - const isReviewThreadReply = item.type === 'pr' && canPostPRReviewThreadReply(comment) - const result = isReviewThreadReply - ? await addPRReviewCommentReplyForRepo({ - repoPath: repoPath ?? '', - repoId: item.repoId, - sourceContext, - prNumber: item.number, - prRepo, - commentId: comment.id, - body: replyBody, - threadId: comment.threadId, - path: comment.path, - line: comment.line - }) - : await addIssueCommentForRepo({ - repoPath: repoPath ?? '', - repoId: item.repoId, - sourceContext, - number: item.number, - // Why: a GitHub App login carries a [bot] suffix that never resolves as a mention. - body: buildPRCommentConversationReplyBody(comment.author, replyBody), - type: item.type, - prRepo - }) - - if (!result.ok) { - toast.error( - result.error || - translate('auto.components.PullRequestPage.5821aab360', 'Failed to post reply.') - ) - return false - } - onCommentAdded( - isReviewThreadReply ? attachPRReviewReplyParent(result.comment, comment) : result.comment - ) - setReplyingTo(null) - toast.success(translate('auto.components.PullRequestPage.11505c7a71', 'Reply posted.')) - return true - }, - [ - canUseRepoMutationContext, - item.number, - item.repoId, - item.type, - onCommentAdded, - prRepo, - repoPath, - sourceContext - ] - ) - - const rightPanel = - item.type === 'pr' ? ( -
- - - - -
- ) : null - - const renderCommentCard = (comment: PRComment, isReply = false): React.JSX.Element => ( -
-
- {comment.authorAvatarUrl ? ( - {comment.author} - ) : ( -
- )} - - {comment.author} - - - · {formatRelativeTime(comment.createdAt)} - - {comment.path && ( - - {comment.path.split('/').pop()} - {comment.line - ? translate('auto.components.PullRequestPage.34b9f7c264', ':L{{value0}}', { - value0: comment.line - }) - : ''} - - )} - {comment.isResolved && ( - - {translate('auto.components.PullRequestPage.76b2a0ac5b', 'resolved')} - - )} -
- - - - - - {translate('auto.components.PullRequestPage.d6c6679de7', 'Reply to comment')} - - - {comment.url && ( - - - - - - {translate('auto.components.PullRequestPage.0ac19bb52e', 'Open comment on GitHub')} - - - )} -
-
-
- - - - {resolvedReplyingTo === comment.id && ( - setReplyingTo(null)} - onSubmit={(replyBody) => handleReply(comment, replyBody)} - /> - )} -
-
- ) - - const renderCommentGroup = (group: PRCommentGroup): React.JSX.Element => { - const cards = - group.kind === 'thread' - ? [ - renderCommentCard(group.root), - ...group.replies.map((reply) => renderCommentCard(reply, true)) - ] - : [renderCommentCard(group.comment)] - - if (!isResolvedPRCommentGroup(group)) { - return ( -
- {cards} -
- ) - } - - const root = getPRCommentGroupRoot(group) - const count = getPRCommentGroupCount(group) - return ( - - - - - {translate('auto.components.PullRequestPage.f4fe47c2bb', 'Resolved')}{' '} - {group.kind === 'thread' - ? translate('auto.components.PullRequestPage.345b68254c', 'thread') - : translate('auto.components.PullRequestPage.e01e34f5fa', 'comment')}{' '} - {translate('auto.components.PullRequestPage.3c891789f6', 'by')} {root.author} - {count > 1 ? ` (${count})` : ''} - - - - {cards} - - - - ) - } - - return ( -
-
-
-
- {authorLabel} - - {translate('auto.components.PullRequestPage.169a93b29a', 'updated')}{' '} - {formatRelativeTime(item.updatedAt)} - - {canEditBody && !loading && detailsLoaded ? ( - bodyEditing ? ( -
- - -
- ) : ( - - - - - - {translate('auto.components.PullRequestPage.da9aaa8bcf', 'Edit description')} - - - ) - ) : null} -
-
- {loading && !detailsLoaded ? ( -
- -
- ) : bodyEditing ? ( - { - if (event.key === 'Escape') { - event.preventDefault() - setBodyDraft(body) - setBodyEditing(false) - return - } - if (isScreenSubmitShortcut(event)) { - event.preventDefault() - void handleSaveBody() - } - }} - placeholder={translate('auto.components.PullRequestPage.778683ec84', 'Description')} - rows={12} - mentionOptions={mentionOptions} - wrapperClassName="flex min-h-64 w-full items-stretch" - className="scrollbar-sleek block min-h-64 w-full resize-y rounded-md border border-input bg-background px-3 py-2 font-mono text-[13px] leading-5 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" - /> - ) : body.trim() ? ( - - ) : ( - - {translate( - 'auto.components.PullRequestPage.c8ea6c7c4c', - 'No description provided.' - )} - - )} -
-
- - {detailsLoaded ? ( - <> -
- - - {translate('auto.components.PullRequestPage.3463d10a63', 'Comments')} - - {comments.length > 0 && ( - - {comments.length} - - )} -
- - {item.type === 'pr' && comments.length > 0 && ( -
- {getPrCommentAudienceFilters().map((filter) => { - const isActive = commentFilter === filter.value - return ( - - ) - })} -
- )} - - {comments.length === 0 ? ( -
- {translate('auto.components.PullRequestPage.d2d589556c', 'No comments yet.')} -
- ) : visibleComments.length === 0 ? ( -
- {getPRCommentAudienceEmptyLabel(commentFilter)} -
- ) : ( -
- {visibleCommentGroups.map(renderCommentGroup)} -
- )} - - ) : null} - - {detailsLoaded && canUseRepoMutationContext && ( - - )} -
- - {rightPanel} -
- ) -} - -function PRActionsPanel({ - item, - repoPath, - repoId, - sourceContext, - projectOrigin, - localState, - onStateChange, - onMutated -}: { - item: GitHubWorkItem - repoPath: string | null - repoId: string | null - sourceContext?: TaskSourceContext | null - projectOrigin: PullRequestPageProjectOrigin | undefined - localState: GitHubWorkItem['state'] - onStateChange: (state: GitHubWorkItem['state']) => void - onMutated: () => void -}): React.JSX.Element { - const [statePending, setStatePending] = useState(false) - const [mergePending, setMergePending] = useState(false) - const patchWorkItem = useAppStore((s) => s.patchWorkItem) - const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent) - const confirm = useConfirmationDialog() - const actionItem = { ...item, state: localState } - const mergePresentation = presentGitHubPRMergeState(actionItem) - const mergeMethods = resolveGitHubPRMergeMethods(actionItem.mergeMethodSettings) - const sourceSettings = useAppStore( - useShallow((s) => - getGitHubMutationRoutingSettings(s, item.repoId ?? repoId ?? null, sourceContext) - ) - ) - const mergeTarget = getActiveRuntimeTarget(sourceSettings) - const prRepo = resolvePullRequestRepo(item, projectOrigin) - const canMutateWithRepoContext = - !!repoPath || !!projectOrigin || mergeTarget.kind === 'environment' - const canMutateState = localState !== 'merged' && canMutateWithRepoContext - const nextState: 'open' | 'closed' = localState === 'closed' ? 'open' : 'closed' - const canMergeWithRepoContext = !!repoPath || mergeTarget.kind === 'environment' - const mergeDisabled = - !canMergeWithRepoContext || mergePending || !mergePresentation.directMergeAvailable - - const patchProjectRowIfNeeded = useCallback( - (state: GitHubWorkItem['state']) => { - if (!projectOrigin) { - return - } - patchProjectRowContent(projectOrigin.cacheKey, projectOrigin.projectItemId, { state }) - }, - [patchProjectRowContent, projectOrigin] - ) - - const applyStatePatch = useCallback( - (state: GitHubWorkItem['state']) => { - onStateChange(state) - patchWorkItem(item.id, { state }, item.repoId, { sourceContext }) - patchProjectRowIfNeeded(state) - }, - [item.id, item.repoId, onStateChange, patchProjectRowIfNeeded, patchWorkItem, sourceContext] - ) - - const handleStateChange = async (): Promise => { - if (!canMutateState || statePending) { - return - } - const label = nextState === 'closed' ? 'Close' : 'Reopen' - const confirmed = await confirm({ - title: translate('auto.components.PullRequestPage.eec3706a6a', '{{value0}} PR #{{value1}}?', { - value0: label, - value1: item.number - }), - description: - nextState === 'closed' - ? translate( - 'auto.components.PullRequestPage.5a65651096', - 'This will close the pull request on GitHub.' - ) - : translate( - 'auto.components.PullRequestPage.3d77438c92', - 'This will reopen the pull request on GitHub.' - ), - confirmLabel: label, - confirmVariant: nextState === 'closed' ? 'destructive' : 'default' - }) - if (!confirmed) { - return - } - const previousState = localState - setStatePending(true) - applyStatePatch(nextState) - try { - await runPullRequestStateUpdate({ - repoPath, - repoId, - sourceContext, - projectOrigin, - number: item.number, - prRepo, - updates: { state: nextState } - }) - toast.success( - nextState === 'closed' - ? translate('auto.components.PullRequestPage.7aa3b5f706', 'Pull request closed') - : translate('auto.components.PullRequestPage.710e47aa06', 'Pull request reopened') - ) - onMutated() - } catch (err) { - applyStatePatch(previousState) - toast.error( - err instanceof Error - ? err.message - : translate('auto.components.PullRequestPage.b8c6cbb8c4', 'Failed to {{value0}} PR', { - value0: label.toLowerCase() - }) - ) - } finally { - setStatePending(false) - } - } - - const handleMerge = async (method: GitHubPRMergeMethod): Promise => { - if (mergeDisabled) { - return - } - const label = GITHUB_PR_MERGE_METHOD_LABELS[method] - const confirmed = await confirm({ - title: translate('auto.components.PullRequestPage.eec3706a6a', '{{value0}} PR #{{value1}}?', { - value0: label, - value1: item.number - }), - description: translate( - 'auto.components.PullRequestPage.a63b3c159c', - 'This will update the pull request on GitHub.' - ), - confirmLabel: label - }) - if (!confirmed) { - return - } - setMergePending(true) - try { - const result = - mergeTarget.kind === 'environment' - ? await callRuntimeRpc>>( - mergeTarget, - 'github.mergePR', - { - repo: getGitHubRuntimeRepoId(sourceContext, repoId ?? item.repoId), - prNumber: item.number, - method, - prRepo - }, - { timeoutMs: 30_000 } - ) - : await window.api.gh.mergePR({ - repoPath: repoPath ?? '', - repoId: repoId ?? undefined, - sourceContext, - prNumber: item.number, - method, - prRepo - }) - if (!result.ok) { - toast.error(result.error) - return - } - applyStatePatch('merged') - if (mergeTarget.kind === 'environment') { - notifyWorkItemDetailsMutation( - { - repoPath: repoPath ?? '', - repoId: item.repoId, - sourceContext, - type: 'pr', - number: item.number - }, - { local: false } - ) - } - toast.success(translate('auto.components.PullRequestPage.c57873d721', 'Pull request merged')) - onMutated() - } catch { - toast.error( - translate('auto.components.PullRequestPage.aae645d36d', 'Failed to merge pull request') - ) - } finally { - setMergePending(false) - } - } - - const handleAutoMerge = async (): Promise => { - if (!canMergeWithRepoContext || !mergePresentation.autoMergeAction) { - return - } - const enabled = mergePresentation.autoMergeAction.kind === 'enable' - setMergePending(true) - try { - const result = - mergeTarget.kind === 'environment' - ? await callRuntimeRpc>>( - mergeTarget, - 'github.setPRAutoMerge', - { - repo: getGitHubRuntimeRepoId(sourceContext, repoId ?? item.repoId), - prNumber: item.number, - enabled, - method: enabled ? mergeMethods.defaultMethod : undefined, - prRepo - }, - { timeoutMs: 30_000 } - ) - : await window.api.gh.setPRAutoMerge({ - repoPath: repoPath ?? '', - repoId: repoId ?? undefined, - sourceContext, - prNumber: item.number, - enabled, - method: enabled ? mergeMethods.defaultMethod : undefined, - prRepo - }) - if (!result.ok) { - toast.error(result.error) - return - } - if (mergeTarget.kind === 'environment') { - notifyWorkItemDetailsMutation( - { - repoPath: repoPath ?? '', - repoId: item.repoId, - sourceContext, - type: 'pr', - number: item.number - }, - { local: false } - ) - } - toast.success( - enabled - ? translate('auto.components.PullRequestPage.5edbe7eefa', 'Auto-merge enabled') - : translate('auto.components.PullRequestPage.0f5821b035', 'Auto-merge disabled') - ) - onMutated() - } catch { - toast.error( - enabled - ? translate('auto.components.PullRequestPage.d31f4b508c', 'Failed to enable auto-merge') - : translate('auto.components.PullRequestPage.973ef2fac9', 'Failed to disable auto-merge') - ) - } finally { - setMergePending(false) - } - } - - return ( - - ) -} - -function CommentReplyForm({ - className, - placeholder, - mentionOptions, - onCancel, - onSubmit -}: { - className?: string - placeholder: string - mentionOptions: MentionOption[] - onCancel: () => void - onSubmit: (body: string) => Promise -}): React.JSX.Element { - const [body, setBody] = useState('') - const [submitting, setSubmitting] = useState(false) - const textareaRef = useRef(null) - const mountedRef = useMountedRef() - - useEffect(() => { - textareaRef.current?.focus() - }, []) - - const submit = useCallback(async () => { - const bodyState = getCommentBodySubmitState(body) - if (bodyState.status === 'empty' || submitting) { - return - } - if (bodyState.status === 'too-large-leading-whitespace') { - toast.error( - translate( - 'auto.components.PullRequestPage.commentTooLarge', - 'Comment is too large to submit safely.' - ) - ) - return - } - setSubmitting(true) - try { - const ok = await onSubmit(bodyState.body) - if (!mountedRef.current) { - return - } - if (ok) { - setBody('') - } - } finally { - if (mountedRef.current) { - setSubmitting(false) - } - } - }, [body, mountedRef, onSubmit, submitting]) - const canSubmitReply = hasBoundedCommentBodyText(body) - - return ( -
- { - if (e.key === 'Escape') { - e.preventDefault() - onCancel() - return - } - if (isScreenSubmitShortcut(e)) { - e.preventDefault() - void submit() - } - }} - placeholder={placeholder} - rows={3} - mentionOptions={mentionOptions} - className="scrollbar-sleek min-h-20 w-full resize-y rounded-md border border-input bg-transparent px-3 py-2 text-[13px] placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" - /> -
- - -
-
- ) -} - -function ChecksTab({ - item, - repoPath, - repoId, - sourceContext, - headSha, - checks, - loading, - variant = 'compact', - onChecksUpdated -}: { - item: GitHubWorkItem - repoPath: string | null - repoId: string | null - sourceContext?: TaskSourceContext | null - headSha: string | undefined - checks: GitHubWorkItemDetails['checks'] - loading: boolean - variant?: 'compact' | 'page' - onChecksUpdated: (checks: PRCheckDetail[]) => void -}): React.JSX.Element { - const targetRepoId = repoId ?? item.repoId - const settings = useAppStore((s) => s.settings) - const updateSettings = useAppStore((s) => s.updateSettings) - const updateRepo = useAppStore((s) => s.updateRepo) - const repo = useAppStore((s) => - targetRepoId ? (s.repos.find((candidate) => candidate.id === targetRepoId) ?? null) : null - ) - const [fixingChecks, setFixingChecks] = useState(false) - const [fixChecksComposerPrompt, setFixChecksComposerPrompt] = useState(null) - const mountedRef = useMountedRef() - const prRepo = useMemo(() => resolvePullRequestRepo(item), [item]) - const nextCheckDetailsRequestIdRef = useRef(0) - const checkDetailsContextKey = [ - sourceContext ? getTaskSourceCacheScope(sourceContext) : 'local', - repoId ?? item.repoId ?? '', - repoPath ?? '', - prRepo ? githubRepoIdentityKey(prRepo) : '', - item.id, - item.number, - headSha ?? '' - ].join('\0') - const [checksState, setChecksState] = useState(() => - createGitHubChecksTabState(checks, checkDetailsContextKey) - ) - const resolvedChecksState = resolveGitHubChecksTabState( - checksState, - checks, - checkDetailsContextKey - ) - const committedChecksContextOwnerRef = useRef(resolvedChecksState.contextOwner) - const nextChecksRefreshRequestIdRef = useRef(0) - const activeChecksRefreshRequestIdRef = useRef(null) - const [refreshingOwner, setRefreshingOwner] = useState<{ - contextOwner: object - requestId: number - } | null>(null) - const refreshing = refreshingOwner?.contextOwner === resolvedChecksState.contextOwner - const [rerunningOwner, setRerunningOwner] = useState(null) - const rerunning = rerunningOwner === resolvedChecksState.contextOwner - useLayoutEffect(() => { - committedChecksContextOwnerRef.current = resolvedChecksState.contextOwner - }, [resolvedChecksState.contextOwner]) - if (resolvedChecksState !== checksState) { - // Why: reconcile before paint when a parent check refresh replaces the source list, so stale rows/details never show. - setChecksState(resolvedChecksState) - } - const { localChecks, expandedCheckKey, detailsByCheckKey } = resolvedChecksState - const list = useMemo(() => localChecks ?? checks ?? [], [checks, localChecks]) - const fixChecksRecipe = useMemo( - () => - resolveSourceControlActionRecipe({ - settings, - repo, - actionId: 'fixChecks' - }), - [repo, settings] - ) - const fixChecksLaunchPlatform = useMemo( - () => - resolveSourceControlLaunchPlatform({ - connectionId: repo?.connectionId ?? null, - worktreePath: repo?.path ?? null, - projectRuntime: repo?.connectionId - ? undefined - : getLocalRepoProjectExecutionRuntimeContext( - useAppStore.getState(), - repo?.id, - CLIENT_PLATFORM - ) - }), - [repo?.connectionId, repo?.id, repo?.path] - ) - const saveFixChecksActionDefault = useCallback( - async ( - target: SourceControlAiWriteTarget, - actionId: SourceControlLaunchActionId, - recipe: SourceControlActionRecipe - ): Promise => { - const state = useAppStore.getState() - const latestSettings = state.settings - if (!latestSettings) { - throw new Error('Settings are not loaded.') - } - const latestRepo = - target.type === 'repo' - ? (state.repos.find((candidate) => candidate.id === target.repoId) ?? null) - : null - const result = saveSourceControlActionRecipe({ - target, - settings: latestSettings, - repo: latestRepo, - actionId, - recipe - }) - if ('sourceControlAi' in result) { - await updateSettings({ sourceControlAi: result.sourceControlAi }) - return - } - await updateRepo(result.target.repoId, result.update) - }, - [updateRepo, updateSettings] - ) - const handleStartFixChecksFromDialog = useCallback( - async ({ - agent, - commandInput, - agentArgs - }: { - agent: Parameters[0]['agentOverride'] - commandInput: string - agentArgs: string - }): Promise => { - if (!targetRepoId) { - return false - } - return await launchWorkItemDirect({ - item: { ...item, repoId: targetRepoId, pasteContent: commandInput }, - repoId: targetRepoId, - launchSource: 'task_page', - telemetrySource: 'sidebar', - promptDelivery: 'submit-after-ready', - agentOverride: agent, - agentArgs, - openModalFallback: () => { - toast.error( - translate( - 'auto.components.PullRequestPage.c4c02ea23e', - 'Unable to create a fix workspace automatically.' - ) - ) - } - }) - }, - [item, targetRepoId] - ) - const runtimeHost = getGitHubSourceRuntimeHost(sourceContext) - const canUseChecksRepoContext = canUseGitHubRepoContext(repoPath, sourceContext) - const sorted = sortChecksBySeverity(list) - const failedChecks = getBrokenChecks(list) - const counts = getCheckCounts(list) - const summaryLabel = getChecksSummaryLabel(list) - // Why: keying the green tick off `list.length` painted an all-neutral PR green above the words - // "0 of N checks passing"; nothing passed, so it reads unresolved like the checks pill does. - const SummaryIcon = - counts.failing > 0 - ? CHECK_ICON.failure - : counts.needsAction > 0 - ? CHECK_ICON.action_required - : counts.pending > 0 - ? CHECK_ICON.pending - : counts.passing > 0 - ? CHECK_ICON.success - : CircleDashed - const summaryColor = - counts.failing > 0 - ? CHECK_COLOR.failure - : counts.needsAction > 0 - ? CHECK_COLOR.action_required - : counts.pending > 0 - ? CHECK_COLOR.pending - : counts.passing > 0 - ? CHECK_COLOR.success - : 'text-muted-foreground' - const canFixBrokenChecks = Boolean((repoId ?? item.repoId) && failedChecks.length > 0) - - const handleRefresh = useCallback( - async (expectedContextOwner?: object): Promise => { - if (!canUseChecksRepoContext) { - toast.error( - translate( - 'auto.components.PullRequestPage.c057f2fcb0', - 'Unable to refresh checks without a repository path.' - ) - ) - return null - } - const refreshContextOwner = expectedContextOwner ?? committedChecksContextOwnerRef.current - if (committedChecksContextOwnerRef.current !== refreshContextOwner) { - return null - } - const refreshRequestId = ++nextChecksRefreshRequestIdRef.current - activeChecksRefreshRequestIdRef.current = refreshRequestId - setRefreshingOwner({ contextOwner: refreshContextOwner, requestId: refreshRequestId }) - try { - const nextChecks = (await (runtimeHost - ? callRuntimeRpc( - { kind: 'environment', environmentId: runtimeHost.environmentId }, - 'github.prChecks', - { - repo: getGitHubRuntimeRepoId(sourceContext, repoId ?? item.repoId), - prNumber: item.number, - headSha, - prRepo, - noCache: true - }, - { timeoutMs: 30_000 } - ) - : window.api.gh.prChecks({ - repoPath: repoPath ?? '', - repoId: repoId ?? undefined, - sourceContext, - prNumber: item.number, - headSha, - prRepo, - noCache: true - }))) as PRCheckDetail[] - if ( - !mountedRef.current || - committedChecksContextOwnerRef.current !== refreshContextOwner || - activeChecksRefreshRequestIdRef.current !== refreshRequestId - ) { - return null - } - setChecksState((current) => - current.contextOwner === refreshContextOwner - ? updateGitHubChecksTabLocalChecks(resetGitHubChecksTabForSource(current), nextChecks) - : current - ) - onChecksUpdated(nextChecks) - return nextChecks - } catch (err) { - if ( - mountedRef.current && - committedChecksContextOwnerRef.current === refreshContextOwner && - activeChecksRefreshRequestIdRef.current === refreshRequestId - ) { - toast.error( - err instanceof Error - ? err.message - : translate('auto.components.PullRequestPage.246b2c6456', 'Failed to refresh checks') - ) - } - return null - } finally { - if (activeChecksRefreshRequestIdRef.current === refreshRequestId) { - activeChecksRefreshRequestIdRef.current = null - } - if (mountedRef.current) { - setRefreshingOwner((current) => - current?.requestId === refreshRequestId ? null : current - ) - } - } - }, - [ - canUseChecksRepoContext, - headSha, - item.number, - item.repoId, - mountedRef, - onChecksUpdated, - runtimeHost, - prRepo, - repoId, - repoPath, - sourceContext - ] - ) - - const handleRerun = useCallback( - async (failedOnly: boolean): Promise => { - if (!canUseChecksRepoContext || rerunning) { - return - } - const rerunContextOwner = committedChecksContextOwnerRef.current - setRerunningOwner(rerunContextOwner) - try { - const result = runtimeHost - ? await callRuntimeRpc>>( - { kind: 'environment', environmentId: runtimeHost.environmentId }, - 'github.rerunPRChecks', - { - repo: getGitHubRuntimeRepoId(sourceContext, repoId ?? item.repoId), - prNumber: item.number, - headSha, - failedOnly, - prRepo - }, - { timeoutMs: 30_000 } - ) - : await window.api.gh.rerunPRChecks({ - repoPath: repoPath ?? '', - repoId: repoId ?? undefined, - sourceContext, - prNumber: item.number, - headSha, - failedOnly, - prRepo - }) - if (!mountedRef.current || committedChecksContextOwnerRef.current !== rerunContextOwner) { - return - } - if (!result.ok) { - toast.error(result.error) - return - } - toast.success( - result.count === 1 - ? translate('auto.components.PullRequestPage.5963a6a852', 'Check rerun requested') - : translate('auto.components.PullRequestPage.18f2af42ac', 'Check reruns requested') - ) - await handleRefresh(rerunContextOwner) - } catch (err) { - if (mountedRef.current && committedChecksContextOwnerRef.current === rerunContextOwner) { - toast.error( - err instanceof Error - ? err.message - : translate('auto.components.PullRequestPage.788a782bb0', 'Failed to rerun checks') - ) - } - } finally { - if (mountedRef.current) { - setRerunningOwner((current) => (current === rerunContextOwner ? null : current)) - } - } - }, - [ - canUseChecksRepoContext, - handleRefresh, - headSha, - item.number, - item.repoId, - mountedRef, - prRepo, - runtimeHost, - rerunning, - repoId, - repoPath, - sourceContext - ] - ) - - const handleFixBrokenChecks = useCallback(async (): Promise => { - if (!targetRepoId || fixingChecks) { - return - } - if (failedChecks.length === 0) { - toast.message( - translate('auto.components.PullRequestPage.51c65c0265', 'No broken checks to fix.') - ) - return - } - - const basePrompt = buildFixBrokenChecksPrompt({ - reviewKind: 'PR', - reviewNumber: item.number, - reviewTitle: item.title, - reviewUrl: item.url, - checks: list - }) - setFixingChecks(true) - try { - const started = await startFixChecksAgent({ - item, - repoId: targetRepoId, - basePrompt, - launchSource: 'task_page', - telemetrySource: 'sidebar', - openModalFallback: () => { - setFixChecksComposerPrompt(basePrompt) - } - }) - if (started) { - toast.success( - translate( - 'auto.components.PullRequestPage.85e62c5266', - 'Started an AI agent for the broken checks.' - ) - ) - } - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - console.error('Failed to start fix checks agent', err) - toast.error( - translate( - 'auto.components.PullRequestPage.98583589c6', - 'Failed to start an AI agent for the broken checks: {{value0}}', - { value0: message } - ) - ) - } finally { - setFixingChecks(false) - } - }, [failedChecks.length, fixingChecks, item, list, targetRepoId]) - - const requestCheckDetails = useCallback( - (check: PRCheckDetail, key: string): void => { - if (!canUseChecksRepoContext || (!check.checkRunId && !check.workflowRunId && !check.url)) { - return - } - const requestId = ++nextCheckDetailsRequestIdRef.current - const commit = (next: Omit): void => { - if (!mountedRef.current) { - return - } - setChecksState((current) => settleGitHubChecksTabDetails(current, key, requestId, next)) - } - setChecksState((current) => beginGitHubChecksTabDetails(current, key, requestId)) - const detailsRequest = withGitHubCheckDetailsTimeout((signal) => - runtimeHost - ? callRuntimeRpc>>( - { kind: 'environment', environmentId: runtimeHost.environmentId }, - 'github.prCheckDetails', - { - repo: getGitHubRuntimeRepoId(sourceContext, repoId ?? item.repoId), - checkRunId: check.checkRunId, - workflowRunId: check.workflowRunId, - checkName: check.name, - url: check.url, - prRepo - }, - { timeoutMs: 30_000, signal } - ) - : window.api.gh.prCheckDetails({ - repoPath: repoPath ?? '', - repoId: repoId ?? undefined, - sourceContext, - checkRunId: check.checkRunId, - workflowRunId: check.workflowRunId, - checkName: check.name, - url: check.url, - prRepo - }) - ) - void detailsRequest - .then((details) => { - commit({ - loading: false, - details, - error: details - ? null - : translate( - 'auto.components.PullRequestPage.6b1d5ee3e4', - 'No inline details are available for this check.' - ) - }) - }) - .catch((err) => { - commit({ - loading: false, - details: null, - error: - err instanceof Error - ? err.message - : translate( - 'auto.components.PullRequestPage.e04c027d98', - 'Failed to load check details.' - ) - }) - }) - }, - [ - canUseChecksRepoContext, - item.repoId, - mountedRef, - runtimeHost, - prRepo, - repoId, - repoPath, - sourceContext - ] - ) - - const handleToggleCheckDetails = useCallback( - (check: PRCheckDetail): void => { - const key = getCheckDetailsKey(check) - setChecksState((current) => toggleGitHubChecksTabExpandedKey(current, key)) - if (detailsByCheckKey[key]) { - return - } - requestCheckDetails(check, key) - }, - [detailsByCheckKey, requestCheckDetails] - ) - - const refreshAction = ( - - - - - - {translate('auto.components.PullRequestPage.5d0f42766d', 'Refresh checks')} - - - ) - const fixBrokenChecksAction = - failedChecks.length > 0 || fixingChecks ? ( - - - - - - {translate( - 'auto.components.PullRequestPage.0fa8b8faec', - 'Start the default AI agent on these checks' - )} - - - ) : null - const rerunAction = - list.length > 0 || rerunning ? ( - - - - - - void handleRerun(true)} - > - - {translate('auto.components.PullRequestPage.68605516dd', 'Rerun failed checks')} - - void handleRerun(false)}> - - {translate('auto.components.PullRequestPage.54cddd1858', 'Rerun all checks')} - - - - ) : null - const secondaryActions = - variant === 'compact' && !fixBrokenChecksAction ? null : fixBrokenChecksAction || - rerunAction ? ( -
- {fixBrokenChecksAction} - {variant === 'page' ? rerunAction : null} -
- ) : null - const actions = ( -
- {refreshAction} - {fixBrokenChecksAction} - {rerunAction} -
- ) - const compactHeader = ( -
-
-
- 0 && counts.failing === 0 && 'animate-spin' - )} - /> -
-
- {translate('auto.components.PullRequestPage.94d95cf1f7', 'Checks')} -
- {list.length > 0 && ( -
- {summaryLabel} -
- )} -
-
-
- {refreshAction} - {list.length > 0 && ( -
- {rerunAction} -
- )} -
-
- {secondaryActions ? ( -
{secondaryActions}
- ) : null} -
- ) - - const renderCheckRow = (check: PRCheckDetail): React.JSX.Element => { - const conclusion = getCheckConclusion(check) - const Icon = CHECK_ICON[conclusion] ?? CircleDashed - const color = CHECK_COLOR[conclusion] ?? 'text-muted-foreground' - const statusLabel = getCheckStatusLabel(check) - const key = getCheckDetailsKey(check) - const expanded = expandedCheckKey === key - const detailsState = detailsByCheckKey[key] - return ( -
- - {expanded && renderCheckDetails(check, detailsState)} -
- ) - } - - const renderCheckDetails = ( - check: PRCheckDetail, - state: CheckDetailsLoadState | undefined - ): React.JSX.Element => { - const details = state?.details - const openUrl = details?.detailsUrl ?? details?.url ?? check.url - const startedAt = formatCheckTimestamp(details?.startedAt) - const completedAt = formatCheckTimestamp(details?.completedAt) - const detailsStatusCheck: PRCheckDetail = { - ...check, - status: (details?.status as PRCheckDetail['status'] | undefined) ?? check.status, - conclusion: - (details?.conclusion as PRCheckDetail['conclusion'] | undefined) ?? check.conclusion - } - const hasOutput = Boolean(details?.title || details?.summary || details?.text) - const hasAnnotations = (details?.annotations.length ?? 0) > 0 - const hasJobs = (details?.jobs.length ?? 0) > 0 - - return ( -
- {state?.loading && !state.error ? ( -
- - {translate('auto.components.PullRequestPage.d8e82b7f15', 'Loading check details…')} -
- ) : ( -
-
- - {translate('auto.components.PullRequestPage.662bc2998d', 'Status:')}{' '} - {details ? getCheckStatusLabel(detailsStatusCheck) : getCheckStatusLabel(check)} - - {startedAt && ( - - {translate('auto.components.PullRequestPage.76551b1161', 'Started')} {startedAt} - - )} - {completedAt && ( - - {translate('auto.components.PullRequestPage.000f90afcf', 'Completed')}{' '} - {completedAt} - - )} - {check.checkRunId && ( - - {translate('auto.components.PullRequestPage.f01bf79a79', 'check #')} - {check.checkRunId} - - )} -
- - {state?.error && ( -
-
- {state.error} -
- -
- )} - - {hasOutput && ( -
- {details?.title && ( -
- {details.title} -
- )} - {details?.summary && ( - - )} - {details?.text && ( - - )} -
- )} - - {hasAnnotations && ( -
-
- {translate('auto.components.PullRequestPage.8432d17901', 'Annotations')} -
-
- {details!.annotations.map((annotation, index) => ( -
0 && 'border-t border-border/30' - )} - > -
- - {annotation.path ?? - translate('auto.components.PullRequestPage.35a0573f41', 'Annotation')} - {annotation.startLine ? `:${annotation.startLine}` : ''} - - {annotation.annotationLevel && ( - - {annotation.annotationLevel} - - )} -
- {annotation.title && ( -
- {annotation.title} -
- )} -
- {annotation.message} -
- {annotation.rawDetails && ( -
-                          {annotation.rawDetails}
-                        
- )} -
- ))} -
-
- )} - - {hasJobs && ( -
-
- {translate('auto.components.PullRequestPage.7720c9c3f5', 'Jobs')} -
-
- {details!.jobs.map((job, index) => ( -
0 && 'border-t border-border/30' - )} - > -
- - {job.name} - - - {job.conclusion ?? - job.status ?? - translate('auto.components.PullRequestPage.77d9388fb0', 'unknown')} - -
- {job.steps.length > 0 && ( -
- {job.steps.map((step) => ( -
- {step.name} - {step.conclusion ?? step.status} -
- ))} -
- )} -
- ))} -
-
- )} - - {!state?.error && !hasOutput && !hasAnnotations && !hasJobs && ( -
- {getCheckConclusion(check) === 'action_required' - ? translate( - 'auto.components.PullRequestPage.checkActionRequiredHint', - 'Needs a manual action on GitHub (e.g. approving the run) to unblock merging.' - ) - : translate( - 'auto.components.PullRequestPage.1550675e5f', - 'No inline output is available for this check.' - )} -
- )} - - {openUrl && ( -
- -
- )} -
- )} -
- ) - } - - const fixChecksAgentDialog = ( - { - if (!open) { - setFixChecksComposerPrompt(null) - } - }} - actionId="fixChecks" - title={translate('auto.components.PullRequestPage.a053bdd082', 'Fix Broken Checks With AI')} - description={translate( - 'auto.components.PullRequestPage.ddfd42f460', - 'Review the prompt before starting an agent.' - )} - baseCommandInput={fixChecksComposerPrompt ?? ''} - connectionId={repo?.connectionId ?? null} - repoId={targetRepoId} - promptDelivery="submit-after-ready" - launchPlatform={fixChecksLaunchPlatform} - launchSource="task_page" - savedAgentId={readSourceControlLaunchRecipeAgentId(fixChecksRecipe)} - savedCommandInputTemplate={fixChecksRecipe.commandInputTemplate ?? null} - savedAgentArgs={fixChecksRecipe.agentArgs ?? null} - onSaveAgentDefault={saveFixChecksActionDefault} - onLaunched={() => { - toast.success( - translate( - 'auto.components.PullRequestPage.85e62c5266', - 'Started an AI agent for the broken checks.' - ) - ) - }} - onStart={handleStartFixChecksFromDialog} - /> - ) - - if (loading && list.length === 0) { - return ( - <> - {variant === 'compact' ? compactHeader : null} -
- -
- - ) - } - if (list.length === 0) { - if (variant === 'page') { - return ( -
-
- -
- - {translate('auto.components.PullRequestPage.45877f5089', 'No checks found')} - - - {translate( - 'auto.components.PullRequestPage.3912daf310', - 'This pull request has no reported checks yet.' - )} - -
- {actions} -
-
- ) - } - return ( - <> - {compactHeader} -
- -
- {translate('auto.components.PullRequestPage.a18d01cda3', 'No checks reported yet')} -
-
- - ) - } - if (variant === 'page') { - const countChips = getCheckCountChips(counts) - return ( - <> -
-
- 0 && counts.failing === 0 && 'animate-spin' - )} - /> -
- - {summaryLabel} - - {countChips.length > 1 && ( - - {countChips.map((chip, i) => ( - - {i > 0 && ·} - {chip.label} - - ))} - - )} -
- {actions} -
-
- {sorted.map((check, index) => ( -
0 && 'border-t border-border/40')} - > - {renderCheckRow(check)} -
- ))} -
-
- {fixChecksAgentDialog} - - ) - } - return ( - <> - {compactHeader} -
- {sorted.map(renderCheckRow)} -
- {fixChecksAgentDialog} - - ) -} - -function MentionTextarea({ - value, - onValueChange, - onKeyDown, - placeholder, - rows, - className, - wrapperClassName, - mentionOptions, - textareaRef -}: { - value: string - onValueChange: (value: string) => void - onKeyDown?: (event: React.KeyboardEvent) => void - placeholder: string - rows: number - className?: string - wrapperClassName?: string - mentionOptions: MentionOption[] - textareaRef: React.RefObject -}): React.JSX.Element { - const [mentionQuery, setMentionQuery] = useState(null) - const [activeIndex, setActiveIndex] = useState(0) - const suggestions = useMemo( - () => (mentionQuery ? filterGitHubMentionOptions(mentionOptions, mentionQuery.query) : []), - [mentionOptions, mentionQuery] - ) - const showSuggestions = mentionQuery !== null && suggestions.length > 0 - - const syncMentionQuery = useCallback((textarea: HTMLTextAreaElement): void => { - const nextQuery = findMentionQuery(textarea.value, textarea.selectionStart) - setMentionQuery(nextQuery) - setActiveIndex(0) - }, []) - - const insertMention = useCallback( - (option: MentionOption): void => { - const textarea = textareaRef.current - const caret = textarea?.selectionStart ?? value.length - const query = textarea ? findMentionQuery(value, caret) : mentionQuery - if (!query) { - return - } - const suffix = value[caret] && !/\s/.test(value[caret]) ? ' ' : '' - const inserted = `@${option.login}${suffix}` - const nextValue = `${value.slice(0, query.atIndex)}${inserted}${value.slice(caret)}` - const nextCaret = query.atIndex + inserted.length - onValueChange(nextValue) - setMentionQuery(null) - requestAnimationFrame(() => { - textarea?.focus() - textarea?.setSelectionRange(nextCaret, nextCaret) - }) - }, - [mentionQuery, onValueChange, textareaRef, value] - ) - - return ( -
- {showSuggestions && ( -
- {suggestions.map((option, index) => ( - - ))} -
- )} -